diff --git a/components/common/UserNav/UserNav.tsx b/components/common/UserNav/UserNav.tsx index f8e6373d9..c615c18b1 100644 --- a/components/common/UserNav/UserNav.tsx +++ b/components/common/UserNav/UserNav.tsx @@ -1,6 +1,7 @@ import { FC } from 'react' import Link from 'next/link' import cn from 'classnames' +import type { LineItem } from '@framework/types' import useCart from '@framework/cart/use-cart' import useCustomer from '@framework/customer/use-customer' import { Heart, Bag } from '@components/icons' @@ -15,7 +16,7 @@ interface Props { const countItem = (count: number, item: LineItem) => count + item.quantity -const UserNav: FC = ({ className, children }) => { +const UserNav: FC = ({ className }) => { const { data } = useCart() const { data: customer } = useCustomer() const { toggleSidebar, closeSidebarIfPresent, openModal } = useUI() diff --git a/components/wishlist/WishlistCard/WishlistCard.tsx b/components/wishlist/WishlistCard/WishlistCard.tsx index 82147f575..d1a9403b3 100644 --- a/components/wishlist/WishlistCard/WishlistCard.tsx +++ b/components/wishlist/WishlistCard/WishlistCard.tsx @@ -42,8 +42,8 @@ const WishlistCard: FC = ({ product }) => { setLoading(true) try { await addItem({ - productId: Number(product.id), - variantId: Number(product.variants[0].id), + productId: product.id, + variantId: product.variants[0].id, }) openSidebar() setLoading(false) diff --git a/framework/bigcommerce/cart/use-cart.tsx b/framework/bigcommerce/cart/use-cart.tsx index afa37ec98..ba005ec59 100644 --- a/framework/bigcommerce/cart/use-cart.tsx +++ b/framework/bigcommerce/cart/use-cart.tsx @@ -1,52 +1,4 @@ -import type { HookFetcher } from '@commerce/utils/types' -import type { SwrOptions } from '@commerce/utils/use-data' -import useResponse from '@commerce/utils/use-response' -import useCommerceCart, { CartInput } from '@commerce/cart/use-cart' -import { normalizeCart } from '../lib/normalize' -import type { Cart, BigcommerceCart } from '../types' +import useCommerceCart, { UseCart } from '@commerce/cart/use-cart' +import type { BigcommerceProvider } from '..' -const defaultOpts = { - url: '/api/bigcommerce/cart', - method: 'GET', -} - -export const fetcher: HookFetcher = async ( - options, - { cartId }, - fetch -) => { - const data = cartId - ? await fetch({ ...defaultOpts, ...options }) - : null - return data && normalizeCart(data) -} - -export function extendHook( - customFetcher: typeof fetcher, - swrOptions?: SwrOptions -) { - const useCart = () => { - const response = useCommerceCart(defaultOpts, [], customFetcher, { - revalidateOnFocus: false, - ...swrOptions, - }) - const res = useResponse(response, { - descriptors: { - isEmpty: { - get() { - return (response.data?.lineItems.length ?? 0) <= 0 - }, - enumerable: true, - }, - }, - }) - - return res - } - - useCart.extend = extendHook - - return useCart -} - -export default extendHook(fetcher) +export default useCommerceCart as UseCart diff --git a/framework/bigcommerce/index.tsx b/framework/bigcommerce/index.tsx index a4c9fffa5..83fbdbcbc 100644 --- a/framework/bigcommerce/index.tsx +++ b/framework/bigcommerce/index.tsx @@ -1,46 +1,17 @@ import { ReactNode } from 'react' -import * as React from 'react' import { CommerceConfig, CommerceProvider as CoreCommerceProvider, useCommerce as useCoreCommerce, } from '@commerce' -import { FetcherError } from '@commerce/utils/errors' +import { bigcommerceProvider, BigcommerceProvider } from './provider' -async function getText(res: Response) { - try { - return (await res.text()) || res.statusText - } catch (error) { - return res.statusText - } -} - -async function getError(res: Response) { - if (res.headers.get('Content-Type')?.includes('application/json')) { - const data = await res.json() - return new FetcherError({ errors: data.errors, status: res.status }) - } - return new FetcherError({ message: await getText(res), status: res.status }) -} +export { bigcommerceProvider } +export type { BigcommerceProvider } export const bigcommerceConfig: CommerceConfig = { locale: 'en-us', cartCookie: 'bc_cartId', - async fetcher({ url, method = 'GET', variables, body: bodyObj }) { - const hasBody = Boolean(variables || bodyObj) - const body = hasBody - ? JSON.stringify(variables ? { variables } : bodyObj) - : undefined - const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined - const res = await fetch(url!, { method, body, headers }) - - if (res.ok) { - const { data } = await res.json() - return data - } - - throw await getError(res) - }, } export type BigcommerceConfig = Partial @@ -52,10 +23,13 @@ export type BigcommerceProps = { export function CommerceProvider({ children, ...config }: BigcommerceProps) { return ( - + {children} ) } -export const useCommerce = () => useCoreCommerce() +export const useCommerce = () => useCoreCommerce() diff --git a/framework/bigcommerce/provider.ts b/framework/bigcommerce/provider.ts new file mode 100644 index 000000000..b6385546c --- /dev/null +++ b/framework/bigcommerce/provider.ts @@ -0,0 +1,108 @@ +import { FetcherError } from '@commerce/utils/errors' +import type { Fetcher, HookHandler } from '@commerce/utils/types' +import type { FetchCartInput } from '@commerce/cart/use-cart' +import { normalizeCart } from './lib/normalize' +import type { Cart } from './types' + +async function getText(res: Response) { + try { + return (await res.text()) || res.statusText + } catch (error) { + return res.statusText + } +} + +async function getError(res: Response) { + if (res.headers.get('Content-Type')?.includes('application/json')) { + const data = await res.json() + return new FetcherError({ errors: data.errors, status: res.status }) + } + return new FetcherError({ message: await getText(res), status: res.status }) +} + +const fetcher: Fetcher = async ({ + url, + method = 'GET', + variables, + body: bodyObj, +}) => { + const hasBody = Boolean(variables || bodyObj) + const body = hasBody + ? JSON.stringify(variables ? { variables } : bodyObj) + : undefined + const headers = hasBody ? { 'Content-Type': 'application/json' } : undefined + const res = await fetch(url!, { method, body, headers }) + + if (res.ok) { + const { data } = await res.json() + return data + } + + throw await getError(res) +} + +const useCart: HookHandler< + Cart | null, + [], + FetchCartInput, + any, + any, + { isEmpty?: boolean } +> = { + fetchOptions: { + url: '/api/bigcommerce/cart', + method: 'GET', + }, + swrOptions: { + revalidateOnFocus: false, + }, + normalizer: normalizeCart, + onResponse(response) { + return Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) <= 0 + }, + enumerable: true, + }, + }) + }, +} + +const useWishlist: HookHandler< + Cart | null, + [], + FetchCartInput, + any, + any, + { isEmpty?: boolean } +> = { + fetchOptions: { + url: '/api/bigcommerce/wishlist', + method: 'GET', + }, + swrOptions: { + revalidateOnFocus: false, + }, + onResponse(response) { + return Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) <= 0 + }, + enumerable: true, + }, + }) + }, +} + +export const bigcommerceProvider = { + locale: 'en-us', + cartCookie: 'bc_cartId', + fetcher, + cartNormalizer: normalizeCart, + cart: { useCart }, + wishlist: { useWishlist }, +} + +export type BigcommerceProvider = typeof bigcommerceProvider diff --git a/framework/commerce/cart/use-cart.tsx b/framework/commerce/cart/use-cart.tsx index 0a7ba49ee..b19e609da 100644 --- a/framework/commerce/cart/use-cart.tsx +++ b/framework/commerce/cart/use-cart.tsx @@ -1,28 +1,55 @@ +import { useMemo } from 'react' import Cookies from 'js-cookie' -import type { HookInput, HookFetcher, HookFetcherOptions } from '../utils/types' -import useData, { ResponseState, SwrOptions } from '../utils/use-data' import type { Cart } from '../types' -import { useCommerce } from '..' +import type { HookFetcherFn } from '../utils/types' +import useData from '../utils/use-data-2' +import { Provider, useCommerce } from '..' -export type CartResponse = ResponseState & { isEmpty?: boolean } - -// Input expected by the `useCart` hook -export type CartInput = { +export type FetchCartInput = { cartId?: Cart['id'] } -export default function useCart( - options: HookFetcherOptions, - input: HookInput, - fetcherFn: HookFetcher, - swrOptions?: SwrOptions -): CartResponse { - const { cartCookie } = useCommerce() - const fetcher: typeof fetcherFn = (options, input, fetch) => { - input.cartId = Cookies.get(cartCookie) - return fetcherFn(options, input, fetch) - } - const response = useData(options, input, fetcher, swrOptions) +export type CartResponse

