mirror of
https://github.com/vercel/commerce.git
synced 2025-07-04 20:21:21 +00:00
Enable text search for the Spree Framework
This commit is contained in:
parent
744a8b998e
commit
a27996a088
@ -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
|
||||
|
@ -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<typeof handler>
|
||||
export const handler: MutationHook<any> = {
|
||||
|
||||
export const handler: MutationHook<AddItemHook> = {
|
||||
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<GraphQLFetcherResult<{ data: IOrder }>>({
|
||||
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
|
||||
}, [])
|
||||
},
|
||||
}
|
||||
|
@ -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<typeof handler>
|
||||
|
||||
export const handler: SWRHook<any> = {
|
||||
// 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<GetCartHook> = {
|
||||
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<GraphQLFetcherResult<{ data: IOrder }>>({
|
||||
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<GraphQLFetcherResult<{ data: IOrder }>>({
|
||||
variables: {
|
||||
methodPath: 'cart.create',
|
||||
arguments: [],
|
||||
},
|
||||
})
|
||||
|
||||
setCartToken(spreeCartCreateSuccessResponse.data.attributes.token)
|
||||
|
||||
spreeCartResponse = spreeCartCreateSuccessResponse
|
||||
}
|
||||
|
||||
return normalizeCart(spreeCartResponse, spreeCartResponse.data)
|
||||
},
|
||||
useHook:
|
||||
({ useData }) =>
|
||||
(input) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.create(
|
||||
{},
|
||||
{
|
||||
(input = {}) => {
|
||||
console.log('useCart useHook called.')
|
||||
|
||||
const response = useData({
|
||||
swrOptions: { revalidateOnFocus: false, ...input.swrOptions },
|
||||
})
|
||||
|
||||
return useMemo<typeof response & { isEmpty: boolean }>(() => {
|
||||
return Object.create(response, {
|
||||
isEmpty: {
|
||||
get() {
|
||||
return true
|
||||
return (response.data?.lineItems.length ?? 0) === 0
|
||||
},
|
||||
enumerable: true,
|
||||
},
|
||||
}
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
}, [response])
|
||||
},
|
||||
}
|
||||
|
@ -2,8 +2,8 @@
|
||||
"provider": "spree",
|
||||
"features": {
|
||||
"wishlist": false,
|
||||
"cart": false,
|
||||
"search": false,
|
||||
"cart": true,
|
||||
"search": true,
|
||||
"customerAuth": false
|
||||
}
|
||||
}
|
||||
|
1
framework/spree/errors/MissingLineItemVariantError.ts
Normal file
1
framework/spree/errors/MissingLineItemVariantError.ts
Normal file
@ -0,0 +1 @@
|
||||
export default class MissingLineItemVariantError extends Error {}
|
@ -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',
|
||||
|
@ -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,
|
||||
|
7
framework/spree/utils/getCartToken.ts
Normal file
7
framework/spree/utils/getCartToken.ts
Normal file
@ -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
|
202
framework/spree/utils/normalizeCart.ts
Normal file
202
framework/spree/utils/normalizeCart.ts
Normal file
@ -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<ProductAttr>(
|
||||
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<ProductAttr>(
|
||||
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
|
@ -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,
|
||||
|
16
framework/spree/utils/setCartToken.ts
Normal file
16
framework/spree/utils/setCartToken.ts
Normal file
@ -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
|
21
framework/spree/utils/validateCookieExpire.ts
Normal file
21
framework/spree/utils/validateCookieExpire.ts
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user