diff --git a/framework/bigcommerce/cart/use-cart.tsx b/framework/bigcommerce/cart/use-cart.tsx index ba005ec59..4f8a5cbcd 100644 --- a/framework/bigcommerce/cart/use-cart.tsx +++ b/framework/bigcommerce/cart/use-cart.tsx @@ -1,4 +1,4 @@ -import useCommerceCart, { UseCart } from '@commerce/cart/use-cart' +import useCart, { UseCart } from '@commerce/cart/use-cart' import type { BigcommerceProvider } from '..' -export default useCommerceCart as UseCart +export default useCart as UseCart diff --git a/framework/bigcommerce/index.tsx b/framework/bigcommerce/index.tsx index 83fbdbcbc..b35785ed2 100644 --- a/framework/bigcommerce/index.tsx +++ b/framework/bigcommerce/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import type { ReactNode } from 'react' import { CommerceConfig, CommerceProvider as CoreCommerceProvider, diff --git a/framework/bigcommerce/provider.ts b/framework/bigcommerce/provider.tsx similarity index 53% rename from framework/bigcommerce/provider.ts rename to framework/bigcommerce/provider.tsx index b6385546c..60106f7f8 100644 --- a/framework/bigcommerce/provider.ts +++ b/framework/bigcommerce/provider.tsx @@ -1,7 +1,10 @@ +import { useMemo } from 'react' 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 { Wishlist } from './api/wishlist' +import useCustomer from './customer/use-customer' import type { Cart } from './types' async function getText(res: Response) { @@ -43,7 +46,7 @@ const fetcher: Fetcher = async ({ const useCart: HookHandler< Cart | null, - [], + {}, FetchCartInput, any, any, @@ -53,26 +56,31 @@ const useCart: HookHandler< 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, - }, + useHook({ input, useData }) { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input.swrOptions }, }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) }, } const useWishlist: HookHandler< - Cart | null, - [], - FetchCartInput, + Wishlist | null, + { includeProducts?: boolean }, + { customerId?: number; includeProducts: boolean }, any, any, { isEmpty?: boolean } @@ -81,18 +89,44 @@ const useWishlist: HookHandler< url: '/api/bigcommerce/wishlist', method: 'GET', }, - swrOptions: { - revalidateOnFocus: false, + fetcher({ input: { customerId, includeProducts }, options, fetch }) { + if (!customerId) return null + + // Use a dummy base as we only care about the relative path + const url = new URL(options.url!, 'http://a') + + if (includeProducts) url.searchParams.set('products', '1') + + return fetch({ + url: url.pathname + url.search, + method: options.method, + }) }, - onResponse(response) { - return Object.create(response, { - isEmpty: { - get() { - return (response.data?.lineItems.length ?? 0) <= 0 - }, - enumerable: true, + useHook({ input, useData }) { + const { data: customer } = useCustomer() + const response = useData({ + input: [ + ['customerId', customer?.id], + ['includeProducts', input.includeProducts], + ], + swrOptions: { + revalidateOnFocus: false, + ...input.swrOptions, }, }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.items?.length || 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) }, } diff --git a/framework/bigcommerce/wishlist/use-wishlist.tsx b/framework/bigcommerce/wishlist/use-wishlist.tsx index 730bf19dc..dfa3d9dbc 100644 --- a/framework/bigcommerce/wishlist/use-wishlist.tsx +++ b/framework/bigcommerce/wishlist/use-wishlist.tsx @@ -1,78 +1,4 @@ -import { HookFetcher } from '@commerce/utils/types' -import { SwrOptions } from '@commerce/utils/use-data' -import useResponse from '@commerce/utils/use-response' -import useCommerceWishlist from '@commerce/wishlist/use-wishlist' -import type { Wishlist } from '../api/wishlist' -import useCustomer from '../customer/use-customer' +import useWishlist, { UseWishlist } from '@commerce/wishlist/use-wishlist' +import type { BigcommerceProvider } from '..' -const defaultOpts = { - url: '/api/bigcommerce/wishlist', - method: 'GET', -} - -export type { Wishlist } - -export interface UseWishlistOptions { - includeProducts?: boolean -} - -export interface UseWishlistInput extends UseWishlistOptions { - customerId?: number -} - -export const fetcher: HookFetcher = ( - options, - { customerId, includeProducts }, - fetch -) => { - if (!customerId) return null - - // Use a dummy base as we only care about the relative path - const url = new URL(options?.url ?? defaultOpts.url, 'http://a') - - if (includeProducts) url.searchParams.set('products', '1') - - return fetch({ - url: url.pathname + url.search, - method: options?.method ?? defaultOpts.method, - }) -} - -export function extendHook( - customFetcher: typeof fetcher, - swrOptions?: SwrOptions -) { - const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { - const { data: customer } = useCustomer() - const response = useCommerceWishlist( - defaultOpts, - [ - ['customerId', customer?.id], - ['includeProducts', includeProducts], - ], - customFetcher, - { - revalidateOnFocus: false, - ...swrOptions, - } - ) - const res = useResponse(response, { - descriptors: { - isEmpty: { - get() { - return (response.data?.items?.length || 0) <= 0 - }, - set: (x) => x, - }, - }, - }) - - return res - } - - useWishlist.extend = extendHook - - return useWishlist -} - -export default extendHook(fetcher) +export default useWishlist as UseWishlist diff --git a/framework/commerce/cart/use-cart.tsx b/framework/commerce/cart/use-cart.tsx index b19e609da..6b1a3c789 100644 --- a/framework/commerce/cart/use-cart.tsx +++ b/framework/commerce/cart/use-cart.tsx @@ -1,7 +1,11 @@ -import { useMemo } from 'react' import Cookies from 'js-cookie' import type { Cart } from '../types' -import type { HookFetcherFn } from '../utils/types' +import type { + Prop, + HookFetcherFn, + UseHookInput, + UseHookResponse, +} from '../utils/types' import useData from '../utils/use-data-2' import { Provider, useCommerce } from '..' @@ -9,18 +13,23 @@ export type FetchCartInput = { cartId?: Cart['id'] } -export type CartResponse

= ReturnType< - NonNullable['useCart']>['onResponse']> +export type UseCartHandler

= Prop< + Prop, + 'useCart' > -export type UseCart

= ( - ...input: UseCartInput

-) => CartResponse

+export type UseCartInput

= UseHookInput> -export type UseCartInput

= NonNullable< - NonNullable['useCart']>>['input'] +export type CartResponse

= UseHookResponse< + UseCartHandler

> +export type UseCart

= Partial< + UseCartInput

+> extends UseCartInput

+ ? (input?: UseCartInput

) => CartResponse

+ : (input: UseCartInput

) => CartResponse

+ export const fetcher: HookFetcherFn = async ({ options, input: { cartId }, @@ -31,25 +40,32 @@ export const fetcher: HookFetcherFn = async ({ return data && normalize ? normalize(data) : data } -export default function useCart

(...input: UseCartInput

) { +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 useHook = opts?.useHook ?? ((ctx) => ctx.useData()) + 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

+ return useHook({ + input, + useData(ctx) { + const response = useData( + { ...opts!, fetcher: wrapper }, + ctx?.input ?? [], + provider.fetcher ?? fetcherRef.current, + ctx?.swrOptions ?? input.swrOptions + ) + return response + }, + }) } diff --git a/framework/commerce/index.tsx b/framework/commerce/index.tsx index 82e86947d..cb4136e3b 100644 --- a/framework/commerce/index.tsx +++ b/framework/commerce/index.tsx @@ -8,18 +8,18 @@ import { } 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 type { Cart, Wishlist } from './types' const Commerce = createContext | {}>({}) export type Provider = CommerceConfig & { fetcher: Fetcher cart?: { - useCart?: HookHandler + useCart?: HookHandler } wishlist?: { - useWishlist?: HookHandler + useWishlist?: HookHandler } } diff --git a/framework/commerce/types.ts b/framework/commerce/types.ts index cc04f52e1..743a93e4e 100644 --- a/framework/commerce/types.ts +++ b/framework/commerce/types.ts @@ -1,3 +1,5 @@ +import type { Wishlist as BCWishlist } from '@framework/api/wishlist' + export interface Discount { // The value of the discount, can be an amount or percentage value: number @@ -87,6 +89,9 @@ export interface Cart { discounts?: Discount[] } +// TODO: Properly define this type +export interface Wishlist extends BCWishlist {} + /** * Cart mutations */ diff --git a/framework/commerce/utils/types.ts b/framework/commerce/utils/types.ts index d84ec07f0..dbde3e7ec 100644 --- a/framework/commerce/utils/types.ts +++ b/framework/commerce/utils/types.ts @@ -4,11 +4,15 @@ 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 +/** + * 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 +/** + * Core fetcher added by CommerceProvider + */ export type Fetcher = ( options: FetcherOptions ) => T | Promise @@ -29,35 +33,36 @@ export type HookFetcher = ( export type HookFetcherFn< Data, - Input = unknown, + Input = never, Result = any, Body = any > = (context: { - options: HookFetcherOptions | null + options: HookFetcherOptions input: Input fetch: (options: FetcherOptions) => Promise normalize?(data: Result): Data }) => Data | Promise -export type HookFetcherOptions = { - query?: string - url?: string - method?: string -} +export type HookFetcherOptions = { method?: string } & ( + | { query: string; url?: string } + | { query?: string; url: string } +) export type HookInputValue = string | number | boolean | undefined -export type HookInput = [string, HookInputValue][] +export type HookSwrInput = [string, HookInputValue][] export type HookFetchInput = { [k: string]: HookInputValue } +export type HookInput = {} + export type HookHandler< // Data obj returned by the hook and fetch operation Data, // Input expected by the hook - Input = [...any], + Input extends { [k: string]: unknown } = {}, // Input expected before doing a fetch operation - FetchInput extends HookFetchInput = never, + FetchInput extends HookFetchInput = {}, // Data returned by the API after a fetch operation Result = any, // Body expected by the API endpoint @@ -65,11 +70,14 @@ export type HookHandler< // Custom state added to the response object of SWR State = {} > = { - input?: Input - swrOptions?: SwrOptions - onResponse?(response: ResponseState): ResponseState & State - onMutation?: any - fetchOptions?: HookFetcherOptions + useHook?(context: { + input: Input & { swrOptions?: SwrOptions } + useData(context?: { + input?: HookFetchInput | HookSwrInput + swrOptions?: SwrOptions + }): ResponseState + }): ResponseState & State + fetchOptions: HookFetcherOptions fetcher?: HookFetcherFn normalizer?(data: Result): Data } @@ -79,3 +87,20 @@ export type SwrOptions = ConfigInterface< CommerceError, HookFetcher > + +/** + * Returns the property K from type T excluding nullables + */ +export type Prop = NonNullable + +export type UseHookParameters> = Parameters< + Prop +> + +export type UseHookResponse> = ReturnType< + Prop +> + +export type UseHookInput< + H extends HookHandler +> = UseHookParameters[0]['input'] diff --git a/framework/commerce/utils/use-data-2.ts b/framework/commerce/utils/use-data-2.ts index 5536bb02c..cc4d2cc5b 100644 --- a/framework/commerce/utils/use-data-2.ts +++ b/framework/commerce/utils/use-data-2.ts @@ -1,10 +1,11 @@ import useSWR, { responseInterface } from 'swr' import type { HookHandler, - HookInput, + HookSwrInput, HookFetchInput, PickRequired, Fetcher, + SwrOptions, } from './types' import defineProperty from './define-property' import { CommerceError } from './errors' @@ -15,8 +16,8 @@ export type ResponseState = responseInterface & { export type UseData = < Data = any, - Input = [...any], - FetchInput extends HookFetchInput = never, + Input extends { [k: string]: unknown } = {}, + FetchInput extends HookFetchInput = {}, Result = any, Body = any >( @@ -24,13 +25,15 @@ export type UseData = < HookHandler, 'fetcher' >, - input: HookInput, - fetcherFn: Fetcher + input: HookFetchInput | HookSwrInput, + fetcherFn: Fetcher, + swrOptions?: SwrOptions ) => ResponseState -const useData: UseData = (options, input, fetcherFn) => { +const useData: UseData = (options, input, fetcherFn, swrOptions) => { + const hookInput = Array.isArray(input) ? input : Object.entries(input) const fetcher = async ( - url?: string, + url: string, query?: string, method?: string, ...args: any[] @@ -40,7 +43,7 @@ const useData: UseData = (options, input, fetcherFn) => { options: { url, query, method }, // Transform the input array into an object input: args.reduce((obj, val, i) => { - obj[input[i][0]!] = val + obj[hookInput[i][0]!] = val return obj }, {}), fetch: fetcherFn, @@ -59,11 +62,11 @@ const useData: UseData = (options, input, fetcherFn) => { () => { const opts = options.fetchOptions return opts - ? [opts.url, opts.query, opts.method, ...input.map((e) => e[1])] + ? [opts.url, opts.query, opts.method, ...hookInput.map((e) => e[1])] : null }, fetcher, - options.swrOptions + swrOptions ) if (!('isLoading' in response)) { diff --git a/framework/commerce/utils/use-data.tsx b/framework/commerce/utils/use-data.tsx index 38af46a44..58a1a0a47 100644 --- a/framework/commerce/utils/use-data.tsx +++ b/framework/commerce/utils/use-data.tsx @@ -1,5 +1,5 @@ import useSWR, { ConfigInterface, responseInterface } from 'swr' -import type { HookInput, HookFetcher, HookFetcherOptions } from './types' +import type { HookSwrInput, HookFetcher, HookFetcherOptions } from './types' import defineProperty from './define-property' import { CommerceError } from './errors' import { useCommerce } from '..' @@ -16,7 +16,7 @@ export type ResponseState = responseInterface & { export type UseData = ( options: HookFetcherOptions | (() => HookFetcherOptions | null), - input: HookInput, + input: HookSwrInput, fetcherFn: HookFetcher, swrOptions?: SwrOptions ) => ResponseState diff --git a/framework/commerce/wishlist/use-wishlist.tsx b/framework/commerce/wishlist/use-wishlist.tsx index df8bebe5c..c2e0d2dc1 100644 --- a/framework/commerce/wishlist/use-wishlist.tsx +++ b/framework/commerce/wishlist/use-wishlist.tsx @@ -1,16 +1,62 @@ -import type { HookInput, HookFetcher, HookFetcherOptions } from '../utils/types' -import useData, { ResponseState, SwrOptions } from '../utils/use-data' +import type { Wishlist } from '../types' +import type { + Prop, + HookFetcherFn, + UseHookInput, + UseHookResponse, +} from '../utils/types' +import useData from '../utils/use-data-2' +import { Provider, useCommerce } from '..' -export type WishlistResponse = ResponseState & { - isEmpty?: boolean +export type UseWishlistHandler

= Prop< + Prop, + 'useWishlist' +> + +export type UseWishlistInput

= UseHookInput< + UseWishlistHandler

+> + +export type WishlistResponse

= UseHookResponse< + UseWishlistHandler

+> + +export type UseWishlist

= Partial< + WishlistResponse

+> extends WishlistResponse

+ ? (input?: WishlistResponse

) => WishlistResponse

+ : (input: WishlistResponse

) => WishlistResponse

+ +export const fetcher: HookFetcherFn = async ({ + options, + fetch, + normalize, +}) => { + const data = await fetch({ ...options }) + return data && normalize ? normalize(data) : data } -export default function useWishlist( - options: HookFetcherOptions, - input: HookInput, - fetcherFn: HookFetcher, - swrOptions?: SwrOptions -): WishlistResponse { - const response = useData(options, input, fetcherFn, swrOptions) - return response +export default function useWishlist

( + input: UseWishlistInput

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

() + + const provider = providerRef.current + const opts = provider.wishlist?.useWishlist + + const fetcherFn = opts?.fetcher ?? fetcher + const useHook = opts?.useHook ?? ((ctx) => ctx.useData()) + + return useHook({ + input, + useData(ctx) { + const response = useData( + { ...opts!, fetcher: fetcherFn }, + ctx?.input ?? [], + provider.fetcher ?? fetcherRef.current, + ctx?.swrOptions ?? input.swrOptions + ) + return response + }, + }) }