Enable text search for the Spree Framework

This commit is contained in:
tniezg 2021-08-16 14:47:27 +02:00
parent 744a8b998e
commit a27996a088
12 changed files with 417 additions and 45 deletions

View File

@ -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

View File

@ -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
}, [])
},
}

View File

@ -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])
},
}

View File

@ -2,8 +2,8 @@
"provider": "spree",
"features": {
"wishlist": false,
"cart": false,
"search": false,
"cart": true,
"search": true,
"customerAuth": false
}
}

View File

@ -0,0 +1 @@
export default class MissingLineItemVariantError extends Error {}

View File

@ -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',

View File

@ -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,

View 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

View 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

View File

@ -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,

View 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

View 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