@techsio/storefront-data
Version:
Medusa-first storefront data layer built on TanStack Query.
533 lines (532 loc) • 22.9 kB
JavaScript
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 };