= ReturnType< + NonNullable['useCart']>['onResponse']> +> - return response +export type UseCart

= ( + ...input: UseCartInput

+) => CartResponse

+ +export type UseCartInput

= NonNullable< + NonNullable['useCart']>>['input'] +> + +export const fetcher: HookFetcherFn = async ({ + options, + input: { cartId }, + fetch, + normalize, +}) => { + const data = cartId ? await fetch({ ...options }) : null + return data && normalize ? normalize(data) : data +} + +export default function useCart

(...input: UseCartInput

) { + const { providerRef, fetcherRef, cartCookie } = useCommerce

() + + const provider = providerRef.current + const opts = provider.cart?.useCart + const fetcherFn = opts?.fetcher ?? fetcher + const wrapper: typeof fetcher = (context) => { + context.input.cartId = Cookies.get(cartCookie) + return fetcherFn(context) + } + const response = useData( + { ...opts, fetcher: wrapper }, + input, + provider.fetcher ?? fetcherRef.current + ) + const memoizedResponse = useMemo( + () => (opts?.onResponse ? opts.onResponse(response) : response), + [response] + ) + + return memoizedResponse as CartResponse

} diff --git a/framework/commerce/index.tsx b/framework/commerce/index.tsx index 506502ea2..82e86947d 100644 --- a/framework/commerce/index.tsx +++ b/framework/commerce/index.tsx @@ -7,36 +7,57 @@ import { useRef, } from 'react' import * as React from 'react' -import { Fetcher } from './utils/types' +import { Fetcher, HookHandler } from './utils/types' +import { Cart } from './types' +import type { FetchCartInput } from './cart/use-cart' -const Commerce = createContext({}) +const Commerce = createContext | {}>({}) -export type CommerceProps = { +export type Provider = CommerceConfig & { + fetcher: Fetcher + cart?: { + useCart?: HookHandler + } + wishlist?: { + useWishlist?: HookHandler + } +} + +export type CommerceProps

= { children?: ReactNode + provider: P config: CommerceConfig } -export type CommerceConfig = { fetcher: Fetcher } & Omit< - CommerceContextValue, - 'fetcherRef' +export type CommerceConfig = Omit< + CommerceContextValue, + 'providerRef' | 'fetcherRef' > -export type CommerceContextValue = { - fetcherRef: MutableRefObject> +export type CommerceContextValue

= { + providerRef: MutableRefObject

+ fetcherRef: MutableRefObject locale: string cartCookie: string } -export function CommerceProvider({ children, config }: CommerceProps) { +export function CommerceProvider

({ + provider, + children, + config, +}: CommerceProps

) { if (!config) { throw new Error('CommerceProvider requires a valid config object') } - const fetcherRef = useRef(config.fetcher) + const providerRef = useRef(provider) + // TODO: Remove the fetcherRef + const fetcherRef = useRef(provider.fetcher) // Because the config is an object, if the parent re-renders this provider // will re-render every consumer unless we memoize the config const cfg = useMemo( () => ({ + providerRef, fetcherRef, locale: config.locale, cartCookie: config.cartCookie, @@ -47,6 +68,6 @@ export function CommerceProvider({ children, config }: CommerceProps) { return {children} } -export function useCommerce() { - return useContext(Commerce) as T +export function useCommerce

() { + return useContext(Commerce) as CommerceContextValue

} diff --git a/framework/commerce/utils/types.ts b/framework/commerce/utils/types.ts index 010205f62..d84ec07f0 100644 --- a/framework/commerce/utils/types.ts +++ b/framework/commerce/utils/types.ts @@ -1,5 +1,17 @@ +import type { ConfigInterface } from 'swr' +import type { CommerceError } from './errors' +import type { ResponseState } from './use-data' + +export type Override = Omit & K + +// Returns the properties in T with the properties in type K changed from optional to required +export type PickRequired = Omit & + Required> + // Core fetcher added by CommerceProvider -export type Fetcher = (options: FetcherOptions) => T | Promise +export type Fetcher = ( + options: FetcherOptions +) => T | Promise export type FetcherOptions = { url?: string @@ -15,12 +27,55 @@ export type HookFetcher = ( fetch: (options: FetcherOptions) => Promise ) => Data | Promise +export type HookFetcherFn< + Data, + Input = unknown, + Result = any, + Body = any +> = (context: { + options: HookFetcherOptions | null + input: Input + fetch: (options: FetcherOptions) => Promise + normalize?(data: Result): Data +}) => Data | Promise + export type HookFetcherOptions = { query?: string url?: string method?: string } -export type HookInput = [string, string | number | boolean | undefined][] +export type HookInputValue = string | number | boolean | undefined -export type Override = Omit & K +export type HookInput = [string, HookInputValue][] + +export type HookFetchInput = { [k: string]: HookInputValue } + +export type HookHandler< + // Data obj returned by the hook and fetch operation + Data, + // Input expected by the hook + Input = [...any], + // Input expected before doing a fetch operation + FetchInput extends HookFetchInput = never, + // Data returned by the API after a fetch operation + Result = any, + // Body expected by the API endpoint + Body = any, + // Custom state added to the response object of SWR + State = {} +> = { + input?: Input + swrOptions?: SwrOptions + onResponse?(response: ResponseState): ResponseState & State + onMutation?: any + fetchOptions?: HookFetcherOptions + fetcher?: HookFetcherFn + normalizer?(data: Result): Data +} + +export type SwrOptions = ConfigInterface< + Data, + CommerceError, + HookFetcher +> diff --git a/framework/commerce/utils/use-data-2.ts b/framework/commerce/utils/use-data-2.ts new file mode 100644 index 000000000..5536bb02c --- /dev/null +++ b/framework/commerce/utils/use-data-2.ts @@ -0,0 +1,81 @@ +import useSWR, { responseInterface } from 'swr' +import type { + HookHandler, + HookInput, + HookFetchInput, + PickRequired, + Fetcher, +} from './types' +import defineProperty from './define-property' +import { CommerceError } from './errors' + +export type ResponseState = responseInterface & { + isLoading: boolean +} + +export type UseData = < + Data = any, + Input = [...any], + FetchInput extends HookFetchInput = never, + Result = any, + Body = any +>( + options: PickRequired< + HookHandler, + 'fetcher' + >, + input: HookInput, + fetcherFn: Fetcher +) => ResponseState + +const useData: UseData = (options, input, fetcherFn) => { + const fetcher = async ( + url?: string, + query?: string, + method?: string, + ...args: any[] + ) => { + try { + return await options.fetcher({ + options: { url, query, method }, + // Transform the input array into an object + input: args.reduce((obj, val, i) => { + obj[input[i][0]!] = val + return obj + }, {}), + fetch: fetcherFn, + normalize: options.normalizer, + }) + } catch (error) { + // SWR will not log errors, but any error that's not an instance + // of CommerceError is not welcomed by this hook + if (!(error instanceof CommerceError)) { + console.error(error) + } + throw error + } + } + const response = useSWR( + () => { + const opts = options.fetchOptions + return opts + ? [opts.url, opts.query, opts.method, ...input.map((e) => e[1])] + : null + }, + fetcher, + options.swrOptions + ) + + if (!('isLoading' in response)) { + defineProperty(response, 'isLoading', { + get() { + return response.data === undefined + }, + enumerable: true, + }) + } + + return response +} + +export default useData