diff --git a/framework/spree/.env.template b/framework/spree/.env.template index f6e557e57..60698d794 100644 --- a/framework/spree/.env.template +++ b/framework/spree/.env.template @@ -2,12 +2,14 @@ COMMERCE_PROVIDER=spree -{# public (available in the web browser) #} +{# - public (available in the web browser) #} NEXT_PUBLIC_SPREE_API_HOST=http://localhost:3000 NEXT_PUBLIC_SPREE_DEFAULT_LOCALE=en-us -NEXT_PUBLIC_SPREE_CART_COOKIE_NAME=spree_cart +NEXT_PUBLIC_SPREE_CART_COOKIE_NAME=spree_cart_token +{# -- cookie expire in days #} +NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE=7 NEXT_PUBLIC_SPREE_IMAGE_HOST=http://localhost:3000 NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN=localhost NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_ID=1 NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_ID=27 -NEXT_PUBLIC_SHOW_SINGLE_VARIANT_OPTIONS=false +NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS=false diff --git a/framework/spree/cart/use-add-item.tsx b/framework/spree/cart/use-add-item.tsx index 7f3d1061f..f2b7bf5ea 100644 --- a/framework/spree/cart/use-add-item.tsx +++ b/framework/spree/cart/use-add-item.tsx @@ -1,17 +1,75 @@ -import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item' -import { MutationHook } from '@commerce/utils/types' +import useAddItem from '@commerce/cart/use-add-item' +import type { UseAddItem } from '@commerce/cart/use-add-item' +import type { MutationHook } from '@commerce/utils/types' +import { useCallback } from 'react' +import useCart from './use-cart' +import type { AddItemHook } from '@commerce/types/cart' +import normalizeCart from '@framework/utils/normalizeCart' +import type { GraphQLFetcherResult } from '@commerce/api' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { AddItem } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass' +import getCartToken from '@framework/utils/getCartToken' export default useAddItem as UseAddItem -export const handler: MutationHook = { + +export const handler: MutationHook = { fetchOptions: { + url: '__UNUSED__', query: '', }, - async fetcher({ input, options, fetch }) {}, + async fetcher({ input, options, fetch }) { + console.info( + 'useAddItem fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { quantity, productId, variantId } = input + + const safeQuantity = quantity ?? 1 + + const token: IToken = { orderToken: getCartToken() } + const addItemParameters: AddItem = { + variant_id: variantId, + quantity: safeQuantity, + include: [ + 'line_items', + 'line_items.variant', + 'line_items.variant.product', + 'line_items.variant.product.images', + 'line_items.variant.images', + 'line_items.variant.option_values', + 'line_items.variant.product.option_types', + ].join(','), + } + + const { + data: { data: spreeSuccessResponse }, + } = await fetch>({ + variables: { + methodPath: 'cart.addItem', + arguments: [token, addItemParameters], + }, + }) + + return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data) + }, useHook: ({ fetch }) => () => { - return async function addItem() { - return {} - } + console.log('useAddItem useHook called.') + + const { mutate, data: cartData } = useCart() + + return useCallback(async (input) => { + const data = await fetch({ input }) + + await mutate(data, false) + + return data + }, []) }, } diff --git a/framework/spree/cart/use-cart.tsx b/framework/spree/cart/use-cart.tsx index b3e509a21..3ded3f525 100644 --- a/framework/spree/cart/use-cart.tsx +++ b/framework/spree/cart/use-cart.tsx @@ -1,42 +1,100 @@ import { useMemo } from 'react' -import { SWRHook } from '@commerce/utils/types' -import useCart, { UseCart } from '@commerce/cart/use-cart' +import type { SWRHook } from '@commerce/utils/types' +import useCart from '@commerce/cart/use-cart' +import type { UseCart } from '@commerce/cart/use-cart' +import type { GetCartHook } from '@commerce/types/cart' +import normalizeCart from '@framework/utils/normalizeCart' +import type { GraphQLFetcherResult } from '@commerce/api' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import setCartToken from '@framework/utils/setCartToken' export default useCart as UseCart -export const handler: SWRHook = { +// This handler avoids calling /api/cart. +// There doesn't seem to be a good reason to call it. +// So far, only @framework/bigcommerce uses it. +export const handler: SWRHook = { fetchOptions: { + url: '__UNUSED__', query: '', }, - async fetcher() { - return { - id: '', - createdAt: '', - currency: { code: '' }, - taxesIncluded: '', - lineItems: [], - lineItemsSubtotalPrice: '', - subtotalPrice: 0, - totalPrice: 0, + async fetcher({ input, options, fetch }) { + console.info( + 'useCart fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { cartId: cartToken } = input + let spreeCartResponse: IOrder | null + + if (!cartToken) { + spreeCartResponse = null + } else { + const spreeToken: IToken = { orderToken: cartToken } + const { + data: { data: spreeCartShowSuccessResponse }, + } = await fetch>({ + variables: { + methodPath: 'cart.show', + arguments: [ + spreeToken, + { + include: [ + 'line_items', + 'line_items.variant', + 'line_items.variant.product', + 'line_items.variant.product.images', + 'line_items.variant.images', + 'line_items.variant.option_values', + 'line_items.variant.product.option_types', + ].join(','), + }, + ], + }, + }) + + spreeCartResponse = spreeCartShowSuccessResponse } + + if (!spreeCartResponse || spreeCartResponse?.data.attributes.completed_at) { + const { + data: { data: spreeCartCreateSuccessResponse }, + } = await fetch>({ + variables: { + methodPath: 'cart.create', + arguments: [], + }, + }) + + setCartToken(spreeCartCreateSuccessResponse.data.attributes.token) + + spreeCartResponse = spreeCartCreateSuccessResponse + } + + return normalizeCart(spreeCartResponse, spreeCartResponse.data) }, useHook: ({ useData }) => - (input) => { - return useMemo( - () => - Object.create( - {}, - { - isEmpty: { - get() { - return true - }, - enumerable: true, - }, - } - ), - [] - ) + (input = {}) => { + console.log('useCart useHook called.') + + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input.swrOptions }, + }) + + return useMemo(() => { + return Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) === 0 + }, + enumerable: true, + }, + }) + }, [response]) }, } diff --git a/framework/spree/commerce.config.json b/framework/spree/commerce.config.json index 4129d4854..e1e53245c 100644 --- a/framework/spree/commerce.config.json +++ b/framework/spree/commerce.config.json @@ -2,8 +2,8 @@ "provider": "spree", "features": { "wishlist": false, - "cart": false, - "search": false, + "cart": true, + "search": true, "customerAuth": false } } diff --git a/framework/spree/errors/MissingLineItemVariantError.ts b/framework/spree/errors/MissingLineItemVariantError.ts new file mode 100644 index 000000000..d9bee0803 --- /dev/null +++ b/framework/spree/errors/MissingLineItemVariantError.ts @@ -0,0 +1 @@ +export default class MissingLineItemVariantError extends Error {} diff --git a/framework/spree/isomorphicConfig.ts b/framework/spree/isomorphicConfig.ts index 7c5245f99..8806c8b7c 100644 --- a/framework/spree/isomorphicConfig.ts +++ b/framework/spree/isomorphicConfig.ts @@ -1,16 +1,20 @@ import forceIsomorphicConfigValues from './utils/forceIsomorphicConfigValues' import requireConfig from './utils/requireConfig' +import validateCookieExpire from './utils/validateCookieExpire' const isomorphicConfig = { spreeApiHost: process.env.NEXT_PUBLIC_SPREE_API_HOST, defaultLocale: process.env.NEXT_PUBLIC_SPREE_DEFAULT_LOCALE, cartCookieName: process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_NAME, + cartCookieExpire: validateCookieExpire( + process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE + ), spreeImageHost: process.env.NEXT_PUBLIC_SPREE_IMAGE_HOST, spreeCategoriesTaxonomyId: process.env.NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_ID, spreeBrandsTaxonomyId: process.env.NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_ID, showSingleVariantOptions: - process.env.NEXT_PUBLIC_SHOW_SINGLE_VARIANT_OPTIONS === 'true', + process.env.NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS === 'true', } export default forceIsomorphicConfigValues( @@ -20,6 +24,7 @@ export default forceIsomorphicConfigValues( 'spreeApiHost', 'defaultLocale', 'cartCookieName', + 'cartCookieExpire', 'spreeImageHost', 'spreeCategoriesTaxonomyId', 'spreeBrandsTaxonomyId', diff --git a/framework/spree/utils/expandOptions.ts b/framework/spree/utils/expandOptions.ts index 105f7d947..54b77dba7 100644 --- a/framework/spree/utils/expandOptions.ts +++ b/framework/spree/utils/expandOptions.ts @@ -10,8 +10,9 @@ import type { RelationType } from '@spree/storefront-api-v2-sdk/types/interfaces import SpreeResponseContentError from '../errors/SpreeResponseContentError' import { findIncluded } from './jsonApi' -const isColorProductOption = (productOption: ProductOption) => - productOption.displayName === 'Color' +const isColorProductOption = (productOption: ProductOption) => { + return productOption.displayName === 'Color' +} const expandOptions = ( spreeSuccessResponse: JsonApiResponse, diff --git a/framework/spree/utils/getCartToken.ts b/framework/spree/utils/getCartToken.ts new file mode 100644 index 000000000..81bb3a897 --- /dev/null +++ b/framework/spree/utils/getCartToken.ts @@ -0,0 +1,7 @@ +import { requireConfigValue } from '@framework/isomorphicConfig' +import Cookies from 'js-cookie' + +const getCartToken = () => + Cookies.get(requireConfigValue('cartCookieName') as string) + +export default getCartToken diff --git a/framework/spree/utils/normalizeCart.ts b/framework/spree/utils/normalizeCart.ts new file mode 100644 index 000000000..0a4072738 --- /dev/null +++ b/framework/spree/utils/normalizeCart.ts @@ -0,0 +1,202 @@ +import type { + Cart, + LineItem, + ProductVariant, + SelectedOption, +} from '@commerce/types/cart' +import MissingLineItemVariantError from '@framework/errors/MissingLineItemVariantError' +import { requireConfigValue } from '@framework/isomorphicConfig' +import type { + JsonApiDocument, + JsonApiListResponse, + JsonApiSingleResponse, +} from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi' +import type { OrderAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import type { RelationType } from '@spree/storefront-api-v2-sdk/types/interfaces/Relationships' +import createGetAbsoluteImageUrl from './createGetAbsoluteImageUrl' +import getMediaGallery from './getMediaGallery' +import { findIncluded, findIncludedOfType } from './jsonApi' + +const isColorProductOption = (productOptionType: any) => { + // TODO: Fix type and merge with isColorProductOption in framework/spree/utils/expandOptions.ts + return productOptionType.attributes.presentation === 'Color' +} + +const normalizeVariant = ( + spreeSuccessResponse: JsonApiSingleResponse | JsonApiListResponse, + spreeVariant: JsonApiDocument +): ProductVariant => { + const productIdentifier = spreeVariant.relationships.product + .data as RelationType + const spreeProduct = findIncluded( + spreeSuccessResponse, + productIdentifier.type, + productIdentifier.id + ) + + if (spreeProduct === null) { + throw new MissingLineItemVariantError( + `Couldn't find product with id ${productIdentifier.id}.` + ) + } + + const spreeVariantImageRecords = findIncludedOfType( + spreeSuccessResponse, + spreeVariant, + 'images' + ) + + let lineItemImage + + const variantImage = getMediaGallery( + spreeVariantImageRecords, + createGetAbsoluteImageUrl(requireConfigValue('spreeImageHost') as string) + )[0] + + if (variantImage) { + lineItemImage = variantImage + } else { + const spreeProductImageRecords = findIncludedOfType( + spreeSuccessResponse, + spreeProduct, + 'images' + ) + + const productImage = getMediaGallery( + spreeProductImageRecords, + createGetAbsoluteImageUrl(requireConfigValue('spreeImageHost') as string) + )[0] + + lineItemImage = productImage + } + + return { + id: spreeVariant.id, + sku: spreeVariant.attributes.sku, + name: spreeProduct.attributes.name, + requiresShipping: true, + price: parseFloat(spreeVariant.attributes.price), + listPrice: parseFloat(spreeVariant.attributes.price), + image: lineItemImage, + isInStock: spreeVariant.attributes.in_stock, + availableForSale: spreeVariant.attributes.purchasable, + weight: spreeVariant.attributes.weight, + height: spreeVariant.attributes.height, + width: spreeVariant.attributes.width, + depth: spreeVariant.attributes.depth, + } +} + +const normalizeLineItem = ( + spreeSuccessResponse: JsonApiSingleResponse | JsonApiListResponse, + spreeLineItem: JsonApiDocument +): LineItem => { + //TODO: Replace JsonApiDocument type in spreeLineItem with more specific, new Spree line item item + const variantIdentifier = spreeLineItem.relationships.variant + .data as RelationType + const variant = findIncluded( + spreeSuccessResponse, + variantIdentifier.type, + variantIdentifier.id + ) + + if (variant === null) { + throw new MissingLineItemVariantError( + `Couldn't find variant with id ${variantIdentifier.id}.` + ) + } + + const productIdentifier = variant.relationships.product.data as RelationType + const product = findIncluded( + spreeSuccessResponse, + productIdentifier.type, + productIdentifier.id + ) + + if (product === null) { + throw new MissingLineItemVariantError( + `Couldn't find product with id ${productIdentifier.id}.` + ) + } + + const path = `/${product.attributes.slug}` + + const spreeOptionValues = findIncludedOfType( + spreeSuccessResponse, + variant, + 'option_values' + ) + + const options: SelectedOption[] = spreeOptionValues.map( + (spreeOptionValue) => { + const spreeOptionTypeIdentifier = spreeOptionValue.relationships + .option_type.data as RelationType + + const spreeOptionType = findIncluded( + spreeSuccessResponse, + spreeOptionTypeIdentifier.type, + spreeOptionTypeIdentifier.id + ) + + if (spreeOptionType === null) { + throw new MissingLineItemVariantError( + `Couldn't find option type with id ${spreeOptionTypeIdentifier.id}.` + ) + } + + const label = isColorProductOption(spreeOptionType) + ? spreeOptionValue.attributes.name + : spreeOptionValue.attributes.presentation + + return { + id: spreeOptionValue.id, + name: spreeOptionType.attributes.presentation, + value: label, + } + } + ) + + return { + id: spreeLineItem.id, + variantId: variant.id, + productId: productIdentifier.id, + name: spreeLineItem.attributes.name, + quantity: spreeLineItem.attributes.quantity, + discounts: [], // TODO: Retrieve from Spree + path, + variant: normalizeVariant(spreeSuccessResponse, variant), + options, + } +} + +const normalizeCart = ( + spreeSuccessResponse: JsonApiSingleResponse | JsonApiListResponse, + spreeCart: OrderAttr +): Cart => { + const lineItems = findIncludedOfType( + spreeSuccessResponse, + spreeCart, + 'line_items' + ).map((lineItem) => normalizeLineItem(spreeSuccessResponse, lineItem)) + + return { + id: spreeCart.id, + createdAt: spreeCart.attributes.created_at.toString(), + currency: { code: spreeCart.attributes.currency }, + taxesIncluded: true, + lineItems, + lineItemsSubtotalPrice: parseFloat(spreeCart.attributes.item_total), + // TODO: We need a value from Spree which includes item total and discounts in one value for subtotalPrice. + subtotalPrice: parseFloat(spreeCart.attributes.item_total), + totalPrice: parseFloat(spreeCart.attributes.total), + customerId: spreeCart.attributes.token, + email: spreeCart.attributes.email, + discounts: [], + // discounts: [{value: number}] // TODO: Retrieve from Spree + } +} + +export { normalizeLineItem } + +export default normalizeCart diff --git a/framework/spree/utils/normalizeProduct.ts b/framework/spree/utils/normalizeProduct.ts index f3ace745b..bc02301ae 100644 --- a/framework/spree/utils/normalizeProduct.ts +++ b/framework/spree/utils/normalizeProduct.ts @@ -1,4 +1,5 @@ import type { + Product, ProductOption, ProductPrice, ProductVariant, @@ -18,7 +19,7 @@ import { findIncludedOfType } from './jsonApi' const normalizeProduct = ( spreeSuccessResponse: JsonApiSingleResponse | JsonApiListResponse, spreeProduct: ProductAttr -) => { +): Product => { const spreeImageRecords = findIncludedOfType( spreeSuccessResponse, spreeProduct, diff --git a/framework/spree/utils/setCartToken.ts b/framework/spree/utils/setCartToken.ts new file mode 100644 index 000000000..68caaa19f --- /dev/null +++ b/framework/spree/utils/setCartToken.ts @@ -0,0 +1,16 @@ +import { requireConfigValue } from '@framework/isomorphicConfig' +import Cookies from 'js-cookie' + +const setCartToken = (cartToken: string) => { + const cookieOptions = { + expires: requireConfigValue('cartCookieExpire') as number, + } + + Cookies.set( + requireConfigValue('cartCookieName') as string, + cartToken, + cookieOptions + ) +} + +export default setCartToken diff --git a/framework/spree/utils/validateCookieExpire.ts b/framework/spree/utils/validateCookieExpire.ts new file mode 100644 index 000000000..35e043439 --- /dev/null +++ b/framework/spree/utils/validateCookieExpire.ts @@ -0,0 +1,21 @@ +const validateCookieExpire = (expire: unknown) => { + let expireInteger: number + + if (typeof expire === 'string') { + expireInteger = parseFloat(expire || '') + } else if (typeof expire === 'number') { + expireInteger = expire + } else { + throw new TypeError( + 'expire must be a string containing a number or an integer.' + ) + } + + if (expireInteger < 0) { + throw new RangeError('expire must be non-negative.') + } + + return expireInteger +} + +export default validateCookieExpire