diff --git a/components/common/UserNav/UserNav.tsx b/components/common/UserNav/UserNav.tsx index c615c18b1..f8e6373d9 100644 --- a/components/common/UserNav/UserNav.tsx +++ b/components/common/UserNav/UserNav.tsx @@ -1,7 +1,6 @@ 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' @@ -16,7 +15,7 @@ interface Props { const countItem = (count: number, item: LineItem) => count + item.quantity -const UserNav: FC = ({ className }) => { +const UserNav: FC = ({ className, children }) => { 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 d1a9403b3..82147f575 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: product.id, - variantId: product.variants[0].id, + productId: Number(product.id), + variantId: Number(product.variants[0].id), }) openSidebar() setLoading(false) diff --git a/framework/bigcommerce/cart/use-cart.tsx b/framework/bigcommerce/cart/use-cart.tsx index ba005ec59..afa37ec98 100644 --- a/framework/bigcommerce/cart/use-cart.tsx +++ b/framework/bigcommerce/cart/use-cart.tsx @@ -1,4 +1,52 @@ -import useCommerceCart, { UseCart } from '@commerce/cart/use-cart' -import type { BigcommerceProvider } from '..' +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' -export default useCommerceCart as UseCart +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) diff --git a/framework/bigcommerce/index.tsx b/framework/bigcommerce/index.tsx index 83fbdbcbc..a4c9fffa5 100644 --- a/framework/bigcommerce/index.tsx +++ b/framework/bigcommerce/index.tsx @@ -1,17 +1,46 @@ import { ReactNode } from 'react' +import * as React from 'react' import { CommerceConfig, CommerceProvider as CoreCommerceProvider, useCommerce as useCoreCommerce, } from '@commerce' -import { bigcommerceProvider, BigcommerceProvider } from './provider' +import { FetcherError } from '@commerce/utils/errors' -export { bigcommerceProvider } -export type { BigcommerceProvider } +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 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 @@ -23,13 +52,10 @@ 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 deleted file mode 100644 index b6385546c..000000000 --- a/framework/bigcommerce/provider.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 b19e609da..0a7ba49ee 100644 --- a/framework/commerce/cart/use-cart.tsx +++ b/framework/commerce/cart/use-cart.tsx @@ -1,55 +1,28 @@ -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 type { HookFetcherFn } from '../utils/types' -import useData from '../utils/use-data-2' -import { Provider, useCommerce } from '..' +import { useCommerce } from '..' -export type FetchCartInput = { +export type CartResponse = ResponseState & { isEmpty?: boolean } + +// Input expected by the `useCart` hook +export type CartInput = { cartId?: Cart['id'] } -export type CartResponse

= ReturnType< - NonNullable['useCart']>['onResponse']> -> - -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) +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( - { ...opts, fetcher: wrapper }, - input, - provider.fetcher ?? fetcherRef.current - ) - const memoizedResponse = useMemo( - () => (opts?.onResponse ? opts.onResponse(response) : response), - [response] - ) + const response = useData(options, input, fetcher, swrOptions) - return memoizedResponse as CartResponse

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

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

= { - providerRef: MutableRefObject

- fetcherRef: MutableRefObject +export type CommerceContextValue = { + fetcherRef: MutableRefObject> locale: string cartCookie: string } -export function CommerceProvider

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

) { +export function CommerceProvider({ children, config }: CommerceProps) { if (!config) { throw new Error('CommerceProvider requires a valid config object') } - const providerRef = useRef(provider) - // TODO: Remove the fetcherRef - const fetcherRef = useRef(provider.fetcher) + const fetcherRef = useRef(config.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, @@ -68,6 +47,6 @@ export function CommerceProvider

({ return {children} } -export function useCommerce

() { - return useContext(Commerce) as CommerceContextValue

+export function useCommerce() { + return useContext(Commerce) as T } diff --git a/framework/commerce/utils/types.ts b/framework/commerce/utils/types.ts index d84ec07f0..010205f62 100644 --- a/framework/commerce/utils/types.ts +++ b/framework/commerce/utils/types.ts @@ -1,17 +1,5 @@ -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 @@ -27,55 +15,12 @@ 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 HookInputValue = string | number | boolean | undefined +export type HookInput = [string, string | number | boolean | undefined][] -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 -> +export type Override = Omit & K diff --git a/framework/commerce/utils/use-data-2.ts b/framework/commerce/utils/use-data-2.ts deleted file mode 100644 index 5536bb02c..000000000 --- a/framework/commerce/utils/use-data-2.ts +++ /dev/null @@ -1,81 +0,0 @@ -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