diff --git a/framework/bigcommerce/index.tsx b/framework/bigcommerce/index.tsx index a4c9fffa5..268b0bd02 100644 --- a/framework/bigcommerce/index.tsx +++ b/framework/bigcommerce/index.tsx @@ -1,11 +1,16 @@ import { ReactNode } from 'react' import * as React from 'react' +import { Fetcher } from '@commerce/utils/types' import { CommerceConfig, CommerceProvider as CoreCommerceProvider, useCommerce as useCoreCommerce, + HookHandler, } from '@commerce' import { FetcherError } from '@commerce/utils/errors' +import type { CartInput } from '@commerce/cart/use-cart' +import { normalizeCart } from './lib/normalize' +import { Cart } from './types' async function getText(res: Response) { try { @@ -23,6 +28,57 @@ async function getError(res: Response) { 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 = { + fetchOptions: { + url: '/api/bigcommerce/cart', + method: 'GET', + }, + fetcher(context) { + return undefined as any + }, + 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 }, +} + +export type BigcommerceProvider = typeof bigcommerceProvider + export const bigcommerceConfig: CommerceConfig = { locale: 'en-us', cartCookie: 'bc_cartId', @@ -52,10 +108,13 @@ export type BigcommerceProps = { export function CommerceProvider({ children, ...config }: BigcommerceProps) { return ( - + {children} ) } -export const useCommerce = () => useCoreCommerce() +export const useCommerce = () => useCoreCommerce() diff --git a/framework/commerce/cart/use-cart-2.tsx b/framework/commerce/cart/use-cart-2.tsx new file mode 100644 index 000000000..cfeb85c87 --- /dev/null +++ b/framework/commerce/cart/use-cart-2.tsx @@ -0,0 +1,28 @@ +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 '..' + +export type CartResponse = ResponseState & { isEmpty?: boolean } + +// Input expected by the `useCart` hook +export type CartInput = { + cartId?: Cart['id'] +} + +export default function useCart( + options: HookFetcherOptions, + input: HookInput, + fetcherFn: HookFetcher, + swrOptions?: SwrOptions +): CartResponse { + const { providerRef, 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) + + return response +} diff --git a/framework/commerce/cart/use-fake.tsx b/framework/commerce/cart/use-fake.tsx new file mode 100644 index 000000000..324e1c4d9 --- /dev/null +++ b/framework/commerce/cart/use-fake.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import Cookies from 'js-cookie' +import type { Cart } from '../types' +import type { HookFetcherFn } from '../utils/types' +import useData from '../utils/use-data-2' +import { Provider, useCommerce } from '..' + +// Input expected by the `useCart` hook +export type CartInput = { + cartId?: Cart['id'] +} + +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 useFake

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

() + + const provider = providerRef.current + const opts = provider.cart?.useCart + const options = opts?.fetchOptions ?? {} + const fetcherFn = opts?.fetcher ?? fetcher + const wrapper: typeof fetcher = (context) => { + context.input.cartId = Cookies.get(cartCookie) + return fetcherFn(context) + } + + const response = useData(options, [], wrapper, opts?.swrOptions) + const memoizedResponse = useMemo( + () => (opts?.onResponse ? opts.onResponse(response) : response), + [response] + ) + + return memoizedResponse +} diff --git a/framework/commerce/index.tsx b/framework/commerce/index.tsx index 506502ea2..91a1be2e0 100644 --- a/framework/commerce/index.tsx +++ b/framework/commerce/index.tsx @@ -7,36 +7,71 @@ import { useRef, } from 'react' import * as React from 'react' -import { Fetcher } from './utils/types' +import { Fetcher, HookFetcherFn, HookFetcherOptions } from './utils/types' +import { Cart } from './types' +import type { ResponseState, SwrOptions } from './utils/use-data' +import type { CartInput } from './cart/use-cart' -const Commerce = createContext({}) +const Commerce = createContext | {}>({}) -export type CommerceProps = { +export type Provider = CommerceConfig & { + cart?: { + useCart?: HookHandler + } + cartNormalizer(data: any): Cart +} + +export type HookHandler = { + swrOptions?: SwrOptions + onResponse?(response: ResponseState): ResponseState + onMutation?: any + fetchOptions?: HookFetcherOptions +} & ( + | // TODO: Maybe the normalizer is not required if it's used by the API route directly? + { + fetcher: HookFetcherFn + normalizer?(data: Result): Data + } + | { + fetcher?: never + normalizer(data: Result): Data + } +) + +export type CommerceProps

= { children?: ReactNode + provider: P config: CommerceConfig } export type CommerceConfig = { fetcher: Fetcher } & Omit< - CommerceContextValue, - 'fetcherRef' + CommerceContextValue, + 'providerRef' | 'fetcherRef' > -export type CommerceContextValue = { +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 providerRef = useRef(provider) 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, @@ -47,6 +82,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..3428b5194 100644 --- a/framework/commerce/utils/types.ts +++ b/framework/commerce/utils/types.ts @@ -15,6 +15,13 @@ export type HookFetcher = ( fetch: (options: FetcherOptions) => Promise ) => Data | Promise +export type HookFetcherFn = (context: { + options: HookFetcherOptions | null + input: Input + fetch: (options: FetcherOptions) => Promise + normalize?(data: Result): Data +}) => Data | Promise + export type HookFetcherOptions = { query?: string url?: string diff --git a/framework/commerce/utils/use-data-2.ts b/framework/commerce/utils/use-data-2.ts new file mode 100644 index 000000000..69eb223b0 --- /dev/null +++ b/framework/commerce/utils/use-data-2.ts @@ -0,0 +1,79 @@ +import useSWR, { ConfigInterface, responseInterface } from 'swr' +import type { + HookInput, + HookFetcher, + HookFetcherOptions, + HookFetcherFn, +} from './types' +import defineProperty from './define-property' +import { CommerceError } from './errors' +import { useCommerce } from '..' + +export type SwrOptions = ConfigInterface< + Data, + CommerceError, + HookFetcher +> + +export type ResponseState = responseInterface & { + isLoading: boolean +} + +export type UseData = ( + options: HookFetcherOptions | (() => HookFetcherOptions | null), + input: HookInput, + fetcherFn: HookFetcherFn, + swrOptions?: SwrOptions +) => ResponseState + +const useData: UseData = (options, input, fetcherFn, swrOptions) => { + const { fetcherRef } = useCommerce() + const fetcher = async ( + url?: string, + query?: string, + method?: string, + ...args: any[] + ) => { + try { + return await fetcherFn({ + 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: fetcherRef.current, + }) + } 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 = typeof options === 'function' ? options() : options + return opts + ? [opts.url, opts.query, opts.method, ...input.map((e) => e[1])] + : null + }, + fetcher, + swrOptions + ) + + if (!('isLoading' in response)) { + defineProperty(response, 'isLoading', { + get() { + return response.data === undefined + }, + enumerable: true, + }) + } + + return response +} + +export default useData