UNPKG

@techsio/storefront-data

Version:

Medusa-first storefront data layer built on TanStack Query.

533 lines (532 loc) • 22.9 kB
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useEffect, useSyncExternalStore } from "react"; import { assertStorefrontAddressValidation } from "../shared/address.js"; import { createCacheConfig, getPrefetchCacheOptions } from "../shared/cache-config.js"; import { cancelCartCaches, invalidateCartCaches, syncCartCaches } from "../shared/cart-cache-sync.js"; import { toErrorMessage } from "../shared/error-utils.js"; import { omitKeys } from "../shared/object-utils.js"; import { shouldSkipPrefetch } from "../shared/prefetch.js"; import { applyRegion } from "../shared/region.js"; import { useRegionContext } from "../shared/region-context.js"; import { createCartQueryKeys } from "./query-keys.js"; const cartPayloadOmitKeys = [ "cartId", "autoCreate", "autoUpdateRegion", "enabled", "variantId", "quantity", "useSameAddress", "shippingAddress", "billingAddress", "salesChannelId" ]; const addLineItemPayloadOmitKeys = [ "cartId", "autoCreate", "autoUpdateRegion", "enabled", "region_id", "country_code", "salesChannelId" ]; const updateLineItemPayloadOmitKeys = [ "cartId", "lineItemId", "enabled", "autoCreate", "autoUpdateRegion" ]; const noopUnsubscribe = ()=>{}; const hasObservableCartStorage = (storage)=>Boolean(storage?.subscribe && storage.getSnapshot); const normalizeCartCreatePayload = (input)=>{ const normalizedInput = input; const payload = omitKeys(normalizedInput, cartPayloadOmitKeys); const salesChannelId = normalizedInput.salesChannelId; if (!salesChannelId) return payload; return { ...payload, sales_channel_id: salesChannelId }; }; const normalizeCartUpdatePayload = (input)=>{ const normalizedInput = input; const payload = omitKeys(normalizedInput, cartPayloadOmitKeys); const salesChannelId = normalizedInput.salesChannelId; if (!salesChannelId) return payload; return { ...payload, sales_channel_id: salesChannelId }; }; const normalizeAddLineItemPayload = (input)=>omitKeys(input, addLineItemPayloadOmitKeys); const normalizeUpdateLineItemPayload = (input)=>omitKeys(input, updateLineItemPayloadOmitKeys); const getItemCount = (cart)=>{ if (!cart?.items?.length) return 0; return cart.items.reduce((acc, item)=>acc + (item.quantity ?? 0), 0); }; function createCartHooks({ service, buildCreateParams, buildUpdateParams, buildAddParams, buildCreateInputFromAddInput, buildUpdateItemParams, queryKeys, queryKeyNamespace = "storefront-data", cacheConfig, requireRegion = true, cartStorage, isNotFoundError, addressAdapter, invalidateOnSuccess = false }) { const resolvedCacheConfig = cacheConfig ?? createCacheConfig(); const resolvedQueryKeys = queryKeys ?? createCartQueryKeys(queryKeyNamespace); const buildCreate = buildCreateParams ?? ((input)=>normalizeCartCreatePayload(input)); const buildUpdate = buildUpdateParams ?? ((input)=>normalizeCartUpdatePayload(input)); const buildAdd = buildAddParams ?? ((input)=>normalizeAddLineItemPayload(input)); const buildCreateInputFromAdd = buildCreateInputFromAddInput ?? ((input)=>input); const buildUpdateItem = buildUpdateItemParams ?? ((input)=>normalizeUpdateLineItemPayload(input)); const normalizeAddressInput = (input, scope)=>addressAdapter?.normalize ? addressAdapter.normalize(input, { scope }) : input; const validateAddressInput = (input, scope)=>{ assertStorefrontAddressValidation(addressAdapter?.validate?.(input, { scope })); }; const buildAddressPayload = (input, scope)=>addressAdapter?.toPayload ? addressAdapter.toPayload(input, { scope }) : input; const readStoredCartId = ()=>{ if (!cartStorage) return null; return cartStorage.getSnapshot?.() ?? cartStorage.get(); }; const subscribeToStoredCart = (listener)=>{ if (!hasObservableCartStorage(cartStorage)) return noopUnsubscribe; return cartStorage.subscribe(listener); }; const getStoredCartServerSnapshot = ()=>cartStorage?.getServerSnapshot?.() ?? null; const useStoredCartId = ()=>useSyncExternalStore(subscribeToStoredCart, readStoredCartId, getStoredCartServerSnapshot); const resolveCartId = (inputCartId)=>inputCartId ?? readStoredCartId(); const persistCartId = (cartId)=>{ cartStorage?.set(cartId); }; const clearCartId = ()=>{ cartStorage?.clear(); }; const callUpdateCart = (cartId, params)=>{ if (!service.updateCart) throw new Error("updateCart service is not configured"); return service.updateCart.call(service, cartId, params); }; const requireCartId = (inputCartId)=>{ const cartId = resolveCartId(inputCartId); if (!cartId) throw new Error("Cart id is required"); return cartId; }; const resolveBillingAddressInput = (useSameAddress, billingAddress, normalizedShipping)=>{ if (useSameAddress || !billingAddress) return normalizedShipping; return normalizeAddressInput(billingAddress, "billing"); }; const resolveAddressMutationInput = (input)=>{ const { shippingAddress, billingAddress, useSameAddress, ...restInput } = input; const normalizedShipping = normalizeAddressInput(shippingAddress, "shipping"); const resolvedBillingInput = resolveBillingAddressInput(useSameAddress, billingAddress, normalizedShipping); return { restInput, normalizedShipping, resolvedBillingInput }; }; const validateAddressInputs = (shippingInput, billingInput)=>{ validateAddressInput(shippingInput, "shipping"); if (!billingInput) return; validateAddressInput(billingInput, "billing"); }; const buildAddressUpdateInput = (restInput, normalizedShipping, resolvedBillingInput)=>({ ...restInput, shipping_address: buildAddressPayload(normalizedShipping, "shipping"), billing_address: resolvedBillingInput ? buildAddressPayload(resolvedBillingInput, "billing") : void 0 }); const invalidateCart = (queryClient, cart)=>{ const cartId = cart?.id; if (!(invalidateOnSuccess && cartId)) return; invalidateCartCaches(queryClient, resolvedQueryKeys, cartId); }; const syncMutationCart = async (queryClient, cart)=>{ const cancellation = cancelCartCaches(queryClient, resolvedQueryKeys, cart.id); syncCartCaches(queryClient, resolvedQueryKeys, cart); invalidateCart(queryClient, cart); await cancellation; }; const createCartFromInput = async (input)=>{ const created = await service.createCart(buildCreate(input)); persistCartId(created.id); return created; }; const loadCart = async ({ input, cartId, canCreate, autoUpdateRegion, signal })=>{ const resolvedCartId = cartId ?? resolveCartId(input.cartId); const createIfAllowed = ()=>{ if (!canCreate) return Promise.resolve(null); return createCartFromInput(input); }; const resolveRegionUpdate = (cart)=>{ if (autoUpdateRegion && input.region_id && cart.region_id && cart.region_id !== input.region_id && service.updateCart) return service.updateCart(cart.id, buildUpdate({ ...input, region_id: input.region_id })); return Promise.resolve(cart); }; if (!resolvedCartId) return createIfAllowed(); try { const cart = await service.retrieveCart(resolvedCartId, signal); if (!cart) { clearCartId(); return createIfAllowed(); } return resolveRegionUpdate(cart); } catch (error) { if (!isNotFoundError?.(error)) throw error; clearCartId(); return createIfAllowed(); } }; const syncCartCache = (queryClient, cart, previousCartId, previousRegionId)=>{ if (!cart?.id) return; const sourceKey = resolvedQueryKeys.active({ cartId: previousCartId, regionId: previousRegionId }); if (queryClient.getQueryData(sourceKey) !== cart) return; syncCartCaches(queryClient, resolvedQueryKeys, cart); if (previousCartId !== cart.id) { const previousKey = resolvedQueryKeys.active({ cartId: previousCartId, regionId: previousRegionId }); queryClient.removeQueries({ queryKey: previousKey, exact: true }); } }; function useCart(input, options) { const queryClient = useQueryClient(); const contextRegion = useRegionContext(); const storedCartId = useStoredCartId(); const resolvedInput = applyRegion(input, contextRegion ?? void 0); const cartId = resolvedInput.cartId ?? storedCartId; const autoCreate = resolvedInput.autoCreate ?? true; const autoUpdateRegion = resolvedInput.autoUpdateRegion ?? true; const canCreate = autoCreate && (!requireRegion || Boolean(resolvedInput.region_id)); const enabled = resolvedInput.enabled ?? (Boolean(cartId) || canCreate); const query = useQuery({ queryKey: resolvedQueryKeys.active({ cartId, regionId: resolvedInput.region_id ?? null }), queryFn: ({ signal })=>loadCart({ input: resolvedInput, cartId, canCreate, autoUpdateRegion, signal }), enabled, ...resolvedCacheConfig.realtime, ...options?.queryOptions ?? {} }); const { data, isLoading, isFetching, isSuccess, error } = query; const cart = data ?? null; const itemCount = getItemCount(cart); useEffect(()=>{ syncCartCache(queryClient, cart, cartId, resolvedInput.region_id ?? null); }, [ cart, cartId, queryClient, resolvedInput.region_id ]); return { cart, isLoading, isFetching, isSuccess, error: toErrorMessage(error), itemCount, isEmpty: 0 === itemCount, hasItems: itemCount > 0, query }; } function useSuspenseCart(input, options) { const queryClient = useQueryClient(); const contextRegion = useRegionContext(); const storedCartId = useStoredCartId(); const resolvedInput = applyRegion(input, contextRegion ?? void 0); const cartId = resolvedInput.cartId ?? storedCartId; const autoCreate = resolvedInput.autoCreate ?? true; const autoUpdateRegion = resolvedInput.autoUpdateRegion ?? true; const canCreate = autoCreate && (!requireRegion || Boolean(resolvedInput.region_id)); const query = useSuspenseQuery({ queryKey: resolvedQueryKeys.active({ cartId, regionId: resolvedInput.region_id ?? null }), queryFn: ({ signal })=>loadCart({ input: resolvedInput, cartId, canCreate, autoUpdateRegion, signal }), ...resolvedCacheConfig.realtime, ...options?.queryOptions ?? {} }); const { data, isFetching } = query; const cart = data ?? null; const itemCount = getItemCount(cart); useEffect(()=>{ syncCartCache(queryClient, cart, cartId, resolvedInput.region_id ?? null); }, [ cart, cartId, queryClient, resolvedInput.region_id ]); return { cart, isLoading: false, isFetching, isSuccess: true, error: null, itemCount, isEmpty: 0 === itemCount, hasItems: itemCount > 0, query }; } function useCreateCart(options) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input)=>service.createCart(buildCreate(input)), onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ persistCartId(cart.id); await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useUpdateCart(options) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input)=>{ if (!service.updateCart) throw new Error("updateCart service is not configured"); const cartId = resolveCartId(input.cartId); if (!cartId) throw new Error("Cart id is required"); return service.updateCart(cartId, buildUpdate(input)); }, onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useUpdateCartAddress(options) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input)=>{ const cartId = requireCartId(input.cartId); const { restInput, normalizedShipping, resolvedBillingInput } = resolveAddressMutationInput(input); validateAddressInputs(normalizedShipping, resolvedBillingInput); const updateInput = buildAddressUpdateInput(restInput, normalizedShipping, resolvedBillingInput); return callUpdateCart(cartId, buildUpdate(updateInput)); }, onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useAddLineItem(options) { const contextRegion = useRegionContext(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input)=>{ if (!service.addLineItem) throw new Error("addLineItem service is not configured"); const resolvedInput = applyRegion(input, contextRegion ?? void 0); let cartId = resolveCartId(resolvedInput.cartId); const autoCreate = resolvedInput.autoCreate ?? true; const canCreate = autoCreate && (!requireRegion || Boolean(resolvedInput.region_id)); if (!cartId) { if (!canCreate) throw new Error("Cart id is required"); const created = await service.createCart(buildCreate(buildCreateInputFromAdd(resolvedInput))); persistCartId(created.id); cartId = created.id; syncCartCaches(queryClient, resolvedQueryKeys, created); } const updated = await service.addLineItem(cartId, buildAdd(resolvedInput)); persistCartId(updated.id); return updated; }, onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useUpdateLineItem(options) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input)=>{ if (!service.updateLineItem) throw new Error("updateLineItem service is not configured"); const cartId = resolveCartId(input.cartId); if (!cartId) throw new Error("Cart id is required"); return service.updateLineItem(cartId, input.lineItemId, buildUpdateItem(input)); }, onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useRemoveLineItem(options) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input)=>{ if (!service.removeLineItem) throw new Error("removeLineItem service is not configured"); const cartId = resolveCartId(input.cartId); if (!cartId) throw new Error("Cart id is required"); return service.removeLineItem(cartId, input.lineItemId); }, onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useTransferCart(options) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input)=>{ if (!service.transferCart) throw new Error("transferCart service is not configured"); const cartId = resolveCartId(input.cartId); if (!cartId) throw new Error("Cart id is required"); return service.transferCart(cartId); }, onMutate: options?.onMutate, onSuccess: async (cart, variables, context)=>{ await syncMutationCart(queryClient, cart); options?.onSuccess?.(cart, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function useCompleteCart(options) { return useMutation({ mutationFn: (input)=>{ if (!service.completeCart) throw new Error("completeCart service is not configured"); const cartId = resolveCartId(input.cartId); if (!cartId) throw new Error("Cart id is required"); return service.completeCart(cartId); }, onMutate: options?.onMutate, onSuccess: (data, variables, context)=>{ if (options?.clearCartOnSuccess === true) clearCartId(); options?.onSuccess?.(data, variables, context); }, onError: (error, variables, context)=>{ options?.onError?.(error, variables, context); }, onSettled: (data, error, variables, context)=>{ options?.onSettled?.(data, error, variables, context); } }); } function usePrefetchCart(options) { const queryClient = useQueryClient(); const contextRegion = useRegionContext(); const cacheStrategy = options?.cacheStrategy ?? "realtime"; const skipIfCached = options?.skipIfCached ?? true; const skipMode = options?.skipMode ?? "fresh"; const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, cacheStrategy); const prefetchCart = async (input)=>{ const resolvedInput = applyRegion(input, contextRegion ?? void 0); const cartId = resolveCartId(resolvedInput.cartId); const autoCreate = resolvedInput.autoCreate ?? true; const canCreate = autoCreate && (!requireRegion || Boolean(resolvedInput.region_id)); const queryKey = resolvedQueryKeys.active({ cartId, regionId: resolvedInput.region_id ?? null }); if (shouldSkipPrefetch({ queryClient, queryKey, cacheOptions: prefetchCacheOptions, skipIfCached, skipMode })) return; await queryClient.prefetchQuery({ queryKey, queryFn: ({ signal })=>loadCart({ input: resolvedInput, cartId, canCreate, autoUpdateRegion: resolvedInput.autoUpdateRegion ?? true, signal }), ...prefetchCacheOptions }); }; return { prefetchCart }; } return { useCart, useSuspenseCart, useCreateCart, useUpdateCart, useUpdateCartAddress, useAddLineItem, useUpdateLineItem, useRemoveLineItem, useTransferCart, useCompleteCart, usePrefetchCart }; } export { createCartHooks };