diff --git a/apps/herbatika/src/components/product-lists/product-list-tabs.tsx b/apps/herbatika/src/components/product-lists/product-list-tabs.tsx index 70bf7e571..d5d235e28 100644 --- a/apps/herbatika/src/components/product-lists/product-list-tabs.tsx +++ b/apps/herbatika/src/components/product-lists/product-list-tabs.tsx @@ -27,6 +27,45 @@ function ProductListEmptyPanel() { ); } +function ProductListItemsSkeleton() { + const rows = [0, 1, 2] as const; + + return ( + +
+ {rows.map((row) => ( +
+ +
+ + +
+
+ + + +
+
+ ))} +
+
+ + +
+
+
+
+ ); +} + function ProductListSummary({ accountLists, }: { @@ -78,17 +117,17 @@ function ProductListActiveContent({ accountLists: AccountProductListsController; }) { if (accountLists.activeListQuery.isLoading) { - return ( - - - - ); + return ; } if (accountLists.activeListQuery.error) { return null; } + if (accountLists.activeProductsAreLoading) { + return ; + } + if (accountLists.activeItems.length === 0) { return ; } diff --git a/apps/herbatika/src/components/product-lists/use-account-product-lists.ts b/apps/herbatika/src/components/product-lists/use-account-product-lists.ts index 642e5573b..1097102ef 100644 --- a/apps/herbatika/src/components/product-lists/use-account-product-lists.ts +++ b/apps/herbatika/src/components/product-lists/use-account-product-lists.ts @@ -112,6 +112,12 @@ export function useAccountProductLists() { () => buildProductMap(activeItems, productsQuery.products), [activeItems, productsQuery.products], ); + const activeProductsAreLoading = useMemo(() => { + return ( + productsQuery.isLoading && + productIds.some((productId) => !productsById.has(productId)) + ); + }, [productIds, productsById, productsQuery.isLoading]); const regionCurrencyCode = resolveRegionCurrency(region); const activeListPriceSummary = useMemo( () => @@ -433,6 +439,7 @@ export function useAccountProductLists() { activeListId, activeListSupportsQuantity, activeListQuery, + activeProductsAreLoading, activeProductId, activeQuantitySetItemId, closeCreateListDialog, diff --git a/apps/herbatika/src/lib/storefront/product-lists.client.ts b/apps/herbatika/src/lib/storefront/product-lists.client.ts deleted file mode 100644 index fd09bced7..000000000 --- a/apps/herbatika/src/lib/storefront/product-lists.client.ts +++ /dev/null @@ -1,367 +0,0 @@ -"use client"; - -import type { - AddFavoriteProductListItemInput, - AddProductListItemInput, - ChangeProductListItemQuantityInput, - CreateCustomProductListInput, - CreateFavoriteProductListInput, - CreateProductListCartInput, - DeleteProductListInput, - DeleteProductListItemInput, - IncrementProductListItemInput, - ProductListCartResponse, - ProductListDeleteResponse, - ProductListItemResponse, - ProductListListInput, - ProductListListResponse, - ProductListListResult, - ProductListResponse, - StoreProductList, - StoreProductListItem, - UpdateProductListInput, - UpdateProductListItemInput, -} from "./product-lists.types"; -import { storefrontSdk } from "./sdk"; - -const PRODUCT_LISTS_PATH = "/store/product-lists"; - -const compactRecord = (record: Record) => { - return Object.fromEntries( - Object.entries(record).filter(([, value]) => value !== undefined), - ); -}; - -const normalizeQuantity = (quantity?: number | null) => { - if (typeof quantity !== "number" || !Number.isFinite(quantity)) { - return undefined; - } - - return Math.max(1, Math.floor(quantity)); -}; - -const normalizeQuantityDelta = (quantity?: number | null) => { - if (typeof quantity !== "number" || !Number.isFinite(quantity)) { - return 1; - } - - const quantityDelta = Math.trunc(quantity); - - if (quantityDelta === 0) { - throw new Error("Quantity change must be a non-zero integer."); - } - - return quantityDelta; -}; - -const normalizeProductListsResponse = ( - response: ProductListListResponse, - fallbackLimit: number, - fallbackOffset: number, -): ProductListListResult => { - const productLists = - response.product_lists ?? response.productLists ?? response.lists ?? []; - - return { - productLists, - count: response.count ?? productLists.length, - limit: response.limit ?? fallbackLimit, - offset: response.offset ?? fallbackOffset, - }; -}; - -export const resolveProductListFromResponse = (response: ProductListResponse) => - response.product_list ?? response.productList ?? response.list ?? null; - -export const resolveProductListItemFromResponse = ( - response: ProductListItemResponse, -) => - response.product_list_item ?? - response.productListItem ?? - response.item ?? - null; - -export const resolveProductListCartFromResponse = ( - response: ProductListCartResponse, -) => response.cart ?? null; - -export const getProductListItems = (list?: StoreProductList | null) => - list?.items ?? []; - -export const getProductListItemCount = (list?: StoreProductList | null) => { - if (!list) { - return 0; - } - - if (typeof list.items_count === "number") { - return list.items_count; - } - - if (typeof list.item_count === "number") { - return list.item_count; - } - - return getProductListItems(list).length; -}; - -export const isFavoriteProductList = (list?: StoreProductList | null) => { - return list?.type === "favorite" || list?.handle === "favorites"; -}; - -export const getProductListTitle = (list?: StoreProductList | null) => { - if (isFavoriteProductList(list)) { - return "Obľúbené"; - } - - return list?.title?.trim() || "Zoznam"; -}; - -const getProductListItemProductId = (item: StoreProductListItem) => { - return item.product_id ?? item.product?.id ?? null; -}; - -const getProductListItemVariantId = (item: StoreProductListItem) => { - return item.variant_id ?? item.variant?.id ?? null; -}; - -const normalizeVariantId = (variantId?: string | null) => { - if (typeof variantId !== "string") { - return null; - } - - const trimmedVariantId = variantId.trim(); - return trimmedVariantId ? trimmedVariantId : null; -}; - -const productListItemMatchesSelection = ( - item: StoreProductListItem, - productId: string, - variantId?: string | null, -) => { - if (getProductListItemProductId(item) !== productId) { - return false; - } - - const requestedVariantId = normalizeVariantId(variantId); - const itemVariantId = normalizeVariantId(getProductListItemVariantId(item)); - - if (requestedVariantId) { - return itemVariantId === requestedVariantId; - } - - return !itemVariantId; -}; - -export const isProductInProductList = ( - list: StoreProductList | null | undefined, - productId: string, - variantId?: string | null, -) => { - return getProductListItems(list).some((item) => - productListItemMatchesSelection(item, productId, variantId), - ); -}; - -export const findProductListItem = ( - list: StoreProductList | null | undefined, - productId: string, - variantId?: string | null, -) => { - return getProductListItems(list).find((item) => - productListItemMatchesSelection(item, productId, variantId), - ); -}; - -export const listProductLists = async ( - input: ProductListListInput = {}, - signal?: AbortSignal, -) => { - const { - enabled: _enabled, - customerId: _customerId, - limit = 20, - offset = 0, - ...query - } = input; - const response = await storefrontSdk.client.fetch( - PRODUCT_LISTS_PATH, - { - query: compactRecord({ ...query, limit, offset }), - signal, - }, - ); - - return normalizeProductListsResponse(response, limit, offset); -}; - -export const getProductList = async (id: string, signal?: AbortSignal) => { - const response = await storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/${id}`, - { signal }, - ); - - return resolveProductListFromResponse(response); -}; - -export const createFavoriteProductList = async ( - input: CreateFavoriteProductListInput = {}, -) => { - const response = await storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/favorites`, - { method: "POST", body: compactRecord(input) }, - ); - - return resolveProductListFromResponse(response); -}; - -export const createCustomProductList = async ( - input: CreateCustomProductListInput, -) => { - const response = await storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/custom`, - { - method: "POST", - body: compactRecord({ - ...input, - access_type: input.access_type ?? "private", - }), - }, - ); - - return resolveProductListFromResponse(response); -}; - -export const updateProductList = async (input: UpdateProductListInput) => { - const response = await storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/${input.listId}`, - { - method: "POST", - body: compactRecord({ - title: input.title, - access_type: input.access_type, - description: input.description, - handle: input.handle, - metadata: input.metadata, - }), - }, - ); - - return resolveProductListFromResponse(response); -}; - -export const deleteProductList = async (input: DeleteProductListInput) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/${input.listId}`, - { method: "DELETE" }, - ); -}; - -export const addProductListItem = async (input: AddProductListItemInput) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/${input.listId}/items`, - { - method: "POST", - body: compactRecord({ - product_id: input.productId, - variant_id: input.variantId ?? undefined, - quantity: normalizeQuantity(input.quantity), - note: input.note, - sort_order: input.sortOrder, - metadata: input.metadata, - }), - }, - ); -}; - -export const addFavoriteProductListItem = async ( - input: AddFavoriteProductListItemInput, -) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/favorites/items`, - { - method: "POST", - body: compactRecord({ - product_id: input.productId, - variant_id: input.variantId ?? undefined, - quantity: normalizeQuantity(input.quantity), - note: input.note, - sort_order: input.sortOrder, - metadata: input.metadata, - }), - }, - ); -}; - -export const createProductListCart = async ( - input: CreateProductListCartInput, -) => { - const response = await storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/${input.listId}/cart`, - { - method: "POST", - body: compactRecord({ - region_id: input.regionId ?? undefined, - country_code: input.countryCode ?? undefined, - email: input.email ?? undefined, - sales_channel_id: input.salesChannelId ?? undefined, - }), - }, - ); - const cart = resolveProductListCartFromResponse(response); - - if (!cart) { - throw new Error("Backend nevrátil košík."); - } - - return cart; -}; - -export const updateProductListItem = async ( - input: UpdateProductListItemInput, -) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/items/${input.itemId}`, - { - method: "POST", - body: compactRecord({ - quantity: normalizeQuantity(input.quantity), - note: input.note, - sort_order: input.sortOrder, - metadata: input.metadata, - }), - }, - ); -}; - -export const changeProductListItemQuantity = async ( - input: ChangeProductListItemQuantityInput, -) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/items/${input.itemId}/change-quantity`, - { - method: "POST", - body: { quantity: normalizeQuantityDelta(input.quantity) }, - }, - ); -}; - -export const incrementProductListItem = async ( - input: IncrementProductListItemInput, -) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/items/${input.itemId}/increment`, - { - method: "POST", - body: { quantity: normalizeQuantity(input.quantity) ?? 1 }, - }, - ); -}; - -export const deleteProductListItem = async ( - input: DeleteProductListItemInput, -) => { - return storefrontSdk.client.fetch( - `${PRODUCT_LISTS_PATH}/${input.listId}/items/${input.itemId}`, - { method: "DELETE" }, - ); -}; diff --git a/apps/herbatika/src/lib/storefront/product-lists.ts b/apps/herbatika/src/lib/storefront/product-lists.ts index 139d8d382..4118a36ad 100644 --- a/apps/herbatika/src/lib/storefront/product-lists.ts +++ b/apps/herbatika/src/lib/storefront/product-lists.ts @@ -1,66 +1,61 @@ "use client"; import type { HttpTypes } from "@medusajs/types"; +import type { + MedusaProductListListHookInput, +} from "@techsio/storefront-data/product-lists/medusa-service"; +import type { + AddFavoriteProductListItemInput, + AddProductListItemInput, + ChangeProductListItemQuantityInput, + CreateCustomProductListInput, + CreateFavoriteProductListInput, + CreateProductListCartInput, + DeleteProductListInput, + DeleteProductListItemInput, + IncrementProductListItemInput, + ProductListAccessType, + ProductListBase, + ProductListCartResponse as SharedProductListCartResponse, + ProductListDeleteResponse, + ProductListItemBase, + ProductListItemResponse as SharedProductListItemResponse, + ProductListListResponse as SharedProductListListResponse, + ProductListListResult as SharedProductListListResult, + ProductListResponse as SharedProductListResponse, + ProductListType, + UpdateProductListInput, + UpdateProductListItemInput, +} from "@techsio/storefront-data/product-lists/types"; import { - useMutation, - useQueries, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { cartStorage } from "./cart-storage"; + findProductListItem as findSharedProductListItem, + getProductListItemCount as getSharedProductListItemCount, + getProductListItems as getSharedProductListItems, + isFavoriteProductList as isSharedFavoriteProductList, + isProductInProductList as isSharedProductInProductList, + resolveProductListItemQuantity as resolveSharedProductListItemQuantity, +} from "@techsio/storefront-data/product-lists/utils"; import { resolveErrorMessage } from "./error-utils"; -import { - addFavoriteProductListItem, - addProductListItem, - changeProductListItemQuantity, - createCustomProductList, - createFavoriteProductList, - createProductListCart, - deleteProductList, - deleteProductListItem, - findProductListItem, - getProductList, - getProductListItemCount, - getProductListItems, - getProductListTitle, - incrementProductListItem, - isFavoriteProductList, - isProductInProductList, - listProductLists, - resolveProductListCartFromResponse, - resolveProductListFromResponse, - resolveProductListItemFromResponse, - updateProductList, - updateProductListItem, -} from "./product-lists.client"; -import type { ProductListListInput } from "./product-lists.types"; -import { STOREFRONT_QUERY_KEY_NAMESPACE } from "./query-keys"; -import { storefrontDefinition } from "./storefront-definition"; - -export { - addFavoriteProductListItem, - addProductListItem, - changeProductListItemQuantity, - createCustomProductList, - createFavoriteProductList, - createProductListCart, - deleteProductList, - deleteProductListItem, - findProductListItem, - getProductList, - getProductListItemCount, - getProductListItems, - getProductListTitle, - incrementProductListItem, - isFavoriteProductList, - isProductInProductList, - listProductLists, - resolveProductListCartFromResponse, - resolveProductListFromResponse, - resolveProductListItemFromResponse, - updateProductList, - updateProductListItem, -}; +import { storefront } from "./storefront"; + +const productListHooks = storefront.hooks.productLists; + +export type StoreProductListType = ProductListType; +export type StoreProductListAccessType = ProductListAccessType; +export type StoreProductListItem = ProductListItemBase; +export type StoreProductList = ProductListBase; +export type ProductListListInput = MedusaProductListListHookInput; +export type ProductListListResult = + SharedProductListListResult; +export type ProductListListResponse = + SharedProductListListResponse; +export type ProductListResponse = SharedProductListResponse; +export type ProductListItemResponse = SharedProductListItemResponse< + StoreProductList, + StoreProductListItem +>; +export type ProductListCartResponse = + SharedProductListCartResponse; export type { AddFavoriteProductListItemInput, @@ -72,226 +67,118 @@ export type { DeleteProductListInput, DeleteProductListItemInput, IncrementProductListItemInput, - ProductListCartResponse, ProductListDeleteResponse, - ProductListItemResponse, - ProductListListInput, - ProductListListResponse, - ProductListListResult, - ProductListResponse, - StoreProductList, - StoreProductListAccessType, - StoreProductListItem, - StoreProductListType, UpdateProductListInput, UpdateProductListItemInput, -} from "./product-lists.types"; +}; + +export const productListQueryKeys = storefront.queryKeys.productLists; + +export const getProductListItems = ( + list?: StoreProductList | null, +): StoreProductListItem[] => getSharedProductListItems(list); + +export const getProductListItemCount = (list?: StoreProductList | null) => + getSharedProductListItemCount(list); -export const productListQueryKeys = { - all: () => [STOREFRONT_QUERY_KEY_NAMESPACE, "product-lists"] as const, - lists: (input?: ProductListListInput) => - [ - ...productListQueryKeys.all(), - "list", - { - handle: input?.handle, - type: input?.type, - limit: input?.limit, - offset: input?.offset, - customerId: input?.customerId ?? null, - }, - ] as const, - detail: (id?: string | null, customerId?: string | null) => - [ - ...productListQueryKeys.all(), - "detail", - { - customerId: customerId ?? null, - id: id ?? "", - }, - ] as const, +export const isFavoriteProductList = (list?: StoreProductList | null) => + isSharedFavoriteProductList(list); + +export const isProductInProductList = ( + list: StoreProductList | null | undefined, + productId: string, + variantId?: string | null, +) => isSharedProductInProductList(list, productId, variantId); + +export const findProductListItem = ( + list: StoreProductList | null | undefined, + productId: string, + variantId?: string | null, +): StoreProductListItem | undefined => + findSharedProductListItem(list, productId, variantId); + +export const resolveProductListItemQuantity = (item: StoreProductListItem) => + resolveSharedProductListItemQuantity(item); + +export const getProductListTitle = (list?: StoreProductList | null) => { + if (isFavoriteProductList(list)) { + return "Obľúbené"; + } + + return list?.title?.trim() || "Zoznam"; }; -export function useProductLists(input: ProductListListInput = {}) { - const enabled = input.enabled ?? true; - const query = useQuery({ - queryKey: productListQueryKeys.lists(input), - queryFn: ({ signal }) => listProductLists(input, signal), - enabled, - }); +type ProductListDetailOptions = { + customerId?: string | null; + enabled?: boolean; +}; + +export function useProductLists( + input: ProductListListInput = {}, + options?: Parameters[1], +) { + const result = productListHooks.useProductLists(input, options); return { - productLists: query.data?.productLists ?? [], - count: query.data?.count ?? 0, - limit: query.data?.limit ?? input.limit ?? 20, - offset: query.data?.offset ?? input.offset ?? 0, - isLoading: query.isLoading, - isFetching: query.isFetching, - error: query.error - ? resolveErrorMessage(query.error, "Zoznamy sa nepodarilo načítať.") + ...result, + error: result.query.error + ? resolveErrorMessage( + result.query.error, + "Zoznamy sa nepodarilo načítať.", + ) : null, - query, }; } export function useProductList( id?: string | null, - options?: { customerId?: string | null; enabled?: boolean }, + options?: ProductListDetailOptions, ) { - const enabled = Boolean(id) && (options?.enabled ?? true); - const query = useQuery({ - queryKey: productListQueryKeys.detail(id, options?.customerId), - queryFn: ({ signal }) => getProductList(id as string, signal), - enabled, + const result = productListHooks.useProductList({ + customerId: options?.customerId, + enabled: options?.enabled, + id, }); return { - productList: query.data ?? null, - isLoading: query.isLoading, - isFetching: query.isFetching, - error: query.error - ? resolveErrorMessage(query.error, "Zoznam sa nepodarilo načítať.") + ...result, + error: result.query.error + ? resolveErrorMessage(result.query.error, "Zoznam sa nepodarilo načítať.") : null, - query, }; } export function useProductListDetails( ids: string[], - options?: { customerId?: string | null; enabled?: boolean }, + options?: ProductListDetailOptions, ) { - const enabled = options?.enabled ?? true; - - return useQueries({ - queries: ids.map((id) => ({ - queryKey: productListQueryKeys.detail(id, options?.customerId), - queryFn: ({ signal }: { signal: AbortSignal }) => - getProductList(id, signal), - enabled: enabled && Boolean(id), + return productListHooks.useProductListDetails( + ids.map((id) => ({ + customerId: options?.customerId, + id, })), - }); -} - -const useInvalidateProductLists = () => { - const queryClient = useQueryClient(); - - return () => - queryClient.invalidateQueries({ - queryKey: productListQueryKeys.all(), - }); -}; - -const syncCreatedProductListCart = ( - queryClient: ReturnType, - cart: HttpTypes.StoreCart, -) => { - const cartQueryKeys = storefrontDefinition.queryKeys.cart; - const regionId = typeof cart.region_id === "string" ? cart.region_id : null; - - queryClient.setQueryData(cartQueryKeys.detail(cart.id), cart); - queryClient.setQueryData( - cartQueryKeys.active({ cartId: cart.id, regionId }), - cart, + { + enabled: options?.enabled, + }, ); - cartStorage.setCartId(cart.id); - queryClient.invalidateQueries({ queryKey: cartQueryKeys.all() }); -}; - -export function useCreateFavoriteProductList() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: createFavoriteProductList, - onSuccess: invalidateProductLists, - }); -} - -export function useCreateCustomProductList() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: createCustomProductList, - onSuccess: invalidateProductLists, - }); -} - -export function useCreateProductListCart() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: createProductListCart, - onSuccess: (cart) => syncCreatedProductListCart(queryClient, cart), - }); -} - -export function useUpdateProductList() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: updateProductList, - onSuccess: invalidateProductLists, - }); -} - -export function useDeleteProductList() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: deleteProductList, - onSuccess: invalidateProductLists, - }); -} - -export function useAddProductListItem() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: addProductListItem, - onSuccess: invalidateProductLists, - }); -} - -export function useAddFavoriteProductListItem() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: addFavoriteProductListItem, - onSuccess: invalidateProductLists, - }); -} - -export function useChangeProductListItemQuantity() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: changeProductListItemQuantity, - onSuccess: invalidateProductLists, - }); -} - -export function useUpdateProductListItem() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: updateProductListItem, - onSuccess: invalidateProductLists, - }); -} - -export function useDeleteProductListItem() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: deleteProductListItem, - onSuccess: invalidateProductLists, - }); } -export function useIncrementProductListItem() { - const invalidateProductLists = useInvalidateProductLists(); - - return useMutation({ - mutationFn: incrementProductListItem, - onSuccess: invalidateProductLists, - }); -} +export const useCreateFavoriteProductList = + productListHooks.useCreateFavoriteProductList; +export const useCreateCustomProductList = + productListHooks.useCreateCustomProductList; +export const useCreateProductListCart = + productListHooks.useCreateProductListCart; +export const useUpdateProductList = productListHooks.useUpdateProductList; +export const useDeleteProductList = productListHooks.useDeleteProductList; +export const useAddProductListItem = productListHooks.useAddProductListItem; +export const useAddFavoriteProductListItem = + productListHooks.useAddFavoriteProductListItem; +export const useChangeProductListItemQuantity = + productListHooks.useChangeProductListItemQuantity; +export const useUpdateProductListItem = + productListHooks.useUpdateProductListItem; +export const useDeleteProductListItem = + productListHooks.useDeleteProductListItem; +export const useIncrementProductListItem = + productListHooks.useIncrementProductListItem; diff --git a/apps/herbatika/src/lib/storefront/product-lists.types.ts b/apps/herbatika/src/lib/storefront/product-lists.types.ts deleted file mode 100644 index e4eb3ad36..000000000 --- a/apps/herbatika/src/lib/storefront/product-lists.types.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { HttpTypes } from "@medusajs/types"; - -export type StoreProductListType = "favorite" | "custom"; -export type StoreProductListAccessType = "private" | "public"; - -export type StoreProductListItem = { - id: string; - product_id?: string | null; - variant_id?: string | null; - quantity?: number | null; - note?: string | null; - sort_order?: number | null; - metadata?: Record | null; - product?: HttpTypes.StoreProduct | null; - variant?: { - id?: string | null; - title?: string | null; - } | null; -}; - -export type StoreProductList = { - id: string; - title?: string | null; - description?: string | null; - handle?: string | null; - type?: StoreProductListType | string | null; - access_type?: StoreProductListAccessType | string | null; - customer_id?: string | null; - items?: StoreProductListItem[] | null; - items_count?: number | null; - item_count?: number | null; - metadata?: Record | null; - created_at?: string | null; - updated_at?: string | null; -}; - -export type ProductListListResponse = { - product_lists?: StoreProductList[]; - productLists?: StoreProductList[]; - lists?: StoreProductList[]; - count?: number; - limit?: number; - offset?: number; -}; - -export type ProductListResponse = { - product_list?: StoreProductList; - productList?: StoreProductList; - list?: StoreProductList; -}; - -export type ProductListItemResponse = ProductListResponse & { - item?: StoreProductListItem; - product_list_item?: StoreProductListItem; - productListItem?: StoreProductListItem; -}; - -export type ProductListCartResponse = { - cart?: HttpTypes.StoreCart | null; -}; - -export type ProductListDeleteResponse = { - deleted: boolean; - id: string; -}; - -export type ProductListListInput = { - handle?: string; - type?: StoreProductListType; - limit?: number; - offset?: number; - customerId?: string | null; - enabled?: boolean; -}; - -export type ProductListListResult = { - productLists: StoreProductList[]; - count: number; - limit: number; - offset: number; -}; - -export type CreateFavoriteProductListInput = { - title?: string; - description?: string; - handle?: string; - metadata?: Record; -}; - -export type CreateCustomProductListInput = { - title: string; - access_type?: StoreProductListAccessType; - description?: string; - handle?: string; - metadata?: Record; -}; - -export type UpdateProductListInput = { - listId: string; - title?: string; - access_type?: StoreProductListAccessType; - description?: string; - handle?: string; - metadata?: Record; -}; - -export type DeleteProductListInput = { - listId: string; -}; - -export type AddProductListItemInput = { - listId: string; - productId: string; - variantId?: string | null; - quantity?: number | null; - note?: string; - sortOrder?: number; - metadata?: Record; -}; - -export type AddFavoriteProductListItemInput = Omit< - AddProductListItemInput, - "listId" ->; - -export type CreateProductListCartInput = { - listId: string; - regionId?: string | null; - countryCode?: string | null; - email?: string | null; - salesChannelId?: string | null; -}; - -export type ChangeProductListItemQuantityInput = { - itemId: string; - quantity?: number; -}; - -export type IncrementProductListItemInput = ChangeProductListItemQuantityInput; - -export type UpdateProductListItemInput = { - itemId: string; - quantity?: number | null; - note?: string | null; - sortOrder?: number | null; - metadata?: Record | null; -}; - -export type DeleteProductListItemInput = { - listId: string; - itemId: string; -}; diff --git a/apps/herbatika/src/lib/storefront/storefront-config.ts b/apps/herbatika/src/lib/storefront/storefront-config.ts index 5220615e7..3ad59481f 100644 --- a/apps/herbatika/src/lib/storefront/storefront-config.ts +++ b/apps/herbatika/src/lib/storefront/storefront-config.ts @@ -29,6 +29,11 @@ import type { MedusaProductServiceConfig, } from "@techsio/storefront-data/products/medusa-service"; import { createProductQueryKeys } from "@techsio/storefront-data/products/query-keys"; +import type { + MedusaProductListDetailKeyInput, + MedusaProductListListKeyInput, +} from "@techsio/storefront-data/product-lists/medusa-service"; +import { createProductListQueryKeys } from "@techsio/storefront-data/product-lists/query-keys"; import type { MedusaRegionDetailInput, MedusaRegionListInput, @@ -159,6 +164,10 @@ export const storefrontQueryKeys = { MedusaProductListInput, MedusaProductDetailInput >(STOREFRONT_QUERY_KEY_NAMESPACE), + productLists: createProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + >(STOREFRONT_QUERY_KEY_NAMESPACE), regions: createRegionQueryKeys< MedusaRegionListInput, MedusaRegionDetailInput diff --git a/apps/herbatika/src/lib/storefront/storefront-core-definition.ts b/apps/herbatika/src/lib/storefront/storefront-core-definition.ts index 88c6d90ab..b8db6ab3e 100644 --- a/apps/herbatika/src/lib/storefront/storefront-core-definition.ts +++ b/apps/herbatika/src/lib/storefront/storefront-core-definition.ts @@ -35,6 +35,9 @@ export const storefrontCoreDefinition = { defaultPageSize: DEFAULT_PRODUCT_PAGE_SIZE, }, }, + productLists: { + queryKeys: storefrontQueryKeys.productLists, + }, orders: { serviceConfig: storefrontOrderServiceConfig, hooks: { diff --git a/apps/herbatika/src/lib/storefront/storefront-server.ts b/apps/herbatika/src/lib/storefront/storefront-server.ts index 6f0c22c67..a3eb282e3 100644 --- a/apps/herbatika/src/lib/storefront/storefront-server.ts +++ b/apps/herbatika/src/lib/storefront/storefront-server.ts @@ -53,6 +53,9 @@ const storefrontServerRead = createMedusaStorefrontServerReadPreset< }, queryKeys: storefrontCoreDefinition.queryKeys.products, }, + productLists: { + queryKeys: storefrontCoreDefinition.productLists.queryKeys, + }, orders: { serviceConfig: storefrontCoreDefinition.orders.serviceConfig, hooks: storefrontCoreDefinition.orders.hooks, diff --git a/apps/herbatika/src/lib/storefront/storefront.ts b/apps/herbatika/src/lib/storefront/storefront.ts index 8e7dc8da9..6b26b6f4d 100644 --- a/apps/herbatika/src/lib/storefront/storefront.ts +++ b/apps/herbatika/src/lib/storefront/storefront.ts @@ -33,6 +33,9 @@ export const storefront = createMedusaStorefrontPreset< serviceConfig: storefrontDefinition.products.serviceConfig, hooks: storefrontDefinition.products.hooks, }, + productLists: { + queryKeys: storefrontDefinition.productLists.queryKeys, + }, orders: { serviceConfig: storefrontDefinition.orders.serviceConfig, queryKeys: storefrontDefinition.queryKeys.orders, diff --git a/libs/storefront-data/AGENTS.md b/libs/storefront-data/AGENTS.md index 8f3c115f2..5f62b2a54 100644 --- a/libs/storefront-data/AGENTS.md +++ b/libs/storefront-data/AGENTS.md @@ -32,6 +32,7 @@ src/ checkout/ # createCheckoutHooks factory + checkout flow orders/ # createOrderHooks factory customers/ # createCustomerHooks factory + product-lists/ # createProductListHooks factory + Medusa product-list service ``` ## Commands diff --git a/libs/storefront-data/README.md b/libs/storefront-data/README.md index bac54b592..f89cd5bbe 100644 --- a/libs/storefront-data/README.md +++ b/libs/storefront-data/README.md @@ -171,8 +171,12 @@ Domain subpaths: - `@techsio/storefront-data//hooks` - `@techsio/storefront-data//medusa-service` - `@techsio/storefront-data//query-keys` +- `@techsio/storefront-data//query-options` where the domain exposes SSR/query-option helpers - `@techsio/storefront-data//types` +Product-list utilities: +- `@techsio/storefront-data/product-lists/utils` + Supported domains: - `auth` - `cart` @@ -182,6 +186,7 @@ Supported domains: - `collections` - `customers` - `orders` +- `product-lists` - `products` - `regions` diff --git a/libs/storefront-data/package.json b/libs/storefront-data/package.json index fc3242508..43649e901 100644 --- a/libs/storefront-data/package.json +++ b/libs/storefront-data/package.json @@ -2,6 +2,11 @@ "name": "@techsio/storefront-data", "version": "0.1.0", "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/TechsioCZ/new-engine.git", + "directory": "libs/storefront-data" + }, "exports": { ".": null, "./package.json": "./package.json", @@ -229,6 +234,30 @@ "types": "./dist/src/products/types.d.ts", "import": "./dist/products/types.js" }, + "./product-lists/hooks": { + "types": "./dist/src/product-lists/hooks.d.ts", + "import": "./dist/product-lists/hooks.js" + }, + "./product-lists/medusa-service": { + "types": "./dist/src/product-lists/medusa-service.d.ts", + "import": "./dist/product-lists/medusa-service.js" + }, + "./product-lists/query-keys": { + "types": "./dist/src/product-lists/query-keys.d.ts", + "import": "./dist/product-lists/query-keys.js" + }, + "./product-lists/query-options": { + "types": "./dist/src/product-lists/query-options.d.ts", + "import": "./dist/product-lists/query-options.js" + }, + "./product-lists/types": { + "types": "./dist/src/product-lists/types.d.ts", + "import": "./dist/product-lists/types.js" + }, + "./product-lists/utils": { + "types": "./dist/src/product-lists/utils.d.ts", + "import": "./dist/product-lists/utils.js" + }, "./regions/hooks": { "types": "./dist/src/regions/hooks.d.ts", "import": "./dist/regions/hooks.js" @@ -251,7 +280,8 @@ } }, "files": [ - "dist" + "dist", + "skills" ], "scripts": { "build": "rslib build", @@ -273,10 +303,9 @@ } }, "devDependencies": { - "@medusajs/js-sdk": "2.14.1", - "@medusajs/types": "2.14.1", "@rsbuild/plugin-react": "^1.4.1", "@rslib/core": "^0.18.0", + "@tanstack/intent": "^0.0.42", "@testing-library/react": "^16.3.1", "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", @@ -296,5 +325,8 @@ }, "publishConfig": { "access": "public" - } + }, + "keywords": [ + "tanstack-intent" + ] } diff --git a/libs/storefront-data/skills/audit-storefront-before-release/SKILL.md b/libs/storefront-data/skills/audit-storefront-before-release/SKILL.md new file mode 100644 index 000000000..133b26778 --- /dev/null +++ b/libs/storefront-data/skills/audit-storefront-before-release/SKILL.md @@ -0,0 +1,191 @@ +--- +name: audit-storefront-before-release +description: > + Load this skill before releasing a storefront that uses + @techsio/storefront-data. Use it to audit SSR hydration, query-key identity, + storage degradation, cart cache sync, and selected payment-session checkout + behavior so the storefront does not ship with a hidden split source of truth. +type: lifecycle +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - implement-ssr-prefetch-and-query-client-boundaries + - implement-cart-and-checkout-platform-flows + - configure-pagination-prefetch-and-cache-policy +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/tests/ssr-hydration.smoke.test.tsx" + - "TechsioCZ/new-engine:libs/storefront-data/tests/shared.browser-storage.test.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/cart.cache-sync.test.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/medusa.flow.test.tsx" +--- + +This skill builds on the SSR, cart/checkout, and cache-policy skills. Use it when the app is already wired and you want a release-time audit. + +# Audit storefront before release + +Run through each section before shipping. + +## SSR and hydration checks + +### Check: server prefetch reuses the client cache after hydration + +Expected: + +```tsx +import { getServerQueryClient } from "@techsio/storefront-data/server/get-query-client" +import { storefront } from "@/src/lib/storefront" + +const queryClient = getServerQueryClient() +await queryClient.prefetchQuery( + storefront.hooks.products.getListQueryOptions({ + region_id: "reg_123", + country_code: "cz", + limit: 24, + }) +) +``` + +Fail condition: the hydrated client immediately refetches the same data because the server and client query inputs differ. +Fix: reuse the exact hook-owned query options and pass the same region-sensitive inputs on both sides. + +### Check: no client bundle imports `server/get-query-client` + +Expected: + +```tsx +"use client" + +import { StorefrontDataProvider } from "@techsio/storefront-data/client/provider" +``` + +Fail condition: a client component imports `@techsio/storefront-data/server/get-query-client`. +Fix: keep server query-client work in Server Components and the provider in client code. + +## Cache and storage checks + +### Check: the storefront is not writing active-cart state with app-local query keys + +Expected: + +```ts +import { syncCartCaches } from "@techsio/storefront-data/shared/cart-cache-sync" +import { storefront } from "@/src/lib/storefront" + +syncCartCaches(queryClient, storefront.queryKeys.cart, cart) +``` + +Fail condition: app code calls `setQueryData` or `invalidateQueries` with hand-written cart keys. +Fix: reuse the shared cart-cache sync helpers and preset query keys. + +### Check: cart persistence still behaves when storage is unavailable + +Expected: + +```ts +import { createLocalStorageValueStore } from "@techsio/storefront-data/shared/storage-value-store" + +const cartStorage = createLocalStorageValueStore({ + key: "shop-cart-id", +}) +``` + +Fail condition: the app assumes `localStorage` is always available or crashes when storage access fails. +Fix: keep the shared storage seam and test the screen with storage disabled or unavailable. + +## Checkout checks + +### Check: selected payment-session behavior is exercised + +Expected: + +```ts +const completeCheckout = storefront.flows.checkout.useCompleteCheckout({ + cartId, + regionId, +}) +``` + +Fail condition: only the happy path is tested where the first payment session already matches the desired provider. +Fix: exercise selected-session reuse, missing-session cases, and stage-specific checkout errors. + +### Check: the flow layer owns checkout completion + +Expected: + +```ts +const completeCheckout = storefront.flows.checkout.useCompleteCheckout({ + cartId, + regionId, +}) + +await completeCheckout.mutateAsync() +``` + +Fail condition: the app completes checkout through direct SDK calls or custom mutations. +Fix: route checkout completion back through the shared flow wrappers. + +## Common Mistakes + +### CRITICAL Browser-only happy-path verification + +Wrong: + +```txt +Only test the screen after `pnpm dev` with client-side navigation. +``` + +Correct: + +```txt +Test an SSR-prefetched route and confirm the hydrated client reuses the prefetched cache. +``` + +A storefront can look correct in client-only testing while still breaking in App Router SSR or hydration. + +Source: `libs/storefront-data/tests/ssr-hydration.smoke.test.tsx` + +### HIGH Skipping selected-payment-session edge cases + +Wrong: + +```txt +Only test one checkout path where the first payment session already matches the final provider. +``` + +Correct: + +```txt +Test selected-session reuse, missing-session cases, and staged checkout errors. +``` + +The hard checkout failures live in selected-session and stage-specific behavior, not only in the happy path. + +Source: `libs/storefront-data/tests/medusa.flow.test.tsx` + +### HIGH Ignoring storage degradation and cross-tab behavior + +Wrong: + +```txt +Assume localStorage always exists and one tab is enough to validate cart persistence. +``` + +Correct: + +```txt +Check cart persistence when storage is unavailable and when another tab mutates the same key. +``` + +Shared browser storage is part of the platform contract. Release review has to include degraded storage and cross-tab synchronization paths. + +Source: `libs/storefront-data/src/shared/storage-value-store.ts`, `libs/storefront-data/tests/shared.browser-storage.test.ts` + +## Pre-Deploy Summary + +- [ ] Server-prefetched routes hydrate without key drift +- [ ] No client code imports `server/get-query-client` +- [ ] Cart cache sync uses shared helpers and preset query keys +- [ ] Storage degradation and cross-tab cart behavior were checked +- [ ] Checkout completion goes through the shared flow layer +- [ ] Selected payment-session and staged error cases were exercised diff --git a/libs/storefront-data/skills/configure-pagination-prefetch-and-cache-policy/SKILL.md b/libs/storefront-data/skills/configure-pagination-prefetch-and-cache-policy/SKILL.md new file mode 100644 index 000000000..45f3d5fe6 --- /dev/null +++ b/libs/storefront-data/skills/configure-pagination-prefetch-and-cache-policy/SKILL.md @@ -0,0 +1,235 @@ +--- +name: configure-pagination-prefetch-and-cache-policy +description: > + Load this skill when tuning @techsio/storefront-data cacheConfig, + skipIfCached, skipMode, usePrefetchProducts, usePrefetchCatalogProducts, or + usePrefetchPages. Use it for pagination, page planning, normalized query + inputs, and keeping prefetch behavior aligned with shared query keys and + cache strategy rules. +type: core +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - setup-storefront-platform-in-next-app +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/cache-config.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/prefetch.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/prefetch-pages-plan.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/query-keys.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/products/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/catalog/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/shared.prefetch-pages-plan.test.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/regression.shared-orders-customers.test.tsx" +--- + +# Configure pagination, prefetch, and cache policy + +## Setup + +Use the preset to set cache strategy defaults, then use the shared prefetch helpers instead of app-local loops or hand-written query keys. + +```ts +// src/lib/storefront.ts +import { createCacheConfig } from "@techsio/storefront-data/shared/cache-config" +import { createMedusaSdk } from "@techsio/storefront-data/shared/medusa-client" +import { createMedusaStorefrontPreset } from "@techsio/storefront-data/medusa/preset" + +const sdk = createMedusaSdk({ + baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL ?? "http://localhost:9000", + publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "", +}) + +export const storefront = createMedusaStorefrontPreset({ + sdk, + queryKeyNamespace: "shop", + cacheConfig: createCacheConfig({ + semiStatic: { + staleTime: 30 * 60 * 1000, + }, + }), +}) +``` + +## Core Patterns + +### Use hook-owned prefetch helpers instead of app-local `queryClient.prefetchQuery` + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function ProductCardPrefetch() { + const { prefetchProduct } = storefront.hooks.products.usePrefetchProduct({ + skipIfCached: true, + skipMode: "any", + }) + + return ( + + ) +} +``` + +### Use the shared page planner for list pagination prefetch + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function ListingPrefetch({ + regionId, + countryCode, + currentPage, + totalPages, +}: { + regionId: string + countryCode: string + currentPage: number + totalPages: number +}) { + storefront.hooks.products.usePrefetchPages({ + baseInput: { + region_id: regionId, + country_code: countryCode, + limit: 24, + }, + currentPage, + totalPages, + hasNextPage: currentPage < totalPages, + hasPrevPage: currentPage > 1, + pageSize: 24, + }) + + return null +} +``` + +### Use query-option helpers for manual reads and SSR + +```ts +import { storefront } from "@/src/lib/storefront" + +const listOptions = storefront.hooks.products.getListQueryOptions({ + region_id: "reg_123", + country_code: "cz", + limit: 24, +}) +``` + +Treat `getListQueryOptions` and `getDetailQueryOptions` as the canonical source of query keys and query functions whenever you leave the hook path. + +## Common Mistakes + +### CRITICAL Hand-written query keys or query options + +Wrong: + +```ts +const queryKey = ["shop", "products", params] +useQuery({ queryKey, queryFn }) +``` + +Correct: + +```ts +const options = storefront.hooks.products.getListQueryOptions(params) +useQuery(options) +``` + +Manual keys drift from normalized key builders and break cache identity across hooks, SSR prefetch, and cart cache sync. + +Source: `libs/storefront-data/src/shared/query-keys.ts`, `libs/storefront-data/tests/regression.shared-orders-customers.test.tsx` + +### HIGH Reading `skipIfCached` as any-hit semantics + +Wrong: + +```ts +const { prefetchProducts } = storefront.hooks.products.usePrefetchProducts({ + skipIfCached: true, +}) +``` + +Correct: + +```ts +const { prefetchProducts } = storefront.hooks.products.usePrefetchProducts({ + skipIfCached: true, + skipMode: "any", +}) +``` + +The default skip mode is `"fresh"`. Stale data still refetches unless you explicitly choose `"any"`. + +Source: `libs/storefront-data/src/shared/prefetch.ts`, `libs/storefront-data/src/products/hooks.ts` + +### HIGH Non-plain values in normalized query inputs + +Wrong: + +```ts +storefront.hooks.products.useProducts({ + filters: new URLSearchParams(location.search), + trace: () => console.log("debug"), +}) +``` + +Correct: + +```ts +storefront.hooks.products.useProducts({ + q: search, + category_id: categoryIds, +}) +``` + +The query-key normalizer expects plain serializable structures and only strips known control flags like `enabled`. + +Source: `libs/storefront-data/src/shared/query-keys.ts` + +### MEDIUM App-local pagination loops + +Wrong: + +```ts +for (let page = 1; page <= 3; page++) { + await prefetchPage(page) +} +``` + +Correct: + +```tsx +storefront.hooks.products.usePrefetchPages({ + baseInput, + currentPage, + totalPages, + hasNextPage, + hasPrevPage, + pageSize: 24, +}) +``` + +The shared page planner encodes the platform's tested expectations for immediate and delayed prefetch order. + +Source: `libs/storefront-data/src/shared/prefetch-pages-plan.ts`, `libs/storefront-data/tests/shared.prefetch-pages-plan.test.ts` + +## References + +- [Prefetch and pagination reference](references/prefetch-and-pagination.md) + +See also: `implement-ssr-prefetch-and-query-client-boundaries` for Server Component prefetch and hydration. diff --git a/libs/storefront-data/skills/configure-pagination-prefetch-and-cache-policy/references/prefetch-and-pagination.md b/libs/storefront-data/skills/configure-pagination-prefetch-and-cache-policy/references/prefetch-and-pagination.md new file mode 100644 index 000000000..424bbb148 --- /dev/null +++ b/libs/storefront-data/skills/configure-pagination-prefetch-and-cache-policy/references/prefetch-and-pagination.md @@ -0,0 +1,45 @@ +# Prefetch and pagination reference + +## Cache strategies + +- `static`: very slow-changing data such as regions +- `semiStatic`: catalog and product reads +- `realtime`: cart or fast-changing state +- `userData`: customer, auth-adjacent, and order reads + +Use `createCacheConfig()` in the preset when a storefront needs different defaults. Keep the strategy names intact so the rest of the package still reasons about the same categories. + +## Skip modes + +- `skipIfCached: false`: always prefetch +- `skipIfCached: true` with `skipMode: "fresh"`: skip only when the cached entry is still fresh +- `skipIfCached: true` with `skipMode: "any"`: skip whenever any cached entry exists, even if stale + +The library default is `"fresh"`. Pick `"any"` intentionally in the app when stale-but-present data is acceptable for the prefetch use case. + +## Shared page planning + +`usePrefetchPages` delegates to the shared page planner in `src/shared/prefetch-pages-plan.ts`. Use it when: + +- a listing screen already knows `currentPage` +- the app knows whether next/previous pages exist +- prefetch should follow the same priority rules across storefronts + +Avoid rebuilding the plan in the app with ad-hoc loops or timers. + +## Query-input hygiene + +Treat these as safe input shapes: + +- plain objects +- arrays of plain values +- strings, numbers, booleans, null + +Avoid these in hook inputs and query-option builders: + +- `URLSearchParams` +- functions +- class instances +- circular structures + +If the app has richer filter state, normalize it into a plain object before passing it into `storefront.hooks.*`. diff --git a/libs/storefront-data/skills/decide-app-specific-overrides-vs-shared-platform/SKILL.md b/libs/storefront-data/skills/decide-app-specific-overrides-vs-shared-platform/SKILL.md new file mode 100644 index 000000000..ef972eeaf --- /dev/null +++ b/libs/storefront-data/skills/decide-app-specific-overrides-vs-shared-platform/SKILL.md @@ -0,0 +1,194 @@ +--- +name: decide-app-specific-overrides-vs-shared-platform +description: > + Load this skill when deciding whether storefront-specific wrappers, DTOs, + address adapters, or backend behavior should stay in the app or move into + @techsio/storefront-data. Use it for thin wrapper rules, customer-specific + read models, and deciding when repeated backend logic becomes shared platform + behavior. +type: composition +library: "@techsio/storefront-data" +library_version: "0.1.0" +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/AGENTS.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/address.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/checkout/address.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/auth/hooks.ts" +--- + +# Decide app-specific overrides vs shared platform + +## Setup + +Default to the shared preset surface. Add a local wrapper only when the app contributes real policy or UX behavior. + +```ts +// src/lib/auth-hooks.ts +import { storefront } from "@/src/lib/storefront" +import { toast } from "sonner" + +export function useLoginWithToast() { + return storefront.hooks.auth.useLogin({ + onSuccess: () => { + toast.success("Logged in") + }, + }) +} +``` + +## Core Patterns + +### Keep customer-specific DTOs and read models in the app + +```ts +// apps/n1/src/lib/customer-models.ts +export type N1ProfileCard = { + email: string + loyaltyTier: string +} +``` + +The shared package should own reusable backend-facing contracts. A storefront-local view model stays local until another storefront needs the same shape. + +### Keep address customization behind the shared contract + +```ts +// apps/n1/src/lib/address-adapter.ts +import type { CheckoutCartAddressAdapter } from "@techsio/storefront-data/shared/address" + +export const checkoutCartAddressAdapter: CheckoutCartAddressAdapter = { + toCreateInput(values) { + return { + first_name: values.firstName, + last_name: values.lastName, + address_1: values.street, + city: values.city, + country_code: values.countryCode, + postal_code: values.postalCode, + } + }, + toPatchInput(values) { + return { + first_name: values.firstName, + last_name: values.lastName, + address_1: values.street, + city: values.city, + country_code: values.countryCode, + postal_code: values.postalCode, + } + }, +} +``` + +### Promote repeated backend behavior into the shared package + +```ts +// apps/n1/src/lib/checkout.ts +export function useCheckoutWithSharedPaymentRule(cartId: string, regionId: string) { + return storefront.flows.checkout.useCompleteCheckout({ + cartId, + regionId, + }) +} +``` + +If another storefront needs the same backend-facing rule, move that rule into `libs/storefront-data` so the next bugfix lands once. + +## Common Mistakes + +### HIGH Wrappers with no added behavior + +Wrong: + +```ts +export const useProducts = (params: ProductParams) => + storefront.hooks.products.useProducts(params) +``` + +Correct: + +```ts +export const useProducts = storefront.hooks.products.useProducts +``` + +A wrapper that adds no policy, translation, or side effect only obscures the preset surface. + +Source: maintainer interview + +### HIGH Customer-specific read models inside the shared package + +Wrong: + +```ts +// libs/storefront-data/src/customers/types.ts +export type N1ProfileCard = { + loyaltyTier: string +} +``` + +Correct: + +```ts +// apps/n1/src/lib/customer-models.ts +export type N1ProfileCard = { + loyaltyTier: string +} +``` + +The shared package owns reusable backend-facing contracts. A storefront-local read model belongs to the storefront until reuse is proven. + +Source: `libs/storefront-data/README.md`, maintainer interview + +### HIGH Repeated backend behavior left inside one app + +Wrong: + +```ts +// apps/n1/src/lib/cart-hooks.ts +export function useSharedCartSemantics() { + return useMutation({ mutationFn: customCartMutation }) +} +``` + +Correct: + +```ts +// libs/storefront-data/src/... shared seam consumed from the preset +export const storefront = createMedusaStorefrontPreset({ sdk }) +``` + +If checkout, cart, or product backend behavior is shared by most storefronts, it belongs in the library so bugfixes propagate once. + +Source: maintainer interview, `libs/storefront-data/AGENTS.md`, `libs/storefront-data/README.md` + +### HIGH UI dependencies leaked into shared platform code + +Wrong: + +```ts +createMedusaStorefrontPreset({ + sdk, + auth: { + hooks: { + onSuccess: () => toast.success("Logged in"), + }, + }, +}) +``` + +Correct: + +```ts +function useLoginWithToast() { + return storefront.hooks.auth.useLogin({ + onSuccess: () => toast.success("Logged in"), + }) +} +``` + +Storefront-specific UI dependencies belong in the app layer, even when the backend flow itself stays shared. + +Source: `libs/storefront-data/src/auth/hooks.ts`, maintainer interview + +See also: `extend-storefront-data-for-new-backend-use-cases` when the behavior is no longer truly storefront-specific. diff --git a/libs/storefront-data/skills/extend-storefront-data-for-new-backend-use-cases/SKILL.md b/libs/storefront-data/skills/extend-storefront-data-for-new-backend-use-cases/SKILL.md new file mode 100644 index 000000000..fc0814b35 --- /dev/null +++ b/libs/storefront-data/skills/extend-storefront-data-for-new-backend-use-cases/SKILL.md @@ -0,0 +1,191 @@ +--- +name: extend-storefront-data-for-new-backend-use-cases +description: > + Load this skill when adding a new shared backend-facing capability to + @techsio/storefront-data through preset wiring, normalized query keys, + service-layer cancellation, and test-backed invariants. Use it when app code + has proven a backend concern is no longer customer-specific and should become + part of the shared storefront platform. +type: core +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - decide-app-specific-overrides-vs-shared-platform +sources: + - "TechsioCZ/new-engine:libs/storefront-data/AGENTS.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/medusa/preset.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/query-keys.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/orders/medusa-service.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/medusa.preset.test.tsx" + - "TechsioCZ/new-engine:libs/storefront-data/tests/medusa.flow.test.tsx" +--- + +This skill builds on `decide-app-specific-overrides-vs-shared-platform`. Use it only after deciding the behavior should become shared. + +# Extend storefront-data for new backend use cases + +## Setup + +Treat new shared behavior as a coordinated change across types, query keys, service wiring, preset composition, and tests. + +```ts +// src/inventory/query-keys.ts +import { createQueryKey, type QueryNamespace } from "../shared/query-keys" + +export const createInventoryQueryKeys = (namespace: QueryNamespace) => ({ + all: () => createQueryKey(namespace, "inventory"), + list: (input: { sku?: string }) => createQueryKey(namespace, "inventory", "list", input), +}) +``` + +```ts +// src/inventory/medusa-service.ts +import type { MedusaSdk } from "../shared/medusa-client" + +export function createMedusaInventoryService(sdk: MedusaSdk) { + return { + async getInventory(input: { sku?: string }, signal?: AbortSignal) { + return sdk.client.fetch({ + method: "GET", + path: "/store/inventory", + query: input, + signal, + }) + }, + } +} +``` + +## Core Patterns + +### Wire new shared behavior through the preset + +```ts +// src/medusa/preset.ts +const services = { + ...existingServices, + inventory: config.inventory?.service ?? createMedusaInventoryService(config.sdk), +} +``` + +The preset is the canonical composition root. If the new domain never reaches the preset, it does not reach the intended integration path. + +### Reuse normalized query-key helpers + +```ts +// src/inventory/hooks.ts +const queryKeys = + config.queryKeys ?? createInventoryQueryKeys(config.queryKeyNamespace ?? "storefront-data") +``` + +Treat `createQueryKey()` and the existing key factories as mandatory infrastructure, not optional style. + +### Pin the new invariant with tests in `libs/storefront-data/tests` + +```ts +// tests/inventory.smoke.test.ts +import { describe, expect, it } from "vitest" + +describe("inventory preset wiring", () => { + it("exposes the inventory surface from the preset", () => { + expect(true).toBe(true) + }) +}) +``` + +Use the test file as the place where the new shared contract becomes explicit. + +## Common Mistakes + +### HIGH New domain outside the preset + +Wrong: + +```ts +export const useInventory = createInventoryHooks({ + service, + queryKeyNamespace: "shop", +}) +``` + +Correct: + +```ts +export const storefront = createMedusaStorefrontPreset({ + sdk, + inventory: { + service, + }, +}) +``` + +The preset is the main composition root. A new capability that lives outside it immediately diverges from the intended integration path. + +Source: `libs/storefront-data/src/medusa/preset.ts`, `libs/storefront-data/AGENTS.md`, `libs/storefront-data/README.md` + +### CRITICAL Hardcoded query keys + +Wrong: + +```ts +const queryKey = ["storefront", "inventory", params] +``` + +Correct: + +```ts +const queryKey = createQueryKey(namespace, "inventory", "list", params) +``` + +Shared domains must participate in normalized key generation or they drift from invalidation and cache-matching behavior everywhere else. + +Source: `libs/storefront-data/src/shared/query-keys.ts`, `libs/storefront-data/AGENTS.md` + +### HIGH `AbortSignal` accepted but ignored + +Wrong: + +```ts +async function getInventory(params: InventoryInput, signal?: AbortSignal) { + return sdk.store.product.list(params) +} +``` + +Correct: + +```ts +async function getInventory(params: InventoryInput, signal?: AbortSignal) { + return sdk.client.fetch({ + method: "GET", + path: "/store/inventory", + query: params, + signal, + }) +} +``` + +TanStack Query passes cancellation signals into query functions. Dropping them creates fake cancellation support. + +Source: `libs/storefront-data/src/orders/medusa-service.ts`, TanStack Query query cancellation docs + +### HIGH Shared behavior without shared tests + +Wrong: + +```ts +// add new flow logic with no regression coverage +``` + +Correct: + +```ts +// add the new logic and pin the invariant in libs/storefront-data/tests/* +``` + +The library now treats tests as part of the consumer-facing contract. Missing regression coverage pushes risk back into each storefront. + +Source: `libs/storefront-data/tests/medusa.preset.test.tsx`, `libs/storefront-data/tests/medusa.flow.test.tsx` + +## References + +- [Extension recipe](references/extension-recipe.md) diff --git a/libs/storefront-data/skills/extend-storefront-data-for-new-backend-use-cases/references/extension-recipe.md b/libs/storefront-data/skills/extend-storefront-data-for-new-backend-use-cases/references/extension-recipe.md new file mode 100644 index 000000000..348d4ed6c --- /dev/null +++ b/libs/storefront-data/skills/extend-storefront-data-for-new-backend-use-cases/references/extension-recipe.md @@ -0,0 +1,20 @@ +# Extension recipe + +Use this order when adding a new shared backend-facing capability: + +1. Decide the behavior is no longer storefront-specific. +2. Add or reuse the backend-facing types. +3. Add query-key helpers through `createQueryKey()`. +4. Add the Medusa service and forward `AbortSignal`. +5. Add hooks or helper functions that match existing package patterns. +6. Wire the new surface into `createMedusaStorefrontPreset`. +7. Add regression tests in `libs/storefront-data/tests`. +8. Only then consume the new surface from apps. + +## Shared extension checks + +- Keep imports direct. Do not add a barrel file. +- Reuse shared helpers instead of burying generic logic inside one domain. +- Treat tests as part of the public contract. +- Prefer extending the preset surface over asking apps to assemble low-level factories. +- If the behavior is still only one-storefront-specific, stop and keep it local. diff --git a/libs/storefront-data/skills/implement-auth-and-customer-session-flows/SKILL.md b/libs/storefront-data/skills/implement-auth-and-customer-session-flows/SKILL.md new file mode 100644 index 000000000..a399ca5d4 --- /dev/null +++ b/libs/storefront-data/skills/implement-auth-and-customer-session-flows/SKILL.md @@ -0,0 +1,233 @@ +--- +name: implement-auth-and-customer-session-flows +description: > + Load this skill when using @techsio/storefront-data for customer auth and + session state through storefront.hooks.auth.useAuth, useLogin, useRegister, + useLogout, and invalidateOnAuthChange. Use it for login/register flows, + session-aware rendering, cross-domain invalidation, and app-level callbacks + for toasts, analytics, or redirects. +type: core +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - setup-storefront-platform-in-next-app +sources: + - "TechsioCZ/new-engine:libs/storefront-data/src/auth/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/auth/medusa-service.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/auth/query-keys.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/medusa/preset.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/auth.medusa-service.test.ts" +--- + +## Setup + +Let the preset own the auth service and invalidation. Keep UX side effects in the app. + +```ts +// src/lib/storefront.ts +import { createMedusaSdk } from "@techsio/storefront-data/shared/medusa-client" +import { createMedusaStorefrontPreset } from "@techsio/storefront-data/medusa/preset" + +const sdk = createMedusaSdk({ + baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL ?? "http://localhost:9000", + publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "", +}) + +export const storefront = createMedusaStorefrontPreset({ + sdk, + queryKeyNamespace: "shop", + auth: { + hooks: { + invalidateOnAuthChange: { + includeDefaults: true, + }, + }, + }, +}) +``` + +## Core Patterns + +### Gate account UI through `useAuth` + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function AccountGate() { + const auth = storefront.hooks.auth.useAuth() + + if (auth.isLoading) return
Loading...
+ if (!auth.isAuthenticated) return
Please sign in
+ + return
{auth.customer?.email}
+} +``` + +### Keep UX side effects in app callbacks + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" +import { toast } from "sonner" + +const track = (event: string) => { + console.log(event) +} + +export function LoginButton() { + const login = storefront.hooks.auth.useLogin({ + onSuccess: () => { + toast.success("Logged in") + }, + }) + + return ( + + ) +} +``` + +### Recover registration edge cases through the shared register hook + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function RegisterButton() { + const register = storefront.hooks.auth.useRegister() + + return ( + + ) +} +``` + +The shared auth service already handles the Medusa-specific recovery and cleanup semantics. The app should only add storefront policy around it. + +## Common Mistakes + +### HIGH Rebuilding auth in the app + +Wrong: + +```ts +const register = async (input: RegisterInput) => { + await sdk.auth.register("customer", "emailpass", input) + await sdk.auth.login("customer", "emailpass", input) +} +``` + +Correct: + +```ts +const register = storefront.hooks.auth.useRegister() +register.mutate(input) +``` + +Local auth re-implementation reintroduces the exact cleanup and invalidation bugs that the shared auth surface is meant to absorb. + +Source: `libs/storefront-data/src/auth/medusa-service.ts`, `libs/storefront-data/src/auth/hooks.ts` + +### CRITICAL Assuming multi-step auth is already supported + +Wrong: + +```ts +storefront.hooks.auth.useLogin().mutate({ provider: "google" }) +``` + +Correct: + +```ts +storefront.hooks.auth.useLogin().mutate({ + email: "alice@example.com", + password: "secret", +}) +``` + +The current Medusa auth adapter rejects multi-step flows. The obvious generic-provider shape is misleading here. + +Source: `libs/storefront-data/src/auth/medusa-service.ts`, `libs/storefront-data/tests/auth.medusa-service.test.ts` + +### HIGH Putting UX callbacks into shared auth code + +Wrong: + +```ts +const storefront = createMedusaStorefrontPreset({ + sdk, + auth: { + hooks: { + onSuccess: () => toast.success("Logged in"), + }, + }, +}) +``` + +Correct: + +```ts +const login = storefront.hooks.auth.useLogin({ + onSuccess: () => { + toast.success("Logged in") + track("login_success") + }, +}) +``` + +Toasts, redirects, analytics, and debug behavior belong in the app layer, not inside the shared auth contract. + +Source: `libs/storefront-data/src/auth/hooks.ts`, maintainer interview + +### HIGH Forgetting cross-domain invalidation needs + +Wrong: + +```ts +const storefront = createMedusaStorefrontPreset({ + sdk, + auth: { hooks: { invalidateOnAuthChange: { includeDefaults: false } } }, +}) +``` + +Correct: + +```ts +const storefront = createMedusaStorefrontPreset({ + sdk, + auth: { + hooks: { + invalidateOnAuthChange: { + includeDefaults: true, + }, + }, + }, +}) +``` + +Auth changes usually affect customer and order data. If you turn off default invalidation, do it intentionally and replace it with equivalent app-specific rules. + +Source: `libs/storefront-data/src/medusa/preset.ts`, `libs/storefront-data/src/auth/hooks.ts` + +See also: `decide-app-specific-overrides-vs-shared-platform` for thin wrapper rules. diff --git a/libs/storefront-data/skills/implement-cart-and-checkout-platform-flows/SKILL.md b/libs/storefront-data/skills/implement-cart-and-checkout-platform-flows/SKILL.md new file mode 100644 index 000000000..c3851bc43 --- /dev/null +++ b/libs/storefront-data/skills/implement-cart-and-checkout-platform-flows/SKILL.md @@ -0,0 +1,242 @@ +--- +name: implement-cart-and-checkout-platform-flows +description: > + Load this skill when implementing cart and checkout through + @techsio/storefront-data with storefront.flows.cart, + storefront.flows.checkout, useCart, useAddToCart, useCheckoutShipping, + useCheckoutPayment, useCompleteCheckout, and shared cart cache sync. Use it + for active cart lifecycle, effective cart state, selected payment-session + semantics, and canonical checkout orchestration. +type: core +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - setup-storefront-platform-in-next-app +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/cart/types.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/cart-cache-sync.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/checkout-flow-utils.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/medusa/cart-flow.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/medusa/checkout-flow.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/cart.cache-sync.test.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/medusa.flow.test.tsx" +--- + +## Setup + +Use the flow layer as the default app surface for cart and checkout. Drop to low-level hooks only for exceptional cases. + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function AddToCartButton({ + regionId, + variantId, +}: { + regionId: string + variantId: string +}) { + const cart = storefront.flows.cart.useCart({ + region_id: regionId, + autoCreate: true, + autoUpdateRegion: true, + }) + const addToCart = storefront.flows.cart.useAddToCart() + + return ( + + ) +} +``` + +## Core Patterns + +### Use checkout shipping through the flow wrapper + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function ShippingOptions({ cartId }: { cartId: string }) { + const shipping = storefront.flows.checkout.useCheckoutShipping({ cartId }) + + return ( +
    + {shipping.shippingOptions.map((option) => ( +
  • + +
  • + ))} +
+ ) +} +``` + +### Use checkout payment through the flow wrapper + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function PaymentOptions({ + cartId, + regionId, +}: { + cartId: string + regionId: string +}) { + const payment = storefront.flows.checkout.useCheckoutPayment({ + cartId, + regionId, + }) + + return ( +
    + {payment.paymentProviders.map((provider) => ( +
  • + +
  • + ))} +
+ ) +} +``` + +### Complete checkout through the canonical flow + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function CompleteCheckoutButton({ + cartId, + regionId, +}: { + cartId: string + regionId: string +}) { + const completeCheckout = storefront.flows.checkout.useCompleteCheckout({ + cartId, + regionId, + }) + + return ( + + ) +} +``` + +## Common Mistakes + +### HIGH Direct Medusa calls instead of the flow layer + +Wrong: + +```ts +await sdk.store.cart.createLineItem(cartId, payload) +await sdk.store.cart.complete(cartId) +``` + +Correct: + +```ts +const addToCart = storefront.flows.cart.useAddToCart() +const completeCheckout = storefront.flows.checkout.useCompleteCheckout({ cartId }) + +await addToCart.mutateAsync({ cartId, ...payload }) +await completeCheckout.mutateAsync() +``` + +The flow wrappers normalize cache sync, result shapes, and checkout orchestration. Direct SDK calls bypass those shared semantics. + +Source: `libs/storefront-data/src/medusa/cart-flow.ts`, `libs/storefront-data/src/medusa/checkout-flow.ts` + +### CRITICAL First payment session wins + +Wrong: + +```ts +const providerId = cart.payment_collection?.payment_sessions?.[0]?.provider_id +``` + +Correct: + +```ts +const providerId = resolveSelectedPaymentProviderId(cart) +``` + +Checkout now derives the active provider from selected payment-session semantics, not whichever session happens to be first. + +Source: `libs/storefront-data/src/shared/checkout-flow-utils.ts`, `libs/storefront-data/tests/medusa.flow.test.tsx` + +### HIGH Latest local cart argument is always authoritative + +Wrong: + +```ts +const complete = () => checkout.mutate({ cart }) +``` + +Correct: + +```ts +const completeCheckout = storefront.flows.checkout.useCompleteCheckout({ + cartId: cart.id, +}) + +const complete = () => completeCheckout.mutate() +``` + +The checkout flow resolves effective cart state from the shared caches and selected state. A stale local cart object is not always the right source of truth. + +Source: `libs/storefront-data/src/shared/checkout-flow-utils.ts`, `libs/storefront-data/src/medusa/checkout-flow.ts` + +### HIGH App-local active-cart cache heuristics + +Wrong: + +```ts +const activeCartKey = ["shop", "cart", "active"] +queryClient.setQueryData(activeCartKey, cart) +``` + +Correct: + +```ts +syncCartCaches(queryClient, storefront.queryKeys.cart, cart) +``` + +Active-cart matching is now shared platform behavior. Hand-written heuristics drift from the tested cache contract. + +Source: `libs/storefront-data/src/shared/cart-cache-sync.ts`, `libs/storefront-data/tests/cart.cache-sync.test.ts` + +See also: `configure-pagination-prefetch-and-cache-policy` for shared query-key behavior and cache semantics. diff --git a/libs/storefront-data/skills/implement-ssr-prefetch-and-query-client-boundaries/SKILL.md b/libs/storefront-data/skills/implement-ssr-prefetch-and-query-client-boundaries/SKILL.md new file mode 100644 index 000000000..5f491d182 --- /dev/null +++ b/libs/storefront-data/skills/implement-ssr-prefetch-and-query-client-boundaries/SKILL.md @@ -0,0 +1,224 @@ +--- +name: implement-ssr-prefetch-and-query-client-boundaries +description: > + Load this skill when using @techsio/storefront-data in Next.js Server + Components with getServerQueryClient, HydrationBoundary, dehydrate, and + preset-owned getListQueryOptions or getDetailQueryOptions. Use it for SSR + prefetch, request-scoped query-client ownership, and avoiding query-key drift + between server prefetch and client hooks. +type: framework +library: "@techsio/storefront-data" +framework: react +library_version: "0.1.0" +requires: + - setup-storefront-platform-in-next-app +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/server/get-query-client.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/query-client.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/query-keys.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/products/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/tests/ssr-hydration.smoke.test.tsx" +--- + +This skill builds on `setup-storefront-platform-in-next-app`. Read it first for the preset and provider boundary. + +# Implement SSR prefetch and query-client boundaries + +## Setup + +Use the request-scoped server helper inside Server Components and prefetch through query options that come from the preset. + +```tsx +// app/products/page.tsx +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { getServerQueryClient } from "@techsio/storefront-data/server/get-query-client" +import { storefront } from "@/src/lib/storefront" +import { ProductsPage } from "./products-page" + +export default async function Page() { + const queryClient = getServerQueryClient() + + await queryClient.prefetchQuery( + storefront.hooks.products.getListQueryOptions({ + region_id: "reg_123", + country_code: "cz", + limit: 24, + }) + ) + + return ( + + + + ) +} +``` + +## Hooks and Components + +### Prefetch detail routes with the same query options the client hook will use + +```tsx +// app/products/[handle]/page.tsx +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { getServerQueryClient } from "@techsio/storefront-data/server/get-query-client" +import { storefront } from "@/src/lib/storefront" +import { ProductPage } from "./product-page" + +export default async function Page({ + params, +}: { + params: Promise<{ handle: string }> +}) { + const { handle } = await params + const queryClient = getServerQueryClient() + + await queryClient.prefetchQuery( + storefront.hooks.products.getDetailQueryOptions({ + handle, + region_id: "reg_123", + country_code: "cz", + }) + ) + + return ( + + + + ) +} +``` + +### Use `makeQueryClient` when code runs on the server outside RSC render + +```ts +// src/lib/server-sitemap.ts +import { makeQueryClient } from "@techsio/storefront-data/shared/query-client" +import { storefront } from "@/src/lib/storefront" + +export async function loadSitemapProducts() { + const queryClient = makeQueryClient() + + await queryClient.prefetchQuery( + storefront.hooks.products.getListQueryOptions({ + region_id: "reg_123", + country_code: "cz", + limit: 100, + }) + ) + + return queryClient.getQueryData( + storefront.queryKeys.products.list({ + region_id: "reg_123", + country_code: "cz", + limit: 100, + }) + ) +} +``` + +`getServerQueryClient()` is for Server Component render. `makeQueryClient()` is the safe explicit choice for standalone server utilities. + +## Common Mistakes + +### HIGH Raw server QueryClient instances + +Wrong: + +```ts +import { QueryClient } from "@tanstack/react-query" + +const queryClient = new QueryClient() +await queryClient.prefetchQuery({ queryKey, queryFn }) +``` + +Correct: + +```ts +import { getServerQueryClient } from "@techsio/storefront-data/server/get-query-client" + +const queryClient = getServerQueryClient() +await queryClient.prefetchQuery(options) +``` + +A raw QueryClient bypasses the library's documented request-scoped SSR path and makes hydration behavior drift from the preset setup. + +Source: `libs/storefront-data/src/server/get-query-client.ts`, `libs/storefront-data/AGENTS.md` + +### HIGH Assuming request memoization outside RSC render + +Wrong: + +```ts +export async function GET() { + const first = getServerQueryClient() + const second = getServerQueryClient() +} +``` + +Correct: + +```ts +export default async function Page() { + const queryClient = getServerQueryClient() + return +} +``` + +React `cache()` only gives request-scoped reuse during Server Component render. Outside that context, each call creates a fresh client. + +Source: `libs/storefront-data/src/server/get-query-client.ts` + +### CRITICAL Hand-written query keys for SSR prefetch + +Wrong: + +```ts +await queryClient.prefetchQuery({ + queryKey: ["shop", "products", params], + queryFn: fetchProducts, +}) +``` + +Correct: + +```ts +await queryClient.prefetchQuery( + storefront.hooks.products.getListQueryOptions(params) +) +``` + +Manual keys drift from the normalized key builders and silently miss the hydrated client cache. + +Source: `libs/storefront-data/src/shared/query-keys.ts`, `libs/storefront-data/tests/ssr-hydration.smoke.test.tsx` + +### HIGH Missing region-sensitive inputs in prefetch + +Wrong: + +```ts +await queryClient.prefetchQuery( + storefront.hooks.products.getListQueryOptions({ + limit: 24, + }) +) +``` + +Correct: + +```ts +await queryClient.prefetchQuery( + storefront.hooks.products.getListQueryOptions({ + region_id: "reg_123", + country_code: "cz", + limit: 24, + }) +) +``` + +SSR helpers do not know the current region implicitly. If server and client shape the inputs differently, you lose cache identity and can fetch different payloads. + +Source: `libs/storefront-data/src/products/hooks.ts`, `libs/storefront-data/src/products/medusa-service.ts` + +See also: `configure-pagination-prefetch-and-cache-policy` for skip modes, page planning, and normalized query inputs. diff --git a/libs/storefront-data/skills/migrate-custom-hooks-to-storefront-data/SKILL.md b/libs/storefront-data/skills/migrate-custom-hooks-to-storefront-data/SKILL.md new file mode 100644 index 000000000..be631b0cc --- /dev/null +++ b/libs/storefront-data/skills/migrate-custom-hooks-to-storefront-data/SKILL.md @@ -0,0 +1,192 @@ +--- +name: migrate-custom-hooks-to-storefront-data +description: > + Load this skill when replacing app-local Medusa hooks, query utilities, or + service wrappers with the preset-first @techsio/storefront-data surface. Use + it for migration cutovers, preserving storefront-specific callbacks, and + removing dual source-of-truth behavior instead of running legacy and shared + data layers in parallel. +type: lifecycle +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - setup-storefront-platform-in-next-app +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/AGENTS.md" + - "TechsioCZ/new-engine:apps/frontend-demo/README.md" + - "TechsioCZ/new-engine:apps/n1/AGENTS.md" +--- + +This skill builds on `setup-storefront-platform-in-next-app`. Use it once the new preset seam exists in the app. + +# Migrate custom hooks to storefront-data + +## Setup + +Start by centralizing the new preset seam before replacing feature code. + +```ts +// src/lib/storefront.ts +import { createMedusaSdk } from "@techsio/storefront-data/shared/medusa-client" +import { createLocalStorageValueStore } from "@techsio/storefront-data/shared/storage-value-store" +import { createMedusaStorefrontPreset } from "@techsio/storefront-data/medusa/preset" + +const sdk = createMedusaSdk({ + baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL ?? "http://localhost:9000", + publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "", +}) + +export const storefront = createMedusaStorefrontPreset({ + sdk, + queryKeyNamespace: "n1", + cart: { + hooks: { + cartStorage: createLocalStorageValueStore({ key: "n1-cart-id" }), + }, + }, +}) +``` + +## Core Patterns + +### Replace one vertical feature block at a time + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function ProductList({ regionId }: { regionId: string }) { + const query = storefront.hooks.products.useProducts({ + region_id: regionId, + limit: 24, + }) + + if (query.isLoading) return
Loading...
+ + return
{query.products.length}
+} +``` + +The goal is a clean cutover for one screen or feature block, followed by deletion of the old implementation for that block. + +### Keep storefront-specific UX by wrapping the shared hook, not forking it + +```ts +// src/lib/auth-hooks.ts +import { storefront } from "@/src/lib/storefront" + +const track = (event: string) => { + console.log(event) +} + +export function useLoginWithAnalytics() { + return storefront.hooks.auth.useLogin({ + onSuccess: () => { + track("login_success") + }, + }) +} +``` + +### Delete the legacy query helpers after the cutover + +```ts +// before +export const productQueryKey = (params: ProductParams) => ["n1-products", params] + +// after +export const productQueryKey = storefront.queryKeys.products.list +``` + +If the screen already reads and writes through the preset, keeping the old query helper around only invites drift back into the app. + +## Common Mistakes + +### CRITICAL Old and new data layers on the same screen + +Wrong: + +```ts +const legacy = useLegacyProducts(params) +const shared = storefront.hooks.products.useProducts(params) +``` + +Correct: + +```ts +const products = storefront.hooks.products.useProducts(params) +``` + +Parallel legacy and shared reads create duplicate requests, conflicting query keys, and no single source of truth. + +Source: maintainer interview, `libs/storefront-data/AGENTS.md`, `libs/storefront-data/README.md` + +### HIGH Feature migration before preset centralization + +Wrong: + +```ts +export const useN1Products = () => storefront.hooks.products.useProducts({}) +export const useN1Cart = () => storefront.hooks.cart.useCart({}) +``` + +Correct: + +```ts +export const storefront = createMedusaStorefrontPreset({ + sdk, + queryKeyNamespace: "n1", +}) +``` + +If the preset seam is not centralized first, query keys, storage, field defaults, and adapters stay scattered across the app. + +Source: `libs/storefront-data/README.md`, maintainer interview + +### HIGH Losing storefront-specific callbacks during replacement + +Wrong: + +```ts +export const useLogin = storefront.hooks.auth.useLogin +``` + +Correct: + +```ts +export function useLogin() { + return storefront.hooks.auth.useLogin({ + onSuccess: () => { + toast.success("Logged in") + track("login_success") + }, + }) +} +``` + +Legacy wrappers often exist only because they carry storefront-specific analytics, debug hooks, or UX side effects. + +Source: maintainer interview + +### MEDIUM Keeping proven common logic app-local + +Wrong: + +```ts +export const useCustomOrders = () => + useQuery({ queryKey: ["n1-orders"], queryFn: loadOrders }) +``` + +Correct: + +```ts +const orders = storefront.hooks.orders.useOrders({ limit: 20 }) +``` + +Once a second storefront needs the same backend behavior, leaving it local recreates the duplication problem the migration is trying to remove. + +Source: maintainer interview + +See also: `decide-app-specific-overrides-vs-shared-platform` for promotion rules. diff --git a/libs/storefront-data/skills/setup-storefront-platform-in-next-app/SKILL.md b/libs/storefront-data/skills/setup-storefront-platform-in-next-app/SKILL.md new file mode 100644 index 000000000..2399b3713 --- /dev/null +++ b/libs/storefront-data/skills/setup-storefront-platform-in-next-app/SKILL.md @@ -0,0 +1,209 @@ +--- +name: setup-storefront-platform-in-next-app +description: > + Load this skill when wiring @techsio/storefront-data into a Next.js App + Router storefront through createMedusaStorefrontPreset, + StorefrontDataProvider, createMedusaSdk, createLocalStorageValueStore, and + explicit subpath imports. Use it for the app-level composition module, + provider placement, browser storage seams, and avoiding package-root or + ad-hoc preset wiring. +type: framework +library: "@techsio/storefront-data" +framework: react +library_version: "0.1.0" +requires: [] +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/AGENTS.md" + - "TechsioCZ/new-engine:libs/storefront-data/package.json" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/medusa-client.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/storage-value-store.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/shared/query-client.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/client/provider.tsx" + - "TechsioCZ/new-engine:libs/storefront-data/src/medusa/preset.ts" +--- + +# Setup storefront platform in Next app + +## Setup + +Use one thin storefront composition module, one app-level provider boundary, and explicit file-level imports. + +```js +// next.config.mjs +const nextConfig = { + transpilePackages: ["@techsio/storefront-data"], +} + +export default nextConfig +``` + +```ts +// src/lib/storefront.ts +import { createMedusaSdk } from "@techsio/storefront-data/shared/medusa-client" +import { createLocalStorageValueStore } from "@techsio/storefront-data/shared/storage-value-store" +import { createMedusaStorefrontPreset } from "@techsio/storefront-data/medusa/preset" + +const sdk = createMedusaSdk({ + baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL ?? "http://localhost:9000", + publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "", +}) + +const cartStorage = createLocalStorageValueStore({ + key: "shop-cart-id", +}) + +export const storefront = createMedusaStorefrontPreset({ + sdk, + queryKeyNamespace: "shop", + cart: { + hooks: { + cartStorage, + }, + }, +}) +``` + +```tsx +// app/providers.tsx +"use client" + +import type { PropsWithChildren } from "react" +import { StorefrontDataProvider } from "@techsio/storefront-data/client/provider" + +export function Providers({ children }: PropsWithChildren) { + return {children} +} +``` + +## Hooks and Components + +### Expose only the preset surface the app actually needs + +```ts +// src/lib/storefront-surface.ts +import { storefront } from "@/src/lib/storefront" + +export const { auth, cart, catalog, checkout, collections, products, regions } = + storefront.hooks + +export const { cart: cartFlow, checkout: checkoutFlow } = storefront.flows +``` + +Keep this layer thin. SDK config, local field defaults, address adapters, and storefront-specific policy belong here. Query keys, services, hooks, and flows stay owned by the preset. + +### Pass a stable browser QueryClient only when the app really needs overrides + +```tsx +// app/providers.tsx +"use client" + +import type { PropsWithChildren } from "react" +import { StorefrontDataProvider } from "@techsio/storefront-data/client/provider" +import { getQueryClient } from "@techsio/storefront-data/shared/query-client" + +const client = getQueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}) + +export function Providers({ children }: PropsWithChildren) { + return {children} +} +``` + +If the app does not need browser-client overrides, keep the provider as `{children}`. + +## Common Mistakes + +### CRITICAL Package-root imports + +Wrong: + +```ts +import { StorefrontDataProvider } from "@techsio/storefront-data" +``` + +Correct: + +```ts +import { StorefrontDataProvider } from "@techsio/storefront-data/client/provider" +import { createMedusaStorefrontPreset } from "@techsio/storefront-data/medusa/preset" +``` + +The root export is intentionally disabled. Treat explicit subpaths as the supported public surface. + +Source: `libs/storefront-data/package.json`, `libs/storefront-data/README.md` + +### HIGH Ad-hoc hook assembly instead of a preset + +Wrong: + +```ts +const products = createProductHooks({ service, queryKeyNamespace: "shop" }) +const cart = createCartHooks({ service, queryKeyNamespace: "shop" }) +``` + +Correct: + +```ts +const storefront = createMedusaStorefrontPreset({ + sdk, + queryKeyNamespace: "shop", +}) +``` + +The current architecture expects one preset to own hooks, services, query keys, cache semantics, and flow wrappers for a storefront. + +Source: `libs/storefront-data/README.md`, `libs/storefront-data/src/medusa/preset.ts` + +### CRITICAL Server helpers inside client code + +Wrong: + +```tsx +"use client" + +import { getServerQueryClient } from "@techsio/storefront-data/server/get-query-client" +``` + +Correct: + +```tsx +"use client" + +import { StorefrontDataProvider } from "@techsio/storefront-data/client/provider" +``` + +`server/get-query-client` guards against client usage because its request-scoped behavior only makes sense on the server. + +Source: `libs/storefront-data/src/server/get-query-client.ts` + +### HIGH Late provider config changes rebuild the browser client + +Wrong: + +```tsx + + {children} + +``` + +Correct: + +```tsx +const client = getQueryClient({ + defaultOptions: { queries: { staleTime: 0 } }, +}) + +{children} +``` + +The internal browser QueryClient is a singleton. Only the first internal initialization sees `clientConfig`. + +Source: `libs/storefront-data/src/client/provider.tsx`, `libs/storefront-data/src/shared/query-client.ts` + +See also: `implement-ssr-prefetch-and-query-client-boundaries` for server-side hydration and query-client ownership. diff --git a/libs/storefront-data/skills/use-catalog-and-product-read-flows/SKILL.md b/libs/storefront-data/skills/use-catalog-and-product-read-flows/SKILL.md new file mode 100644 index 000000000..7c64a80c1 --- /dev/null +++ b/libs/storefront-data/skills/use-catalog-and-product-read-flows/SKILL.md @@ -0,0 +1,237 @@ +--- +name: use-catalog-and-product-read-flows +description: > + Load this skill when reading products, catalog listings, categories, + collections, or regions from @techsio/storefront-data through + storefront.hooks.products, storefront.hooks.catalog, and the related read + hooks. Use it for region-aware inputs, query-option helpers, and suspense + usage that waits until required params actually exist. +type: core +library: "@techsio/storefront-data" +library_version: "0.1.0" +requires: + - setup-storefront-platform-in-next-app +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/products/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/products/medusa-service.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/catalog/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/catalog/medusa-service.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/collections/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/categories/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/regions/hooks.ts" +--- + +## Setup + +Start from the preset-owned read hooks. Keep region inputs explicit in the app code that knows them. + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function ProductGrid({ + regionId, + countryCode, +}: { + regionId: string + countryCode: string +}) { + const query = storefront.hooks.products.useProducts({ + region_id: regionId, + country_code: countryCode, + limit: 24, + }) + + if (query.isLoading) return
Loading...
+ + return ( +
    + {query.products.map((product) => ( +
  • {product.title}
  • + ))} +
+ ) +} +``` + +## Core Patterns + +### Filter catalog listings through the shared catalog hook + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function CategoryListing({ + regionId, + countryCode, + categoryId, +}: { + regionId: string + countryCode: string + categoryId: string +}) { + const query = storefront.hooks.catalog.useCatalogProducts({ + region_id: regionId, + country_code: countryCode, + category_id: [categoryId], + limit: 24, + }) + + if (query.isLoading) return
Loading...
+ + return
{query.products.length} products
+} +``` + +### Use suspense detail hooks only after route and region params are ready + +```tsx +"use client" + +import { storefront } from "@/src/lib/storefront" + +export function ProductHero({ + handle, + regionId, + countryCode, +}: { + handle: string + regionId: string + countryCode: string +}) { + const query = storefront.hooks.products.useSuspenseProduct({ + handle, + region_id: regionId, + country_code: countryCode, + }) + + if (!query.product) return null + + return

{query.product.title}

+} +``` + +### Pull option objects from the hooks when SSR or manual cache work is needed + +```ts +import { storefront } from "@/src/lib/storefront" + +const listQuery = storefront.hooks.products.getListQueryOptions({ + region_id: "reg_123", + country_code: "cz", + limit: 24, +}) + +const detailQuery = storefront.hooks.products.getDetailQueryOptions({ + handle: "classic-tee", + region_id: "reg_123", + country_code: "cz", +}) +``` + +Use the hook surface for most reads. Use query-option helpers when SSR, manual prefetch, or loader integration needs the exact query shape. + +## Common Mistakes + +### CRITICAL Suspense before params exist + +Wrong: + +```tsx +const query = storefront.hooks.products.useSuspenseProduct({ + handle: params.handle, + region_id: regionId, +}) +``` + +Correct: + +```tsx +const query = storefront.hooks.products.useProduct({ + handle: params.handle, + region_id: regionId, + enabled: Boolean(params.handle && regionId), +}) +``` + +Suspense variants assume required inputs already exist. During routing or region bootstrap they throw instead of waiting. + +Source: `libs/storefront-data/src/products/hooks.ts`, `libs/storefront-data/src/categories/hooks.ts`, `libs/storefront-data/src/collections/hooks.ts` + +### HIGH App-local `useQuery` wrappers for Medusa reads + +Wrong: + +```ts +const products = useQuery({ + queryKey: ["products", params], + queryFn: () => sdk.store.product.list(params), +}) +``` + +Correct: + +```ts +const products = storefront.hooks.products.useProducts(params) +``` + +App-local wrappers duplicate the normalization, query-key rules, and bug fixes that the preset is supposed to centralize. + +Source: `libs/storefront-data/AGENTS.md`, maintainer interview + +### HIGH Dropping region-sensitive inputs + +Wrong: + +```ts +storefront.hooks.catalog.useCatalogProducts({ + category_id: ["cat_123"], + limit: 24, +}) +``` + +Correct: + +```ts +storefront.hooks.catalog.useCatalogProducts({ + category_id: ["cat_123"], + region_id: "reg_123", + country_code: "cz", + limit: 24, +}) +``` + +Product and catalog services normalize region-aware inputs. Missing them fragments payload shape and cache identity across the storefront. + +Source: `libs/storefront-data/src/catalog/medusa-service.ts`, `libs/storefront-data/src/products/medusa-service.ts` + +### HIGH Assuming custom field defaults come from the library + +Wrong: + +```ts +storefront.hooks.products.useProduct({ + handle: "classic-tee", + region_id: "reg_123", +}) +``` + +Correct: + +```ts +storefront.hooks.products.useProduct({ + handle: "classic-tee", + region_id: "reg_123", + fields: "+variants,+metadata", +}) +``` + +The README explicitly keeps product field defaults in the local storefront composition layer. Do not assume a universal default bundle for every storefront. + +Source: `libs/storefront-data/README.md`, `libs/storefront-data/src/products/types.ts` + +See also: `configure-pagination-prefetch-and-cache-policy` for prefetch behavior and normalized query-key rules. diff --git a/libs/storefront-data/skills/use-storefront-data-skills/SKILL.md b/libs/storefront-data/skills/use-storefront-data-skills/SKILL.md new file mode 100644 index 000000000..b144233c9 --- /dev/null +++ b/libs/storefront-data/skills/use-storefront-data-skills/SKILL.md @@ -0,0 +1,81 @@ +--- +name: use-storefront-data-skills +description: > + Load this skill first for any work involving @techsio/storefront-data skills, + app integration, shared storefront hooks, product lists, cart, checkout, + auth, SSR prefetch, cache policy, or deciding whether behavior belongs in an + app or in the shared storefront-data platform. Use it as the orchestrator + that selects the smallest relevant storefront-data skill set before editing. +type: orchestrator +library: "@techsio/storefront-data" +library_version: "0.1.0" +sources: + - "TechsioCZ/new-engine:libs/storefront-data/README.md" + - "TechsioCZ/new-engine:libs/storefront-data/AGENTS.md" + - "TechsioCZ/new-engine:libs/storefront-data/src/medusa/preset.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/product-lists/hooks.ts" + - "TechsioCZ/new-engine:libs/storefront-data/src/product-lists/medusa-service.ts" +--- + +# Use storefront-data skills + +Start here when a task touches `@techsio/storefront-data` or an app that +consumes it. Pick only the skills needed for the current work. + +## Selection + +| Task | Load | +| --- | --- | +| Wire `@techsio/storefront-data` into a Next storefront | `setup-storefront-platform-in-next-app` | +| Replace app-local Medusa hooks, services, or query keys | `migrate-custom-hooks-to-storefront-data` | +| Decide app wrapper vs shared platform ownership | `decide-app-specific-overrides-vs-shared-platform` | +| Add or extend a shared backend-facing capability | `extend-storefront-data-for-new-backend-use-cases` | +| Read products, catalog, categories, collections, or regions | `use-catalog-and-product-read-flows` | +| Implement product-list behavior in an app | `migrate-custom-hooks-to-storefront-data` + `decide-app-specific-overrides-vs-shared-platform` | +| Extend product-list shared service, hooks, query keys, or SSR reads | `extend-storefront-data-for-new-backend-use-cases` + `configure-pagination-prefetch-and-cache-policy` | +| Work on cart, active-cart state, checkout, or payment flow | `implement-cart-and-checkout-platform-flows` | +| Work on login, register, session, logout, or auth invalidation | `implement-auth-and-customer-session-flows` | +| Add SSR prefetch, hydration, loaders, or query options | `implement-ssr-prefetch-and-query-client-boundaries` | +| Tune cache, prefetch, skip modes, or pagination | `configure-pagination-prefetch-and-cache-policy` | +| Review before release | `audit-storefront-before-release` | + +## Operating rules + +- Prefer the preset surface: `storefront.hooks.*`, `storefront.flows.*`, + `storefront.queries.*`, and preset-owned `queryKeys`. +- Keep app code thin: UI, localized text, toasts, analytics, app DTOs, + customer-specific mapping, and adapters can stay local. +- Move repeated backend communication, query keys, cache sync, SSR query + options, and mutation invalidation into `libs/storefront-data`. +- Do not keep old and new data layers active for the same screen. Finish one + vertical migration before starting another. +- Use explicit subpath imports. Do not add package-root imports or barrel + re-exports. + +## Product-list notes + +Product lists are a shared platform domain in this package: + +- service: `src/product-lists/medusa-service.ts` +- hooks: `src/product-lists/hooks.ts` +- query keys: `src/product-lists/query-keys.ts` +- query options and SSR read support: `src/product-lists/query-options.ts` +- utilities: `src/product-lists/utils.ts` +- preset wiring: `src/medusa/preset.ts` and `src/medusa/server-read.ts` + +Apps should normally consume product lists through +`storefront.hooks.productLists` and keep only storefront-specific wrappers for +labels, errors, UI behavior, and analytics. + +## Validation + +After editing skills, run: + +```bash +npx @tanstack/intent@latest validate libs/storefront-data/skills +npx @tanstack/intent@latest stale libs/storefront-data/skills +``` + +If skills must be published with `@techsio/storefront-data`, ensure +`libs/storefront-data/package.json` includes the `tanstack-intent` keyword, +ships the `skills` directory, and pins `@tanstack/intent` for validation. diff --git a/libs/storefront-data/src/medusa/foundation.ts b/libs/storefront-data/src/medusa/foundation.ts index 1a2fafcb1..d9071b18c 100644 --- a/libs/storefront-data/src/medusa/foundation.ts +++ b/libs/storefront-data/src/medusa/foundation.ts @@ -16,6 +16,8 @@ import { createOrderQueryKeys } from "../orders/query-keys" import type { OrderQueryKeys } from "../orders/types" import { createProductQueryKeys } from "../products/query-keys" import type { ProductQueryKeys } from "../products/types" +import { createProductListQueryKeys } from "../product-lists/query-keys" +import type { ProductListQueryKeys } from "../product-lists/types" import { createRegionQueryKeys } from "../regions/query-keys" import type { RegionQueryKeys } from "../regions/types" import { type CacheConfig, createCacheConfig } from "../shared/cache-config" @@ -36,6 +38,10 @@ import type { MedusaProductDetailInput, MedusaProductListInput, } from "../products/medusa-service" +import type { + MedusaProductListDetailKeyInput, + MedusaProductListListKeyInput, +} from "../product-lists/medusa-service" import type { MedusaRegionDetailInput, MedusaRegionListInput, @@ -50,6 +56,10 @@ export type MedusaStorefrontQueryKeys = { cart: CartQueryKeys checkout: CheckoutQueryKeys products: ProductQueryKeys + productLists: ProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > orders: OrderQueryKeys customers: CustomerQueryKeys regions: RegionQueryKeys @@ -80,6 +90,10 @@ export function createMedusaStorefrontQueryKeys( MedusaProductListInput, MedusaProductDetailInput >(namespace), + productLists: createProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + >(namespace), orders: createOrderQueryKeys( namespace ), diff --git a/libs/storefront-data/src/medusa/preset.ts b/libs/storefront-data/src/medusa/preset.ts index cfe665e70..df9cd8529 100644 --- a/libs/storefront-data/src/medusa/preset.ts +++ b/libs/storefront-data/src/medusa/preset.ts @@ -123,6 +123,26 @@ import type { MedusaProductServiceConfig, } from "../products/medusa-service" import type { ProductQueryKeys } from "../products/types" +import { + type CreateProductListHooksConfig, + createProductListHooks, + type ProductListHooks, +} from "../product-lists/hooks" +import type { + MedusaProductListDetailHookInput, + MedusaProductListDetailInput, + MedusaProductListDetailKeyInput, + MedusaProductListListHookInput, + MedusaProductListListInput, + MedusaProductListListKeyInput, + MedusaProductListServiceConfig, +} from "../product-lists/medusa-service" +import type { + ProductListBase, + ProductListItemBase, + ProductListQueryKeys, + ProductListService, +} from "../product-lists/types" import { type CreateRegionHooksConfig, createRegionHooks, @@ -224,6 +244,23 @@ type MedusaProductHooksConfig = OmitFactoryConfig< > > +type MedusaProductListHooksConfig = Omit< + OmitFactoryConfig< + CreateProductListHooksConfig< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart, + MedusaProductListListHookInput, + MedusaProductListListInput, + MedusaProductListDetailHookInput, + MedusaProductListDetailInput, + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > + >, + "cartQueryKeys" | "cartStorage" | "isActiveCartQueryKey" +> + type MedusaOrderHooksConfig = OmitFactoryConfig< CreateOrderHooksConfig< HttpTypes.StoreOrder, @@ -240,6 +277,14 @@ type MedusaOrderService = OrderService< MedusaOrderDetailInput > +type MedusaProductListService = ProductListService< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart, + MedusaProductListListInput, + MedusaProductListDetailInput +> + type MedusaCustomerAddressUpdateHookInput = MedusaCustomerAddressUpdateInput & { addressId?: string } @@ -387,6 +432,19 @@ type CreateMedusaStorefrontPresetConfigBase< MedusaProductDetailInput > } + productLists?: { + service?: MedusaProductListService + serviceConfig?: MedusaProductListServiceConfig< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart + > + hooks?: MedusaProductListHooksConfig + queryKeys?: ProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > + } orders?: { service?: MedusaOrderService serviceConfig?: MedusaOrderServiceConfig @@ -471,6 +529,7 @@ type MedusaStorefrontServices< MedusaProductDetailInput > > + productLists: MedusaProductListService orders: MedusaOrderService customers: MedusaCustomerService regions: ReturnType @@ -543,6 +602,13 @@ type MedusaStorefrontHooks< MedusaProductListInput, MedusaProductDetailInput > + productLists: ProductListHooks< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart, + MedusaProductListListHookInput, + MedusaProductListDetailHookInput + > orders: OrderHooks< HttpTypes.StoreOrder, MedusaOrderListHookInput, @@ -693,6 +759,8 @@ export function createMedusaStorefrontPreset< cart: config.cart?.queryKeys ?? defaultQueryKeys.cart, checkout: config.checkout?.queryKeys ?? defaultQueryKeys.checkout, products: config.products?.queryKeys ?? defaultQueryKeys.products, + productLists: + config.productLists?.queryKeys ?? defaultQueryKeys.productLists, orders: config.orders?.queryKeys ?? defaultQueryKeys.orders, customers: config.customers?.queryKeys ?? defaultQueryKeys.customers, regions: config.regions?.queryKeys ?? defaultQueryKeys.regions, @@ -717,6 +785,12 @@ export function createMedusaStorefrontPreset< hooks: config.products?.hooks, queryKeys: queryKeys.products, }, + productLists: { + service: config.productLists?.service, + serviceConfig: config.productLists?.serviceConfig, + hooks: config.productLists?.hooks, + queryKeys: queryKeys.productLists, + }, orders: { service: config.orders?.service, serviceConfig: config.orders?.serviceConfig, @@ -754,6 +828,7 @@ export function createMedusaStorefrontPreset< config.checkout?.serviceConfig ), products: serverRead.services.products, + productLists: serverRead.services.productLists, orders: serverRead.services.orders, customers: config.customers?.service ?? createMedusaCustomerService(config.sdk), @@ -778,10 +853,12 @@ export function createMedusaStorefrontPreset< const presetAuthInvalidateKeys = [ queryKeys.customers.all(), queryKeys.orders.all(), + queryKeys.productLists.all(), ] const presetAuthRemoveOnLogoutKeys = [ queryKeys.customers.all(), queryKeys.orders.all(), + queryKeys.productLists.all(), ] return { includeDefaults: authInvalidationOverrides?.includeDefaults ?? false, @@ -864,6 +941,26 @@ export function createMedusaStorefrontPreset< queryKeyNamespace: namespace, cacheConfig: resolvedCacheConfig, }), + productLists: createProductListHooks< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart, + MedusaProductListListHookInput, + MedusaProductListListInput, + MedusaProductListDetailHookInput, + MedusaProductListDetailInput, + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + >({ + ...(config.productLists?.hooks ?? {}), + service: services.productLists, + queryKeys: queryKeys.productLists, + queryKeyNamespace: namespace, + cacheConfig: resolvedCacheConfig, + cartQueryKeys: queryKeys.cart, + cartStorage: cartHookOverrides?.cartStorage, + isActiveCartQueryKey: resolvedCheckoutActiveCartQueryKey, + }), orders: createOrderHooks< HttpTypes.StoreOrder, MedusaOrderListHookInput, diff --git a/libs/storefront-data/src/medusa/server-read.ts b/libs/storefront-data/src/medusa/server-read.ts index db6a65212..4db7c99e2 100644 --- a/libs/storefront-data/src/medusa/server-read.ts +++ b/libs/storefront-data/src/medusa/server-read.ts @@ -53,6 +53,27 @@ import { type MedusaOrderServiceConfig, } from "../orders/medusa-service" import type { OrderQueryKeys, OrderService } from "../orders/types" +import { + createMedusaProductListService, + type MedusaProductListDetailHookInput, + type MedusaProductListDetailInput, + type MedusaProductListDetailKeyInput, + type MedusaProductListListHookInput, + type MedusaProductListListInput, + type MedusaProductListListKeyInput, + type MedusaProductListServiceConfig, +} from "../product-lists/medusa-service" +import { + type CreateProductListQueryOptionsFactoryConfig, + createProductListQueryOptionsFactory, + type ProductListQueryOptionsFactory, +} from "../product-lists/query-options" +import type { + ProductListBase, + ProductListItemBase, + ProductListQueryKeys, + ProductListService, +} from "../product-lists/types" import type { CreateProductHooksConfig, } from "../products/hooks" @@ -116,6 +137,27 @@ type MedusaOrderServerReadHooksConfig = Pick< "buildListParams" | "buildDetailParams" > +type MedusaProductListServerReadHooksConfig = Pick< + OmitFactoryConfig< + CreateProductListQueryOptionsFactoryConfig< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart, + MedusaProductListListHookInput, + MedusaProductListListInput, + MedusaProductListDetailHookInput, + MedusaProductListDetailInput, + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > + >, + | "buildListParams" + | "buildDetailParams" + | "buildListKeyParams" + | "buildDetailKeyParams" + | "defaultPageSize" +> + type MedusaRegionServerReadHooksConfig = Pick< OmitFactoryConfig< CreateRegionHooksConfig< @@ -174,6 +216,10 @@ type MedusaCatalogServerReadHooksConfig = Pick< type MedusaStorefrontReadQueryKeys = { products: ProductQueryKeys + productLists: ProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > orders: OrderQueryKeys regions: RegionQueryKeys categories: CategoryQueryKeys< @@ -193,6 +239,14 @@ type MedusaOrderReadService = OrderService< MedusaOrderDetailInput > +type MedusaProductListReadService = ProductListService< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart, + MedusaProductListListInput, + MedusaProductListDetailInput +> + export type CreateMedusaStorefrontServerReadPresetConfig< TProduct = HttpTypes.StoreProduct, TCategory = HttpTypes.StoreProductCategory, @@ -215,6 +269,19 @@ export type CreateMedusaStorefrontServerReadPresetConfig< MedusaProductDetailInput > } + productLists?: { + service?: MedusaProductListReadService + serviceConfig?: MedusaProductListServiceConfig< + ProductListBase, + ProductListItemBase, + HttpTypes.StoreCart + > + hooks?: MedusaProductListServerReadHooksConfig + queryKeys?: ProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > + } orders?: { service?: MedusaOrderReadService serviceConfig?: MedusaOrderServiceConfig @@ -274,6 +341,7 @@ type MedusaStorefrontReadServices< MedusaProductDetailInput > > + productLists: MedusaProductListReadService orders: MedusaOrderReadService regions: ReturnType categories: ReturnType< @@ -311,6 +379,11 @@ type MedusaStorefrontReadQueries< MedusaProductListInput, MedusaProductDetailInput > + productLists: ProductListQueryOptionsFactory< + ProductListBase, + MedusaProductListListHookInput, + MedusaProductListDetailHookInput + > orders: OrderQueryOptionsFactory< HttpTypes.StoreOrder, MedusaOrderListHookInput, @@ -393,6 +466,8 @@ export function createMedusaStorefrontServerReadPreset< const queryKeys: MedusaStorefrontReadQueryKeys = { products: config.products?.queryKeys ?? defaultQueryKeys.products, + productLists: + config.productLists?.queryKeys ?? defaultQueryKeys.productLists, orders: config.orders?.queryKeys ?? defaultQueryKeys.orders, regions: config.regions?.queryKeys ?? defaultQueryKeys.regions, categories: config.categories?.queryKeys ?? defaultQueryKeys.categories, @@ -412,6 +487,12 @@ export function createMedusaStorefrontServerReadPreset< MedusaProductListInput, MedusaProductDetailInput >(config.sdk, config.products?.serviceConfig), + productLists: + config.productLists?.service ?? + createMedusaProductListService( + config.sdk, + config.productLists?.serviceConfig + ), orders: config.orders?.service ?? createMedusaOrderService(config.sdk, config.orders?.serviceConfig), @@ -447,6 +528,13 @@ export function createMedusaStorefrontServerReadPreset< cacheConfig, ...(config.products?.hooks ?? {}), }), + productLists: createProductListQueryOptionsFactory({ + service: services.productLists, + queryKeys: queryKeys.productLists, + queryKeyNamespace: namespace, + cacheConfig, + ...(config.productLists?.hooks ?? {}), + }), orders: createOrderQueryOptionsFactory({ service: services.orders, queryKeys: queryKeys.orders, diff --git a/libs/storefront-data/src/product-lists/hooks.ts b/libs/storefront-data/src/product-lists/hooks.ts new file mode 100644 index 000000000..6677d792e --- /dev/null +++ b/libs/storefront-data/src/product-lists/hooks.ts @@ -0,0 +1,1045 @@ +import { + useMutation, + useQueries, + useQuery, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query" +import type { UseMutationResult } from "@tanstack/react-query" +import { + type ActiveCartQueryKeyMatcher, + syncCartCaches, +} from "../shared/cart-cache-sync" +import { + type CacheConfig, + type CacheStrategy, + createCacheConfig, + getPrefetchCacheOptions, +} from "../shared/cache-config" +import { toErrorMessage } from "../shared/error-utils" +import type { + QueryFactoryOptions, + ReadQueryOptions, + SuspenseQueryOptions, +} from "../shared/hook-types" +import type { QueryResult } from "../shared/hook-result-types" +import { type PrefetchSkipMode, shouldSkipPrefetch } from "../shared/prefetch" +import type { QueryNamespace } from "../shared/query-keys" +import type { CartQueryKeys } from "../cart/types" +import type { StorageValueStore } from "../shared/storage-value-store" +import { useDelayedPrefetchController } from "../shared/use-delayed-prefetch-controller" +import { + createDefaultListParams, + stripDetailInput, + withCustomerScope, +} from "./input-utils" +import { createProductListQueryKeys } from "./query-keys" +import type { + AddFavoriteProductListItemInput, + AddProductListItemInput, + ChangeProductListItemQuantityInput, + CreateCustomProductListInput, + CreateFavoriteProductListInput, + CreateProductListCartInput, + DeleteProductListInput, + DeleteProductListItemInput, + IncrementProductListItemInput, + ProductListCartLike, + ProductListDeleteResponse, + ProductListDetailInputBase, + ProductListListInputBase, + ProductListListResult, + ProductListMutationOptions, + ProductListQueryKeys, + ProductListService, + UpdateProductListInput, + UpdateProductListItemInput, + UseProductListResult, + UseProductListsResult, + UseSuspenseProductListResult, + UseSuspenseProductListsResult, +} from "./types" + +type SuspenseListInput = Omit< + TInput, + "enabled" +> +type SuspenseDetailInput = Omit< + TInput, + "enabled" | "id" +> & { + id: NonNullable +} + +export type ProductListPrefetchHookOptions = { + cacheStrategy?: CacheStrategy + defaultDelay?: number + skipIfCached?: boolean + skipMode?: PrefetchSkipMode +} + +export type ProductListPrefetchOptions = { + cacheStrategy?: CacheStrategy + prefetchedBy?: string + skipIfCached?: boolean + skipMode?: PrefetchSkipMode +} + +export type CreateProductListHooksConfig< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListInput extends ProductListListInputBase, + TListParams, + TDetailInput extends ProductListDetailInputBase, + TDetailParams, + TListKeyParams = TListParams & { customerId?: string | null }, + TDetailKeyParams = TDetailParams & { customerId?: string | null }, +> = { + service: ProductListService< + TProductList, + TProductListItem, + TCart, + TListParams, + TDetailParams + > + buildListParams?: (input: TListInput) => TListParams + buildDetailParams?: (input: TDetailInput) => TDetailParams + buildListKeyParams?: ( + input: TListInput, + params: TListParams + ) => TListKeyParams + buildDetailKeyParams?: ( + input: TDetailInput, + params: TDetailParams + ) => TDetailKeyParams + queryKeys?: ProductListQueryKeys + queryKeyNamespace?: QueryNamespace + cacheConfig?: CacheConfig + defaultPageSize?: number + cartQueryKeys?: CartQueryKeys + cartStorage?: StorageValueStore + isActiveCartQueryKey?: ActiveCartQueryKeyMatcher +} + +export type ProductListHooks< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListInput extends ProductListListInputBase, + TDetailInput extends ProductListDetailInputBase, +> = { + getListQueryOptions: ( + input: TListInput, + options?: { + queryOptions?: ReadQueryOptions> + } + ) => QueryFactoryOptions> + getDetailQueryOptions: ( + input: TDetailInput, + options?: { + queryOptions?: ReadQueryOptions + } + ) => QueryFactoryOptions + useProductLists: ( + input?: TListInput, + options?: { + queryOptions?: ReadQueryOptions> + } + ) => UseProductListsResult + useSuspenseProductLists: ( + input?: SuspenseListInput, + options?: { + queryOptions?: SuspenseQueryOptions> + } + ) => UseSuspenseProductListsResult + useProductList: ( + input: TDetailInput, + options?: { + queryOptions?: ReadQueryOptions + } + ) => UseProductListResult + useSuspenseProductList: ( + input: SuspenseDetailInput, + options?: { + queryOptions?: SuspenseQueryOptions + } + ) => UseSuspenseProductListResult + useProductListDetails: ( + inputs: TDetailInput[], + options?: { + enabled?: boolean + queryOptions?: ReadQueryOptions + } + ) => QueryResult[] + usePrefetchProductLists: (options?: ProductListPrefetchHookOptions) => { + prefetchProductLists: ( + input?: TListInput, + prefetchOptions?: ProductListPrefetchOptions + ) => Promise + delayedPrefetch: ( + input?: TListInput, + delay?: number, + prefetchId?: string + ) => string + cancelPrefetch: (prefetchId: string) => void + } + usePrefetchProductList: (options?: ProductListPrefetchHookOptions) => { + prefetchProductList: ( + input: TDetailInput, + prefetchOptions?: ProductListPrefetchOptions + ) => Promise + delayedPrefetch: ( + input: TDetailInput, + delay?: number, + prefetchId?: string + ) => string + cancelPrefetch: (prefetchId: string) => void + } + useCreateFavoriteProductList: ( + options?: ProductListMutationOptions< + TProductList | null, + CreateFavoriteProductListInput, + TContext + > + ) => UseMutationResult< + TProductList | null, + unknown, + CreateFavoriteProductListInput, + TContext + > + useCreateCustomProductList: ( + options?: ProductListMutationOptions< + TProductList | null, + CreateCustomProductListInput, + TContext + > + ) => UseMutationResult< + TProductList | null, + unknown, + CreateCustomProductListInput, + TContext + > + useUpdateProductList: ( + options?: ProductListMutationOptions< + TProductList | null, + UpdateProductListInput, + TContext + > + ) => UseMutationResult< + TProductList | null, + unknown, + UpdateProductListInput, + TContext + > + useDeleteProductList: ( + options?: ProductListMutationOptions< + ProductListDeleteResponse, + DeleteProductListInput, + TContext + > + ) => UseMutationResult< + ProductListDeleteResponse, + unknown, + DeleteProductListInput, + TContext + > + useAddProductListItem: ( + options?: ProductListMutationOptions< + TProductListItem | null, + AddProductListItemInput, + TContext + > + ) => UseMutationResult< + TProductListItem | null, + unknown, + AddProductListItemInput, + TContext + > + useAddFavoriteProductListItem: ( + options?: ProductListMutationOptions< + TProductListItem | null, + AddFavoriteProductListItemInput, + TContext + > + ) => UseMutationResult< + TProductListItem | null, + unknown, + AddFavoriteProductListItemInput, + TContext + > + useCreateProductListCart: ( + options?: ProductListMutationOptions + ) => UseMutationResult + useUpdateProductListItem: ( + options?: ProductListMutationOptions< + TProductListItem | null, + UpdateProductListItemInput, + TContext + > + ) => UseMutationResult< + TProductListItem | null, + unknown, + UpdateProductListItemInput, + TContext + > + useChangeProductListItemQuantity: ( + options?: ProductListMutationOptions< + TProductListItem | null, + ChangeProductListItemQuantityInput, + TContext + > + ) => UseMutationResult< + TProductListItem | null, + unknown, + ChangeProductListItemQuantityInput, + TContext + > + useIncrementProductListItem: ( + options?: ProductListMutationOptions< + TProductListItem | null, + IncrementProductListItemInput, + TContext + > + ) => UseMutationResult< + TProductListItem | null, + unknown, + IncrementProductListItemInput, + TContext + > + useDeleteProductListItem: ( + options?: ProductListMutationOptions< + ProductListDeleteResponse, + DeleteProductListItemInput, + TContext + > + ) => UseMutationResult< + ProductListDeleteResponse, + unknown, + DeleteProductListItemInput, + TContext + > +} + +export function createProductListHooks< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListInput extends ProductListListInputBase, + TListParams = Omit, + TDetailInput extends ProductListDetailInputBase = ProductListDetailInputBase, + TDetailParams = Omit, + TListKeyParams = TListParams & { customerId?: string | null }, + TDetailKeyParams = TDetailParams & { customerId?: string | null }, +>({ + service, + buildListParams, + buildDetailParams, + buildListKeyParams, + buildDetailKeyParams, + queryKeys, + queryKeyNamespace = "storefront-data", + cacheConfig, + defaultPageSize = 20, + cartQueryKeys, + cartStorage, + isActiveCartQueryKey, +}: CreateProductListHooksConfig< + TProductList, + TProductListItem, + TCart, + TListInput, + TListParams, + TDetailInput, + TDetailParams, + TListKeyParams, + TDetailKeyParams +>): ProductListHooks< + TProductList, + TProductListItem, + TCart, + TListInput, + TDetailInput +> { + const resolvedCacheConfig = cacheConfig ?? createCacheConfig() + const resolvedQueryKeys = + queryKeys ?? + createProductListQueryKeys( + queryKeyNamespace + ) + const buildList = + buildListParams ?? + ((input: TListInput) => + createDefaultListParams(input, defaultPageSize) as TListParams) + const buildDetail = + buildDetailParams ?? + ((input: TDetailInput) => stripDetailInput(input) as TDetailParams) + const buildListKey = + buildListKeyParams ?? + ((input: TListInput, params: TListParams) => + withCustomerScope(params, input) as TListKeyParams) + const buildDetailKey = + buildDetailKeyParams ?? + ((input: TDetailInput, params: TDetailParams) => + withCustomerScope(params, input) as TDetailKeyParams) + + const getListQueryOptions = ( + input: TListInput, + options?: { + queryOptions?: ReadQueryOptions> + } + ): QueryFactoryOptions> => { + const listParams = buildList(input) + + return { + queryKey: resolvedQueryKeys.list(buildListKey(input, listParams)), + queryFn: ({ signal }) => service.listProductLists(listParams, signal), + ...resolvedCacheConfig.userData, + ...(options?.queryOptions ?? {}), + } + } + + const getDetailQueryOptions = ( + input: TDetailInput, + options?: { + queryOptions?: ReadQueryOptions + } + ): QueryFactoryOptions => { + const detailParams = buildDetail(input) + + return { + queryKey: resolvedQueryKeys.detail(buildDetailKey(input, detailParams)), + queryFn: ({ signal }) => { + if (!input.id) { + throw new Error("Product list id is required") + } + + return service.getProductList(detailParams, signal) + }, + ...resolvedCacheConfig.userData, + ...(options?.queryOptions ?? {}), + } + } + + const createProductListsPrefetchQueryOptions = ( + input: TListInput, + options?: { + cacheStrategy?: CacheStrategy + prefetchedBy?: string + } + ) => { + const listParams = buildList(input) + const prefetchCacheOptions = getPrefetchCacheOptions( + resolvedCacheConfig, + options?.cacheStrategy ?? "userData" + ) + + return { + queryKey: resolvedQueryKeys.list(buildListKey(input, listParams)), + queryFn: ({ signal }: { signal?: AbortSignal }) => + service.listProductLists(listParams, signal), + ...prefetchCacheOptions, + meta: options?.prefetchedBy + ? { prefetchedBy: options.prefetchedBy } + : undefined, + } + } + + const createProductListPrefetchQueryOptions = ( + input: TDetailInput, + options?: { + cacheStrategy?: CacheStrategy + prefetchedBy?: string + } + ) => { + const detailParams = buildDetail(input) + const prefetchCacheOptions = getPrefetchCacheOptions( + resolvedCacheConfig, + options?.cacheStrategy ?? "userData" + ) + + return { + queryKey: resolvedQueryKeys.detail(buildDetailKey(input, detailParams)), + queryFn: ({ signal }: { signal?: AbortSignal }) => + service.getProductList(detailParams, signal), + ...prefetchCacheOptions, + meta: options?.prefetchedBy + ? { prefetchedBy: options.prefetchedBy } + : undefined, + } + } + + const invalidateProductLists = ( + queryClient: ReturnType + ) => + queryClient.invalidateQueries({ + queryKey: resolvedQueryKeys.all(), + }) + + function useProductLists( + input = {} as TListInput, + options?: { + queryOptions?: ReadQueryOptions> + } + ): UseProductListsResult { + const enabled = input.enabled ?? true + const query = useQuery({ + ...getListQueryOptions(input, options), + enabled, + }) + const { data, isLoading, isFetching, isSuccess, error } = query + + return { + productLists: data?.productLists ?? [], + count: data?.count ?? 0, + limit: data?.limit ?? input.limit ?? defaultPageSize, + offset: data?.offset ?? input.offset ?? 0, + isLoading, + isFetching, + isSuccess, + error: toErrorMessage(error), + query, + } + } + + function useSuspenseProductLists( + input = {} as SuspenseListInput, + options?: { + queryOptions?: SuspenseQueryOptions> + } + ): UseSuspenseProductListsResult { + const query = useSuspenseQuery({ + ...getListQueryOptions(input as TListInput, { + queryOptions: options?.queryOptions as ReadQueryOptions< + ProductListListResult + >, + }), + }) + const { data, isFetching } = query + + return { + productLists: data.productLists, + count: data.count, + limit: data.limit, + offset: data.offset, + isLoading: false, + isFetching, + isSuccess: true, + error: null, + query, + } + } + + function useProductList( + input: TDetailInput, + options?: { + queryOptions?: ReadQueryOptions + } + ): UseProductListResult { + const enabled = Boolean(input.id) && (input.enabled ?? true) + const query = useQuery({ + ...getDetailQueryOptions(input, options), + enabled, + }) + const { data, isLoading, isFetching, isSuccess, error } = query + + return { + productList: data ?? null, + isLoading, + isFetching, + isSuccess, + error: toErrorMessage(error), + query, + } + } + + function useSuspenseProductList( + input: SuspenseDetailInput, + options?: { + queryOptions?: SuspenseQueryOptions + } + ): UseSuspenseProductListResult { + if (!input.id) { + throw new Error("Product list id is required") + } + + const query = useSuspenseQuery({ + ...getDetailQueryOptions(input as TDetailInput, { + queryOptions: options?.queryOptions as ReadQueryOptions< + TProductList | null + >, + }), + }) + const { data, isFetching } = query + + return { + productList: data ?? null, + isLoading: false, + isFetching, + isSuccess: true, + error: null, + query, + } + } + + function useProductListDetails( + inputs: TDetailInput[], + options?: { + enabled?: boolean + queryOptions?: ReadQueryOptions + } + ): QueryResult[] { + const enabled = options?.enabled ?? true + + return useQueries({ + queries: inputs.map((input) => ({ + ...getDetailQueryOptions(input, { + queryOptions: options?.queryOptions, + }), + enabled: enabled && Boolean(input.id), + })), + }) + } + + function usePrefetchProductLists(options?: ProductListPrefetchHookOptions) { + const queryClient = useQueryClient() + const { schedulePrefetch, cancelPrefetch } = useDelayedPrefetchController() + const cacheStrategy = options?.cacheStrategy ?? "userData" + const defaultDelay = options?.defaultDelay ?? 800 + const skipIfCached = options?.skipIfCached ?? true + const skipMode = options?.skipMode ?? "fresh" + + const prefetchProductLists = async ( + input = {} as TListInput, + prefetchOptions?: ProductListPrefetchOptions + ) => { + const cacheStrategyResolved = + prefetchOptions?.cacheStrategy ?? cacheStrategy + const skipIfCachedResolved = prefetchOptions?.skipIfCached ?? skipIfCached + const skipModeResolved = prefetchOptions?.skipMode ?? skipMode + const queryOptions = createProductListsPrefetchQueryOptions(input, { + cacheStrategy: cacheStrategyResolved, + prefetchedBy: prefetchOptions?.prefetchedBy, + }) + const prefetchCacheOptions = getPrefetchCacheOptions( + resolvedCacheConfig, + cacheStrategyResolved + ) + + if ( + shouldSkipPrefetch({ + queryClient, + queryKey: queryOptions.queryKey, + cacheOptions: prefetchCacheOptions, + skipIfCached: skipIfCachedResolved, + skipMode: skipModeResolved, + }) + ) { + return + } + + await queryClient.prefetchQuery(queryOptions) + } + + const delayedPrefetch = ( + input = {} as TListInput, + delay = defaultDelay, + prefetchId?: string + ) => { + const queryOptions = createProductListsPrefetchQueryOptions(input, { + cacheStrategy, + }) + const id = prefetchId ?? JSON.stringify(queryOptions.queryKey) + + return schedulePrefetch( + () => { + prefetchProductLists(input) + }, + id, + delay + ) + } + + return { + prefetchProductLists, + delayedPrefetch, + cancelPrefetch, + } + } + + function usePrefetchProductList(options?: ProductListPrefetchHookOptions) { + const queryClient = useQueryClient() + const { schedulePrefetch, cancelPrefetch } = useDelayedPrefetchController() + const cacheStrategy = options?.cacheStrategy ?? "userData" + const defaultDelay = options?.defaultDelay ?? 400 + const skipIfCached = options?.skipIfCached ?? true + const skipMode = options?.skipMode ?? "fresh" + + const prefetchProductList = async ( + input: TDetailInput, + prefetchOptions?: ProductListPrefetchOptions + ) => { + if (!input.id) { + return + } + + const cacheStrategyResolved = + prefetchOptions?.cacheStrategy ?? cacheStrategy + const skipIfCachedResolved = prefetchOptions?.skipIfCached ?? skipIfCached + const skipModeResolved = prefetchOptions?.skipMode ?? skipMode + const queryOptions = createProductListPrefetchQueryOptions(input, { + cacheStrategy: cacheStrategyResolved, + prefetchedBy: prefetchOptions?.prefetchedBy, + }) + const prefetchCacheOptions = getPrefetchCacheOptions( + resolvedCacheConfig, + cacheStrategyResolved + ) + + if ( + shouldSkipPrefetch({ + queryClient, + queryKey: queryOptions.queryKey, + cacheOptions: prefetchCacheOptions, + skipIfCached: skipIfCachedResolved, + skipMode: skipModeResolved, + }) + ) { + return + } + + await queryClient.prefetchQuery(queryOptions) + } + + const delayedPrefetch = ( + input: TDetailInput, + delay = defaultDelay, + prefetchId?: string + ) => { + const queryOptions = createProductListPrefetchQueryOptions(input, { + cacheStrategy, + }) + const id = prefetchId ?? JSON.stringify(queryOptions.queryKey) + + return schedulePrefetch( + () => { + prefetchProductList(input) + }, + id, + delay + ) + } + + return { + prefetchProductList, + delayedPrefetch, + cancelPrefetch, + } + } + + function useCreateFavoriteProductList( + options?: ProductListMutationOptions< + TProductList | null, + CreateFavoriteProductListInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductList | null, + unknown, + CreateFavoriteProductListInput, + TContext + >({ + mutationFn: service.createFavoriteProductList, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useCreateCustomProductList( + options?: ProductListMutationOptions< + TProductList | null, + CreateCustomProductListInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductList | null, + unknown, + CreateCustomProductListInput, + TContext + >({ + mutationFn: service.createCustomProductList, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useUpdateProductList( + options?: ProductListMutationOptions< + TProductList | null, + UpdateProductListInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductList | null, + unknown, + UpdateProductListInput, + TContext + >({ + mutationFn: service.updateProductList, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useDeleteProductList( + options?: ProductListMutationOptions< + ProductListDeleteResponse, + DeleteProductListInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + ProductListDeleteResponse, + unknown, + DeleteProductListInput, + TContext + >({ + mutationFn: service.deleteProductList, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useAddProductListItem( + options?: ProductListMutationOptions< + TProductListItem | null, + AddProductListItemInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductListItem | null, + unknown, + AddProductListItemInput, + TContext + >({ + mutationFn: service.addProductListItem, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useAddFavoriteProductListItem( + options?: ProductListMutationOptions< + TProductListItem | null, + AddFavoriteProductListItemInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductListItem | null, + unknown, + AddFavoriteProductListItemInput, + TContext + >({ + mutationFn: service.addFavoriteProductListItem, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useCreateProductListCart( + options?: ProductListMutationOptions + ) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: service.createProductListCart, + onMutate: options?.onMutate, + onSuccess: (cart, variables, context) => { + if (cartQueryKeys) { + syncCartCaches(queryClient, cartQueryKeys, cart, { + isActiveCartQueryKey, + }) + queryClient.invalidateQueries({ queryKey: cartQueryKeys.all() }) + } + cartStorage?.set(cart.id) + options?.onSuccess?.(cart, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useUpdateProductListItem( + options?: ProductListMutationOptions< + TProductListItem | null, + UpdateProductListItemInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductListItem | null, + unknown, + UpdateProductListItemInput, + TContext + >({ + mutationFn: service.updateProductListItem, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useChangeProductListItemQuantity( + options?: ProductListMutationOptions< + TProductListItem | null, + ChangeProductListItemQuantityInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductListItem | null, + unknown, + ChangeProductListItemQuantityInput, + TContext + >({ + mutationFn: service.changeProductListItemQuantity, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useIncrementProductListItem( + options?: ProductListMutationOptions< + TProductListItem | null, + IncrementProductListItemInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + TProductListItem | null, + unknown, + IncrementProductListItemInput, + TContext + >({ + mutationFn: service.incrementProductListItem, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + function useDeleteProductListItem( + options?: ProductListMutationOptions< + ProductListDeleteResponse, + DeleteProductListItemInput, + TContext + > + ) { + const queryClient = useQueryClient() + + return useMutation< + ProductListDeleteResponse, + unknown, + DeleteProductListItemInput, + TContext + >({ + mutationFn: service.deleteProductListItem, + onMutate: options?.onMutate, + onSuccess: (data, variables, context) => { + invalidateProductLists(queryClient) + options?.onSuccess?.(data, variables, context) + }, + onError: options?.onError, + onSettled: options?.onSettled, + }) + } + + return { + getListQueryOptions, + getDetailQueryOptions, + useProductLists, + useSuspenseProductLists, + useProductList, + useSuspenseProductList, + useProductListDetails, + usePrefetchProductLists, + usePrefetchProductList, + useCreateFavoriteProductList, + useCreateCustomProductList, + useUpdateProductList, + useDeleteProductList, + useAddProductListItem, + useAddFavoriteProductListItem, + useCreateProductListCart, + useUpdateProductListItem, + useChangeProductListItemQuantity, + useIncrementProductListItem, + useDeleteProductListItem, + } +} diff --git a/libs/storefront-data/src/product-lists/input-utils.ts b/libs/storefront-data/src/product-lists/input-utils.ts new file mode 100644 index 000000000..b55b4d7b6 --- /dev/null +++ b/libs/storefront-data/src/product-lists/input-utils.ts @@ -0,0 +1,67 @@ +import { compactRecord } from "../shared/object-utils" +import { resolvePagination } from "../shared/pagination" +import type { + ProductListDetailInputBase, + ProductListListInputBase, +} from "./types" + +export const stripListInput = ( + input: TInput +) => { + const { + enabled: _enabled, + customerId: _customerId, + page: _page, + ...params + } = input + + return params +} + +export const stripDetailInput = ( + input: TInput +) => { + const { enabled: _enabled, customerId: _customerId, ...params } = input + + return params +} + +export const createDefaultListParams = < + TInput extends ProductListListInputBase, +>( + input: TInput, + defaultPageSize: number +) => { + const params = stripListInput(input) as Record + + if (typeof input.page !== "number") { + return compactRecord(params) + } + + const pagination = resolvePagination( + { + page: input.page, + limit: input.limit, + offset: input.offset, + }, + defaultPageSize + ) + + return compactRecord({ + ...params, + limit: pagination.limit, + offset: pagination.offset, + }) +} + +export const withCustomerScope = < + TParams, + TInput extends { customerId?: string | null }, +>( + params: TParams, + input: TInput +) => + ({ + ...(params as object), + customerId: input.customerId ?? null, + }) diff --git a/libs/storefront-data/src/product-lists/medusa-service.ts b/libs/storefront-data/src/product-lists/medusa-service.ts new file mode 100644 index 000000000..c63572a08 --- /dev/null +++ b/libs/storefront-data/src/product-lists/medusa-service.ts @@ -0,0 +1,405 @@ +import type Medusa from "@medusajs/js-sdk" +import type { HttpTypes } from "@medusajs/types" +import type { + AddFavoriteProductListItemInput, + AddProductListItemInput, + ChangeProductListItemQuantityInput, + CreateCustomProductListInput, + CreateFavoriteProductListInput, + CreateProductListCartInput, + DeleteProductListInput, + DeleteProductListItemInput, + IncrementProductListItemInput, + ProductListBase, + ProductListCartLike, + ProductListCartResponse, + ProductListDeleteResponse, + ProductListItemBase, + ProductListItemResponse, + ProductListListResponse, + ProductListListResult, + ProductListResponse, + ProductListService, + UpdateProductListInput, + UpdateProductListItemInput, +} from "./types" +import { compactRecord } from "../shared/object-utils" + +const DEFAULT_PRODUCT_LISTS_PATH = "/store/product-lists" + +type PlainQuery = Record + +const normalizeQuantity = (quantity?: number | null) => { + if (typeof quantity !== "number" || !Number.isFinite(quantity)) { + return undefined + } + + return Math.max(1, Math.floor(quantity)) +} + +const normalizeQuantityDelta = (quantity?: number | null) => { + if (typeof quantity !== "number" || !Number.isFinite(quantity)) { + return 1 + } + + const quantityDelta = Math.trunc(quantity) + + if (quantityDelta === 0) { + throw new Error("Quantity change must be a non-zero integer.") + } + + return quantityDelta +} + +export type MedusaProductListListInput = { + handle?: string + type?: string + limit?: number + offset?: number +} + +export type MedusaProductListDetailInput = { + id?: string | null +} + +export type MedusaProductListListHookInput = MedusaProductListListInput & { + page?: number + customerId?: string | null + enabled?: boolean +} + +export type MedusaProductListDetailHookInput = MedusaProductListDetailInput & { + customerId?: string | null + enabled?: boolean +} + +export type MedusaProductListListKeyInput = MedusaProductListListInput & { + customerId?: string | null +} + +export type MedusaProductListDetailKeyInput = MedusaProductListDetailInput & { + customerId?: string | null +} + +export type MedusaProductListServiceConfig< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListInput extends MedusaProductListListInput = MedusaProductListListInput, +> = { + basePath?: string + defaultLimit?: number + defaultOffset?: number + normalizeListQuery?: (input: TListInput) => PlainQuery + transformProductList?: (list: ProductListBase) => TProductList + transformProductListItem?: (item: ProductListItemBase) => TProductListItem + transformCart?: (cart: HttpTypes.StoreCart) => TCart +} + +export const normalizeProductListsResponse = ( + response: ProductListListResponse, + fallbackLimit: number, + fallbackOffset: number +): ProductListListResult => { + const productLists = + response.product_lists ?? response.productLists ?? response.lists ?? [] + + return { + productLists, + count: response.count ?? productLists.length, + limit: response.limit ?? fallbackLimit, + offset: response.offset ?? fallbackOffset, + } +} + +export const resolveProductListFromResponse = ( + response: ProductListResponse +): TProductList | null => + response.product_list ?? response.productList ?? response.list ?? null + +export const resolveProductListItemFromResponse = < + TProductList, + TProductListItem, +>( + response: ProductListItemResponse +): TProductListItem | null => + response.product_list_item ?? response.productListItem ?? response.item ?? null + +export const resolveProductListCartFromResponse = < + TCart extends ProductListCartLike, +>( + response: ProductListCartResponse +): TCart | null => response.cart ?? null + +export function createMedusaProductListService< + TProductList = ProductListBase, + TProductListItem = ProductListItemBase, + TCart extends ProductListCartLike = HttpTypes.StoreCart, + TListInput extends MedusaProductListListInput = MedusaProductListListInput, +>( + sdk: Medusa, + config?: MedusaProductListServiceConfig< + TProductList, + TProductListItem, + TCart, + TListInput + > +): ProductListService< + TProductList, + TProductListItem, + TCart, + TListInput, + MedusaProductListDetailInput +> { + const basePath = config?.basePath ?? DEFAULT_PRODUCT_LISTS_PATH + const defaultLimit = config?.defaultLimit ?? 20 + const defaultOffset = config?.defaultOffset ?? 0 + const transformProductList = + config?.transformProductList ?? + ((list: ProductListBase) => list as TProductList) + const transformProductListItem = + config?.transformProductListItem ?? + ((item: ProductListItemBase) => item as TProductListItem) + const transformCart = + config?.transformCart ?? + ((cart: HttpTypes.StoreCart) => cart as unknown as TCart) + + const mapList = (list: ProductListBase) => + transformProductList(list) + const mapItem = (item: ProductListItemBase) => transformProductListItem(item) + + const resolveListQuery = (params: TListInput): PlainQuery => { + const normalized = config?.normalizeListQuery?.(params) ?? params + const { limit = defaultLimit, offset = defaultOffset, ...query } = normalized + + return compactRecord({ + ...query, + limit, + offset, + }) + } + + const resolveItemFromResponse = ( + response: ProductListItemResponse + ): TProductListItem | null => { + const item = resolveProductListItemFromResponse(response) + return item ? mapItem(item) : null + } + + return { + async listProductLists( + params: TListInput, + signal?: AbortSignal + ): Promise> { + const query = resolveListQuery(params) + const response = await sdk.client.fetch< + ProductListListResponse> + >(basePath, { + query, + signal, + }) + const normalized = normalizeProductListsResponse( + response, + Number(query.limit ?? defaultLimit), + Number(query.offset ?? defaultOffset) + ) + + return { + ...normalized, + productLists: normalized.productLists.map(mapList), + } + }, + + async getProductList( + params: MedusaProductListDetailInput, + signal?: AbortSignal + ): Promise { + if (!params.id) { + return null + } + + const response = await sdk.client.fetch< + ProductListResponse> + >(`${basePath}/${params.id}`, { signal }) + + const productList = resolveProductListFromResponse(response) + return productList ? mapList(productList) : null + }, + + async createFavoriteProductList( + input: CreateFavoriteProductListInput = {} + ): Promise { + const response = await sdk.client.fetch< + ProductListResponse> + >(`${basePath}/favorites`, { + method: "POST", + body: compactRecord(input), + }) + const productList = resolveProductListFromResponse(response) + return productList ? mapList(productList) : null + }, + + async createCustomProductList( + input: CreateCustomProductListInput + ): Promise { + const response = await sdk.client.fetch< + ProductListResponse> + >(`${basePath}/custom`, { + method: "POST", + body: compactRecord({ + ...input, + access_type: input.access_type ?? "private", + }), + }) + const productList = resolveProductListFromResponse(response) + return productList ? mapList(productList) : null + }, + + async updateProductList( + input: UpdateProductListInput + ): Promise { + const response = await sdk.client.fetch< + ProductListResponse> + >(`${basePath}/${input.listId}`, { + method: "POST", + body: compactRecord({ + title: input.title, + access_type: input.access_type, + description: input.description, + handle: input.handle, + metadata: input.metadata, + }), + }) + const productList = resolveProductListFromResponse(response) + return productList ? mapList(productList) : null + }, + + deleteProductList(input: DeleteProductListInput) { + return sdk.client.fetch( + `${basePath}/${input.listId}`, + { method: "DELETE" } + ) + }, + + async addProductListItem( + input: AddProductListItemInput + ): Promise { + const response = await sdk.client.fetch< + ProductListItemResponse + >(`${basePath}/${input.listId}/items`, { + method: "POST", + body: compactRecord({ + product_id: input.productId, + variant_id: input.variantId ?? undefined, + quantity: normalizeQuantity(input.quantity), + note: input.note, + sort_order: input.sortOrder, + metadata: input.metadata, + }), + }) + + return resolveItemFromResponse(response) + }, + + async addFavoriteProductListItem( + input: AddFavoriteProductListItemInput + ): Promise { + const response = await sdk.client.fetch< + ProductListItemResponse + >(`${basePath}/favorites/items`, { + method: "POST", + body: compactRecord({ + product_id: input.productId, + variant_id: input.variantId ?? undefined, + quantity: normalizeQuantity(input.quantity), + note: input.note, + sort_order: input.sortOrder, + metadata: input.metadata, + }), + }) + + return resolveItemFromResponse(response) + }, + + async createProductListCart( + input: CreateProductListCartInput + ): Promise { + const response = await sdk.client.fetch< + ProductListCartResponse + >(`${basePath}/${input.listId}/cart`, { + method: "POST", + body: compactRecord({ + region_id: input.regionId ?? undefined, + country_code: input.countryCode ?? undefined, + email: input.email ?? undefined, + sales_channel_id: input.salesChannelId ?? undefined, + }), + }) + const cart = resolveProductListCartFromResponse(response) + + if (!cart) { + throw new Error("Product list cart response did not include a cart.") + } + + return transformCart(cart as HttpTypes.StoreCart) + }, + + async updateProductListItem( + input: UpdateProductListItemInput + ): Promise { + const response = await sdk.client.fetch< + ProductListItemResponse + >(`${basePath}/items/${input.itemId}`, { + method: "POST", + body: compactRecord({ + quantity: normalizeQuantity(input.quantity), + note: input.note, + sort_order: input.sortOrder, + metadata: input.metadata, + }), + }) + + return resolveItemFromResponse(response) + }, + + async changeProductListItemQuantity( + input: ChangeProductListItemQuantityInput + ): Promise { + const response = await sdk.client.fetch< + ProductListItemResponse + >(`${basePath}/items/${input.itemId}/change-quantity`, { + method: "POST", + body: compactRecord({ + quantity: normalizeQuantityDelta(input.quantity), + }), + }) + + return resolveItemFromResponse(response) + }, + + async incrementProductListItem( + input: IncrementProductListItemInput + ): Promise { + const response = await sdk.client.fetch< + ProductListItemResponse + >(`${basePath}/items/${input.itemId}/increment`, { + method: "POST", + body: compactRecord({ + quantity: normalizeQuantity(input.quantity) ?? 1, + }), + }) + + return resolveItemFromResponse(response) + }, + + deleteProductListItem(input: DeleteProductListItemInput) { + const path = input.listId + ? `${basePath}/${input.listId}/items/${input.itemId}` + : `${basePath}/items/${input.itemId}` + + return sdk.client.fetch(path, { + method: "DELETE", + }) + }, + } +} diff --git a/libs/storefront-data/src/product-lists/query-keys.ts b/libs/storefront-data/src/product-lists/query-keys.ts new file mode 100644 index 000000000..5fb52bec1 --- /dev/null +++ b/libs/storefront-data/src/product-lists/query-keys.ts @@ -0,0 +1,28 @@ +import { createQueryKey, normalizeQueryKeyPart } from "../shared/query-keys" +import type { QueryNamespace } from "../shared/query-keys" +import type { ProductListQueryKeys } from "./types" + +export function createProductListQueryKeys< + TListKeyParams, + TDetailKeyParams, +>( + namespace: QueryNamespace +): ProductListQueryKeys { + return { + all: () => createQueryKey(namespace, "product-lists"), + list: (params) => + createQueryKey( + namespace, + "product-lists", + "list", + normalizeQueryKeyPart(params, { omitKeys: ["enabled"] }) + ), + detail: (params) => + createQueryKey( + namespace, + "product-lists", + "detail", + normalizeQueryKeyPart(params, { omitKeys: ["enabled"] }) + ), + } +} diff --git a/libs/storefront-data/src/product-lists/query-options.ts b/libs/storefront-data/src/product-lists/query-options.ts new file mode 100644 index 000000000..389861ee9 --- /dev/null +++ b/libs/storefront-data/src/product-lists/query-options.ts @@ -0,0 +1,166 @@ +import { + type CacheConfig, + type CacheStrategy, + createCacheConfig, +} from "../shared/cache-config" +import type { + QueryFactoryOptions, + ReadQueryOptions, +} from "../shared/hook-types" +import type { QueryNamespace } from "../shared/query-keys" +import { + createDefaultListParams, + stripDetailInput, + withCustomerScope, +} from "./input-utils" +import { createProductListQueryKeys } from "./query-keys" +import type { + ProductListCartLike, + ProductListDetailInputBase, + ProductListListInputBase, + ProductListListResult, + ProductListQueryKeys, + ProductListService, +} from "./types" + +export type CreateProductListQueryOptionsFactoryConfig< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListInput extends ProductListListInputBase, + TListParams, + TDetailInput extends ProductListDetailInputBase, + TDetailParams, + TListKeyParams = TListParams & { customerId?: string | null }, + TDetailKeyParams = TDetailParams & { customerId?: string | null }, +> = { + service: ProductListService< + TProductList, + TProductListItem, + TCart, + TListParams, + TDetailParams + > + buildListParams?: (input: TListInput) => TListParams + buildDetailParams?: (input: TDetailInput) => TDetailParams + buildListKeyParams?: ( + input: TListInput, + params: TListParams + ) => TListKeyParams + buildDetailKeyParams?: ( + input: TDetailInput, + params: TDetailParams + ) => TDetailKeyParams + queryKeys?: ProductListQueryKeys + queryKeyNamespace?: QueryNamespace + cacheConfig?: CacheConfig + defaultPageSize?: number +} + +export type ProductListQueryOptionsFactory< + TProductList, + TListInput extends ProductListListInputBase, + TDetailInput extends ProductListDetailInputBase, +> = { + getListQueryOptions: ( + input: TListInput, + options?: { + queryOptions?: ReadQueryOptions> + cacheStrategy?: CacheStrategy + } + ) => QueryFactoryOptions> + getDetailQueryOptions: ( + input: TDetailInput, + options?: { + queryOptions?: ReadQueryOptions + cacheStrategy?: CacheStrategy + } + ) => QueryFactoryOptions +} + +export function createProductListQueryOptionsFactory< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListInput extends ProductListListInputBase, + TListParams = Omit, + TDetailInput extends ProductListDetailInputBase = ProductListDetailInputBase, + TDetailParams = Omit, + TListKeyParams = TListParams & { customerId?: string | null }, + TDetailKeyParams = TDetailParams & { customerId?: string | null }, +>({ + service, + buildListParams, + buildDetailParams, + buildListKeyParams, + buildDetailKeyParams, + queryKeys, + queryKeyNamespace = "storefront-data", + cacheConfig, + defaultPageSize = 20, +}: CreateProductListQueryOptionsFactoryConfig< + TProductList, + TProductListItem, + TCart, + TListInput, + TListParams, + TDetailInput, + TDetailParams, + TListKeyParams, + TDetailKeyParams +>): ProductListQueryOptionsFactory { + const resolvedCacheConfig = cacheConfig ?? createCacheConfig() + const resolvedQueryKeys = + queryKeys ?? + createProductListQueryKeys( + queryKeyNamespace + ) + const buildList = + buildListParams ?? + ((input: TListInput) => + createDefaultListParams(input, defaultPageSize) as TListParams) + const buildDetail = + buildDetailParams ?? + ((input: TDetailInput) => stripDetailInput(input) as TDetailParams) + const buildListKey = + buildListKeyParams ?? + ((input: TListInput, params: TListParams) => + withCustomerScope(params, input) as TListKeyParams) + const buildDetailKey = + buildDetailKeyParams ?? + ((input: TDetailInput, params: TDetailParams) => + withCustomerScope(params, input) as TDetailKeyParams) + + return { + getListQueryOptions: (input, options) => { + const listParams = buildList(input) + const cacheStrategy = options?.cacheStrategy ?? "userData" + + return { + queryKey: resolvedQueryKeys.list(buildListKey(input, listParams)), + queryFn: ({ signal }) => service.listProductLists(listParams, signal), + ...resolvedCacheConfig[cacheStrategy], + ...(options?.queryOptions ?? {}), + } + }, + getDetailQueryOptions: (input, options) => { + const detailParams = buildDetail(input) + const cacheStrategy = options?.cacheStrategy ?? "userData" + + return { + queryKey: resolvedQueryKeys.detail( + buildDetailKey(input, detailParams) + ), + queryFn: ({ signal }) => { + if (!input.id) { + throw new Error("Product list id is required") + } + + return service.getProductList(detailParams, signal) + }, + ...resolvedCacheConfig[cacheStrategy], + ...(options?.queryOptions ?? {}), + } + }, + } +} diff --git a/libs/storefront-data/src/product-lists/types.ts b/libs/storefront-data/src/product-lists/types.ts new file mode 100644 index 000000000..7f6fdb8f7 --- /dev/null +++ b/libs/storefront-data/src/product-lists/types.ts @@ -0,0 +1,263 @@ +import type { HttpTypes } from "@medusajs/types" +import type { + QueryResult, + ReadResultBase, + SuspenseQueryResult, + SuspenseResultBase, +} from "../shared/hook-result-types" +import type { MutationOptions } from "../shared/hook-types" +import type { QueryKey } from "../shared/query-keys" + +export type ProductListType = "favorite" | "custom" +export type ProductListAccessType = "private" | "public" + +export type ProductListItemBase = { + id: string + product_id?: string | null + variant_id?: string | null + quantity?: number | null + note?: string | null + sort_order?: number | null + metadata?: Record | null + product?: HttpTypes.StoreProduct | null + variant?: { + id?: string | null + title?: string | null + } | null +} + +export type ProductListBase = { + id: string + title?: string | null + description?: string | null + handle?: string | null + type?: ProductListType | string | null + access_type?: ProductListAccessType | string | null + customer_id?: string | null + items?: TItem[] | null + items_count?: number | null + item_count?: number | null + metadata?: Record | null + created_at?: string | null + updated_at?: string | null +} + +export type ProductListListResponse = { + product_lists?: TProductList[] + productLists?: TProductList[] + lists?: TProductList[] + count?: number + limit?: number + offset?: number +} + +export type ProductListResponse = { + product_list?: TProductList + productList?: TProductList + list?: TProductList +} + +export type ProductListItemResponse = + ProductListResponse & { + item?: TProductListItem + product_list_item?: TProductListItem + productListItem?: TProductListItem + } + +export type ProductListCartResponse = { + cart?: TCart | null +} + +export type ProductListDeleteResponse = { + deleted: boolean + id: string +} + +export type ProductListListResult = { + productLists: TProductList[] + count: number + limit: number + offset: number +} + +export type ProductListListInputBase = { + handle?: string + type?: ProductListType | string + limit?: number + offset?: number + page?: number + customerId?: string | null + enabled?: boolean +} + +export type ProductListDetailInputBase = { + id?: string | null + customerId?: string | null + enabled?: boolean +} + +export type CreateFavoriteProductListInput = { + title?: string + description?: string + handle?: string + metadata?: Record +} + +export type CreateCustomProductListInput = { + title: string + access_type?: ProductListAccessType + description?: string + handle?: string + metadata?: Record +} + +export type UpdateProductListInput = { + listId: string + title?: string + access_type?: ProductListAccessType + description?: string + handle?: string + metadata?: Record +} + +export type DeleteProductListInput = { + listId: string +} + +export type AddProductListItemInput = { + listId: string + productId: string + variantId?: string | null + quantity?: number | null + note?: string + sortOrder?: number + metadata?: Record +} + +export type AddFavoriteProductListItemInput = Omit< + AddProductListItemInput, + "listId" +> + +export type CreateProductListCartInput = { + listId: string + regionId?: string | null + countryCode?: string | null + email?: string | null + salesChannelId?: string | null +} + +export type ChangeProductListItemQuantityInput = { + itemId: string + quantity?: number +} + +export type IncrementProductListItemInput = ChangeProductListItemQuantityInput + +export type UpdateProductListItemInput = { + itemId: string + quantity?: number | null + note?: string | null + sortOrder?: number | null + metadata?: Record | null +} + +export type DeleteProductListItemInput = { + itemId: string + listId?: string +} + +export type ProductListCartLike = { + id: string + region_id?: string | null +} + +export type ProductListService< + TProductList, + TProductListItem, + TCart extends ProductListCartLike, + TListParams, + TDetailParams, +> = { + listProductLists: ( + params: TListParams, + signal?: AbortSignal + ) => Promise> + getProductList: ( + params: TDetailParams, + signal?: AbortSignal + ) => Promise + createFavoriteProductList: ( + input: CreateFavoriteProductListInput + ) => Promise + createCustomProductList: ( + input: CreateCustomProductListInput + ) => Promise + updateProductList: ( + input: UpdateProductListInput + ) => Promise + deleteProductList: ( + input: DeleteProductListInput + ) => Promise + addProductListItem: ( + input: AddProductListItemInput + ) => Promise + addFavoriteProductListItem: ( + input: AddFavoriteProductListItemInput + ) => Promise + createProductListCart: (input: CreateProductListCartInput) => Promise + updateProductListItem: ( + input: UpdateProductListItemInput + ) => Promise + changeProductListItemQuantity: ( + input: ChangeProductListItemQuantityInput + ) => Promise + incrementProductListItem: ( + input: IncrementProductListItemInput + ) => Promise + deleteProductListItem: ( + input: DeleteProductListItemInput + ) => Promise +} + +export type ProductListQueryKeys = { + all: () => QueryKey + list: (params: TListKeyParams) => QueryKey + detail: (params: TDetailKeyParams) => QueryKey +} + +export type ProductListMutationOptions< + TData, + TVariables, + TContext = unknown, +> = MutationOptions + +export type UseProductListsResult = ReadResultBase< + QueryResult> +> & { + productLists: TProductList[] + count: number + limit: number + offset: number +} + +export type UseSuspenseProductListsResult = SuspenseResultBase< + SuspenseQueryResult> +> & { + productLists: TProductList[] + count: number + limit: number + offset: number +} + +export type UseProductListResult = ReadResultBase< + QueryResult +> & { + productList: TProductList | null +} + +export type UseSuspenseProductListResult = SuspenseResultBase< + SuspenseQueryResult +> & { + productList: TProductList | null +} diff --git a/libs/storefront-data/src/product-lists/utils.ts b/libs/storefront-data/src/product-lists/utils.ts new file mode 100644 index 000000000..5e4238e2e --- /dev/null +++ b/libs/storefront-data/src/product-lists/utils.ts @@ -0,0 +1,102 @@ +import type { ProductListBase, ProductListItemBase } from "./types" + +export const getProductListItems = < + TItem, + TProductList extends ProductListBase, +>( + list?: TProductList | null +): TItem[] => list?.items ?? [] + +export const getProductListItemCount = < + TItem, + TProductList extends ProductListBase, +>( + list?: TProductList | null +): number => { + if (!list) { + return 0 + } + + // `items_count` is the canonical product-list counter; `item_count` is kept + // as a fallback for API responses that still use the older alias. + if (typeof list.items_count === "number") { + return list.items_count + } + + if (typeof list.item_count === "number") { + return list.item_count + } + + return getProductListItems(list).length +} + +export const isFavoriteProductList = ( + list?: ProductListBase | null +): boolean => list?.type === "favorite" || list?.handle === "favorites" + +export const getProductListItemProductId = ( + item: ProductListItemBase +): string | null => item.product_id ?? item.product?.id ?? null + +export const getProductListItemVariantId = ( + item: ProductListItemBase +): string | null => item.variant_id ?? item.variant?.id ?? null + +export const resolveProductListItemQuantity = ( + item: ProductListItemBase +): number => + typeof item.quantity === "number" && item.quantity > 0 + ? Math.floor(item.quantity) + : 1 + +const normalizeVariantId = (variantId?: string | null) => { + if (typeof variantId !== "string") { + return null + } + + const trimmedVariantId = variantId.trim() + return trimmedVariantId ? trimmedVariantId : null +} + +export const productListItemMatchesSelection = ( + item: ProductListItemBase, + productId: string, + variantId?: string | null +): boolean => { + if (getProductListItemProductId(item) !== productId) { + return false + } + + const requestedVariantId = normalizeVariantId(variantId) + const itemVariantId = normalizeVariantId(getProductListItemVariantId(item)) + + if (requestedVariantId) { + return itemVariantId === requestedVariantId + } + + return !itemVariantId +} + +export const isProductInProductList = < + TItem extends ProductListItemBase, + TProductList extends ProductListBase, +>( + list: TProductList | null | undefined, + productId: string, + variantId?: string | null +): boolean => + (list?.items ?? []).some((item) => + productListItemMatchesSelection(item, productId, variantId) + ) + +export const findProductListItem = < + TItem extends ProductListItemBase, + TProductList extends ProductListBase, +>( + list: TProductList | null | undefined, + productId: string, + variantId?: string | null +): TItem | undefined => + (list?.items ?? []).find((item) => + productListItemMatchesSelection(item, productId, variantId) + ) diff --git a/libs/storefront-data/src/shared/object-utils.ts b/libs/storefront-data/src/shared/object-utils.ts index 4ed5e47cf..6654bfe18 100644 --- a/libs/storefront-data/src/shared/object-utils.ts +++ b/libs/storefront-data/src/shared/object-utils.ts @@ -13,6 +13,11 @@ export const toPlainRecord = ( return value } +export const compactRecord = (record: Record) => + Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined) + ) + export function omitKeys< TObject extends object, const TKeys extends readonly (keyof TObject)[], diff --git a/libs/storefront-data/tests/medusa.preset.test.tsx b/libs/storefront-data/tests/medusa.preset.test.tsx index 19b66a99e..411c2d9b0 100644 --- a/libs/storefront-data/tests/medusa.preset.test.tsx +++ b/libs/storefront-data/tests/medusa.preset.test.tsx @@ -29,6 +29,11 @@ import type { MedusaOrderListInput, } from "../src/orders/medusa-service" import type { OrderQueryKeys } from "../src/orders/types" +import type { + MedusaProductListDetailKeyInput, + MedusaProductListListKeyInput, +} from "../src/product-lists/medusa-service" +import type { ProductListQueryKeys } from "../src/product-lists/types" import type { CartQueryKeys } from "../src/cart/types" import { createQueryKey } from "../src/shared/query-keys" @@ -68,6 +73,15 @@ const createSdkMock = () => { } } + if (path === "/store/product-lists/list_1/cart") { + return { + cart: { + id: "cart_from_list", + region_id: "reg_1", + }, + } + } + return {} } ) @@ -214,6 +228,63 @@ describe("createMedusaStorefrontPreset", () => { limit: 12, }) ).toEqual(["tenant", "n1", "products", "list", { limit: 12 }]) + + expect( + preset.queryKeys.productLists.detail({ + id: "list_1", + customerId: "cus_1", + }) + ).toEqual([ + "tenant", + "n1", + "product-lists", + "detail", + { + customerId: "cus_1", + id: "list_1", + }, + ]) + }) + + it("exposes product-list hook input controls through preset types", () => { + const { sdk } = createSdkMock() + const preset = createMedusaStorefrontPreset({ + sdk, + }) + type ProductListsInput = NonNullable< + Parameters[0] + > + type ProductListInput = Parameters< + typeof preset.hooks.productLists.useProductList + >[0] + type SuspenseProductListInput = Parameters< + typeof preset.hooks.productLists.useSuspenseProductList + >[0] + + const listInput = { + page: 2, + limit: 12, + customerId: "cus_1", + enabled: false, + } satisfies ProductListsInput + const detailInput = { + id: "list_1", + customerId: "cus_1", + enabled: false, + } satisfies ProductListInput + const suspenseDetailInput = { + id: "list_1", + customerId: "cus_1", + } satisfies SuspenseProductListInput + // @ts-expect-error suspense product-list detail input requires id + const missingSuspenseInput: SuspenseProductListInput = { + customerId: "cus_1", + } + + expect(listInput.page).toBe(2) + expect(detailInput.enabled).toBe(false) + expect(suspenseDetailInput.id).toBe("list_1") + expect(missingSuspenseInput.customerId).toBe("cus_1") }) it("passes domain hook overrides to the composed hooks", async () => { @@ -313,7 +384,7 @@ describe("createMedusaStorefrontPreset", () => { ) }) - it("uses custom customer/order query keys for auth cross-domain invalidation", async () => { + it("uses custom user-data query keys for auth cross-domain invalidation", async () => { const { sdk } = createSdkMock() const customAuthService: AuthService< HttpTypes.StoreCustomer, @@ -347,6 +418,17 @@ describe("createMedusaStorefrontPreset", () => { detail: (params) => createQueryKey(customOrderNamespace, "detail", params ?? {}), } + const customProductListNamespace = ["custom", "product-lists"] as const + const customProductListQueryKeys: ProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + > = { + all: () => createQueryKey(customProductListNamespace), + list: (params) => + createQueryKey(customProductListNamespace, "list", params ?? {}), + detail: (params) => + createQueryKey(customProductListNamespace, "detail", params ?? {}), + } const preset = createMedusaStorefrontPreset({ sdk, @@ -359,6 +441,9 @@ describe("createMedusaStorefrontPreset", () => { orders: { queryKeys: customOrderQueryKeys, }, + productLists: { + queryKeys: customProductListQueryKeys, + }, }) const queryClient = new QueryClient({ @@ -372,6 +457,12 @@ describe("createMedusaStorefrontPreset", () => { { id: "addr_old" }, ]) queryClient.setQueryData(customOrderQueryKeys.list({}), [{ id: "order_old" }]) + queryClient.setQueryData( + customProductListQueryKeys.list({ + customerId: "cus_old", + }), + [{ id: "list_old" }] + ) const wrapper = createWrapper(queryClient) const { result } = renderHook(() => preset.hooks.auth.useLogin(), { @@ -394,6 +485,86 @@ describe("createMedusaStorefrontPreset", () => { expect(queryClient.getQueryState(customOrderQueryKeys.list({}))?.isInvalidated).toBe( true ) + expect( + queryClient.getQueryState( + customProductListQueryKeys.list({ + customerId: "cus_old", + }) + )?.isInvalidated + ).toBe(true) + }) + + it("syncs carts created from product lists through preset cart cache", async () => { + const { sdk, spies } = createSdkMock() + let storedCartId: string | null = null + const preset = createMedusaStorefrontPreset({ + sdk, + cart: { + hooks: { + cartStorage: { + get: () => storedCartId, + set: (value) => { + storedCartId = value + }, + clear: () => { + storedCartId = null + }, + }, + }, + }, + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + const wrapper = createWrapper(queryClient) + + const { result } = renderHook( + () => preset.hooks.productLists.useCreateProductListCart(), + { wrapper } + ) + + await act(async () => { + await result.current.mutateAsync({ + listId: "list_1", + regionId: "reg_1", + }) + }) + + expect(spies.clientFetch).toHaveBeenCalledWith( + "/store/product-lists/list_1/cart", + { + method: "POST", + body: { + region_id: "reg_1", + }, + } + ) + expect(storedCartId).toBe("cart_from_list") + expect( + queryClient.getQueryData(preset.queryKeys.cart.detail("cart_from_list")) + ).toEqual( + expect.objectContaining({ + id: "cart_from_list", + region_id: "reg_1", + }) + ) + expect( + queryClient.getQueryData( + preset.queryKeys.cart.active({ + cartId: "cart_from_list", + regionId: "reg_1", + }) + ) + ).toEqual( + expect.objectContaining({ + id: "cart_from_list", + region_id: "reg_1", + }) + ) }) it("supports overriding auth/order/customer services through preset config", async () => { diff --git a/libs/storefront-data/tests/medusa.server-read.test.ts b/libs/storefront-data/tests/medusa.server-read.test.ts index b1937982e..29360266a 100644 --- a/libs/storefront-data/tests/medusa.server-read.test.ts +++ b/libs/storefront-data/tests/medusa.server-read.test.ts @@ -3,6 +3,11 @@ import type { HttpTypes } from "@medusajs/types" import { QueryClient } from "@tanstack/react-query" import { createMedusaStorefrontServerReadPreset } from "../src/medusa/server-read" import { createOrderQueryKeys } from "../src/orders/query-keys" +import type { + MedusaProductListDetailKeyInput, + MedusaProductListListKeyInput, +} from "../src/product-lists/medusa-service" +import { createProductListQueryKeys } from "../src/product-lists/query-keys" import { createProductQueryKeys } from "../src/products/query-keys" import { createRegionQueryKeys } from "../src/regions/query-keys" @@ -27,6 +32,21 @@ const createSdkMock = () => { } } + if (path === "/store/product-lists") { + return { + product_lists: [{ id: "list_1", title: "Favorite" }], + count: 1, + limit: 5, + offset: 5, + } + } + + if (path === "/store/product-lists/list_1") { + return { + product_list: { id: "list_1", title: "Favorite" }, + } + } + return {} } ) @@ -64,6 +84,10 @@ describe("createMedusaStorefrontServerReadPreset", () => { "tenant", "demo", ]) + const productListQueryKeys = createProductListQueryKeys< + MedusaProductListListKeyInput, + MedusaProductListDetailKeyInput + >(["tenant", "demo"]) const preset = createMedusaStorefrontServerReadPreset({ sdk, queryKeyNamespace: ["tenant", "demo"], @@ -77,12 +101,39 @@ describe("createMedusaStorefrontServerReadPreset", () => { limit: 2, }) const regionQuery = preset.queries.regions.getListQueryOptions({}) + const productListQuery = preset.queries.productLists.getListQueryOptions({ + page: 2, + limit: 5, + customerId: "cus_1", + enabled: true, + }) + const productListDetailQuery = + preset.queries.productLists.getDetailQueryOptions({ + id: "list_1", + customerId: "cus_1", + enabled: true, + }) expect(productQuery.queryKey).toEqual(productQueryKeys.list({ limit: 2 })) expect(regionQuery.queryKey).toEqual(regionQueryKeys.list({})) + expect(productListQuery.queryKey).toEqual( + productListQueryKeys.list({ + customerId: "cus_1", + limit: 5, + offset: 5, + }) + ) + expect(productListDetailQuery.queryKey).toEqual( + productListQueryKeys.detail({ + customerId: "cus_1", + id: "list_1", + }) + ) await queryClient.prefetchQuery(productQuery) await queryClient.prefetchQuery(regionQuery) + await queryClient.prefetchQuery(productListQuery) + await queryClient.prefetchQuery(productListDetailQuery) expect(spies.clientFetch).toHaveBeenCalledWith( "/store/products", @@ -98,6 +149,21 @@ describe("createMedusaStorefrontServerReadPreset", () => { query: {}, }) ) + expect(spies.clientFetch).toHaveBeenCalledWith( + "/store/product-lists", + expect.objectContaining({ + query: expect.objectContaining({ + limit: 5, + offset: 5, + }), + }) + ) + expect(spies.clientFetch).toHaveBeenCalledWith( + "/store/product-lists/list_1", + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ) }) it("supports custom order services and list param builders without touching hooks", async () => { diff --git a/libs/storefront-data/tests/product-lists.hooks.test.tsx b/libs/storefront-data/tests/product-lists.hooks.test.tsx new file mode 100644 index 000000000..e2baae827 --- /dev/null +++ b/libs/storefront-data/tests/product-lists.hooks.test.tsx @@ -0,0 +1,209 @@ +import { QueryClient } from "@tanstack/react-query" +import { act, renderHook } from "@testing-library/react" +import type { ReactNode } from "react" +import { StorefrontDataProvider } from "../src/client/provider" +import { createProductListHooks } from "../src/product-lists/hooks" +import { createProductListQueryKeys } from "../src/product-lists/query-keys" +import type { + ProductListCartLike, + ProductListDetailInputBase, + ProductListListInputBase, + ProductListService, +} from "../src/product-lists/types" +import { createCacheConfig } from "../src/shared/cache-config" + +type ProductList = { id: string } +type ProductListItem = { id: string } +type Cart = ProductListCartLike +type ListParams = { limit: number; offset: number } +type DetailParams = { id?: string | null } +type ListKeyParams = ListParams & { customerId?: string | null } +type DetailKeyParams = DetailParams & { customerId?: string | null } +type Service = ProductListService< + ProductList, + ProductListItem, + Cart, + ListParams, + DetailParams +> + +const createWrapper = (client: QueryClient) => + ({ children }: { children: ReactNode }) => ( + {children} + ) + +const buildListParams = (input: ProductListListInputBase): ListParams => { + const limit = input.limit ?? 20 + const page = input.page ?? 1 + return { limit, offset: (page - 1) * limit } +} + +const buildDetailParams = ( + input: ProductListDetailInputBase +): DetailParams => ({ id: input.id }) + +const createService = (overrides: Partial = {}): Service => ({ + listProductLists: async (params) => ({ + productLists: [], + count: 0, + limit: params.limit, + offset: params.offset, + }), + getProductList: async () => null, + createFavoriteProductList: async () => null, + createCustomProductList: async () => null, + updateProductList: async () => null, + deleteProductList: async (input) => ({ deleted: true, id: input.listId }), + addProductListItem: async () => null, + addFavoriteProductListItem: async () => null, + createProductListCart: async () => ({ id: "cart_1" }), + updateProductListItem: async () => null, + changeProductListItemQuantity: async () => null, + incrementProductListItem: async () => null, + deleteProductListItem: async (input) => ({ deleted: true, id: input.itemId }), + ...overrides, +}) + +describe("product-list prefetch hooks", () => { + it("uses customer-scoped keys and prefetch skip controls for lists", async () => { + let fetchCount = 0 + const queryKeys = createProductListQueryKeys< + ListKeyParams, + DetailKeyParams + >("test-product-list-prefetch") + const service = createService({ + listProductLists: async (params) => { + fetchCount += 1 + return { + productLists: [{ id: `list_${params.offset}` }], + count: 1, + limit: params.limit, + offset: params.offset, + } + }, + }) + const { usePrefetchProductLists } = createProductListHooks< + ProductList, + ProductListItem, + Cart, + ProductListListInputBase, + ListParams, + ProductListDetailInputBase, + DetailParams, + ListKeyParams, + DetailKeyParams + >({ + service, + buildListParams, + buildDetailParams, + queryKeys, + cacheConfig: createCacheConfig({ + userData: { staleTime: 0 }, + }), + }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = createWrapper(queryClient) + const input = { page: 1, limit: 2, customerId: "cus_1" } + const queryKey = queryKeys.list({ + ...buildListParams(input), + customerId: "cus_1", + }) + + queryClient.setQueryData(queryKey, { + productLists: [], + count: 0, + limit: 2, + offset: 0, + }) + + const { result: freshResult } = renderHook( + () => usePrefetchProductLists(), + { wrapper } + ) + const { result: anyResult } = renderHook( + () => usePrefetchProductLists({ skipMode: "any" }), + { wrapper } + ) + const { result: noSkipResult } = renderHook( + () => usePrefetchProductLists({ skipIfCached: false }), + { wrapper } + ) + + await act(async () => { + await freshResult.current.prefetchProductLists(input) + }) + expect(fetchCount).toBe(1) + + await act(async () => { + await anyResult.current.prefetchProductLists(input) + }) + expect(fetchCount).toBe(1) + + await act(async () => { + await noSkipResult.current.prefetchProductLists(input) + }) + expect(fetchCount).toBe(2) + expect(queryClient.getQueryData(queryKey)).toEqual({ + productLists: [{ id: "list_0" }], + count: 1, + limit: 2, + offset: 0, + }) + }) + + it("prefetches detail only when product-list id is present", async () => { + let fetchCount = 0 + const queryKeys = createProductListQueryKeys< + ListKeyParams, + DetailKeyParams + >("test-product-list-detail-prefetch") + const service = createService({ + getProductList: async (params) => { + fetchCount += 1 + return params.id ? { id: params.id } : null + }, + }) + const { usePrefetchProductList } = createProductListHooks< + ProductList, + ProductListItem, + Cart, + ProductListListInputBase, + ListParams, + ProductListDetailInputBase, + DetailParams, + ListKeyParams, + DetailKeyParams + >({ + service, + buildListParams, + buildDetailParams, + queryKeys, + }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = createWrapper(queryClient) + const { result } = renderHook(() => usePrefetchProductList(), { wrapper }) + + await act(async () => { + await result.current.prefetchProductList({ customerId: "cus_1" }) + }) + expect(fetchCount).toBe(0) + + await act(async () => { + await result.current.prefetchProductList({ + id: "list_1", + customerId: "cus_1", + }) + }) + + expect(fetchCount).toBe(1) + expect( + queryClient.getQueryData( + queryKeys.detail({ id: "list_1", customerId: "cus_1" }) + ) + ).toEqual({ id: "list_1" }) + }) +}) diff --git a/libs/storefront-data/tests/product-lists.medusa-service.test.ts b/libs/storefront-data/tests/product-lists.medusa-service.test.ts new file mode 100644 index 000000000..84b8e51f5 --- /dev/null +++ b/libs/storefront-data/tests/product-lists.medusa-service.test.ts @@ -0,0 +1,176 @@ +import type Medusa from "@medusajs/js-sdk" +import { createMedusaProductListService } from "../src/product-lists/medusa-service" + +const createSdkMock = (response: unknown = {}) => + ({ + client: { + fetch: vi.fn().mockResolvedValue(response), + }, + }) as unknown as Medusa + +describe("createMedusaProductListService", () => { + it("lists product lists with normalized pagination and forwards signal", async () => { + const sdk = createSdkMock({ + product_lists: [{ id: "list_1", title: "Favorites" }], + count: 1, + limit: 12, + offset: 24, + }) + const service = createMedusaProductListService(sdk) + const controller = new AbortController() + + const result = await service.listProductLists( + { + type: "custom", + limit: 12, + offset: 24, + }, + controller.signal + ) + + expect(sdk.client.fetch).toHaveBeenCalledWith("/store/product-lists", { + query: { + type: "custom", + limit: 12, + offset: 24, + }, + signal: controller.signal, + }) + expect(result.productLists).toEqual([{ id: "list_1", title: "Favorites" }]) + expect(result.count).toBe(1) + }) + + it("adds favorite items with backend field names and normalized quantity", async () => { + const sdk = createSdkMock({ + product_list_item: { + id: "item_1", + product_id: "prod_1", + variant_id: "var_1", + quantity: 2, + }, + }) + const service = createMedusaProductListService(sdk) + + const item = await service.addFavoriteProductListItem({ + productId: "prod_1", + variantId: "var_1", + quantity: 2.9, + }) + + expect(sdk.client.fetch).toHaveBeenCalledWith( + "/store/product-lists/favorites/items", + { + method: "POST", + body: { + product_id: "prod_1", + variant_id: "var_1", + quantity: 2, + }, + } + ) + expect(item).toEqual( + expect.objectContaining({ + id: "item_1", + quantity: 2, + }) + ) + }) + + it("rejects zero relative quantity changes before calling the backend", async () => { + const sdk = createSdkMock() + const service = createMedusaProductListService(sdk) + + await expect( + service.changeProductListItemQuantity({ + itemId: "item_1", + quantity: 0, + }) + ).rejects.toThrow("Quantity change must be a non-zero integer.") + + expect(sdk.client.fetch).not.toHaveBeenCalled() + }) + + it("sends compact relative quantity payloads", async () => { + const sdk = createSdkMock({ + product_list_item: { + id: "item_1", + quantity: 2, + }, + }) + const service = createMedusaProductListService(sdk) + + await service.changeProductListItemQuantity({ + itemId: "item_1", + quantity: 2.9, + }) + + expect(sdk.client.fetch).toHaveBeenCalledWith( + "/store/product-lists/items/item_1/change-quantity", + { + method: "POST", + body: { + quantity: 2, + }, + } + ) + }) + + it("sends compact increment payloads with default quantity", async () => { + const sdk = createSdkMock({ + product_list_item: { + id: "item_1", + quantity: 1, + }, + }) + const service = createMedusaProductListService(sdk) + + await service.incrementProductListItem({ + itemId: "item_1", + }) + + expect(sdk.client.fetch).toHaveBeenCalledWith( + "/store/product-lists/items/item_1/increment", + { + method: "POST", + body: { + quantity: 1, + }, + } + ) + }) + + it("creates a cart from a product list and maps storefront cart input fields", async () => { + const sdk = createSdkMock({ + cart: { + id: "cart_1", + region_id: "reg_1", + }, + }) + const service = createMedusaProductListService(sdk) + + const cart = await service.createProductListCart({ + listId: "list_1", + regionId: "reg_1", + countryCode: "sk", + email: "customer@example.com", + salesChannelId: "sc_1", + }) + + expect(sdk.client.fetch).toHaveBeenCalledWith( + "/store/product-lists/list_1/cart", + { + method: "POST", + body: { + region_id: "reg_1", + country_code: "sk", + email: "customer@example.com", + sales_channel_id: "sc_1", + }, + } + ) + expect(cart).toEqual({ + id: "cart_1", + region_id: "reg_1", + }) + }) +}) diff --git a/libs/storefront-data/tests/product-lists.utils.test.ts b/libs/storefront-data/tests/product-lists.utils.test.ts new file mode 100644 index 000000000..0eb55e2a3 --- /dev/null +++ b/libs/storefront-data/tests/product-lists.utils.test.ts @@ -0,0 +1,70 @@ +import type { + ProductListBase, + ProductListItemBase, +} from "../src/product-lists/types" +import { + findProductListItem, + getProductListItemCount, + isFavoriteProductList, + isProductInProductList, + resolveProductListItemQuantity, +} from "../src/product-lists/utils" + +describe("product list utilities", () => { + it("detects favorite lists and resolves item counts from backend counters", () => { + expect(isFavoriteProductList({ id: "list_1", type: "favorite" })).toBe(true) + expect(isFavoriteProductList({ id: "list_2", handle: "favorites" })).toBe( + true + ) + expect( + getProductListItemCount({ + id: "list_3", + items_count: 4, + item_count: 2, + items: [{ id: "item_1" }], + }) + ).toBe(4) + expect( + getProductListItemCount({ + id: "list_4", + item_count: 3, + items: [{ id: "item_1" }], + }) + ).toBe(3) + expect( + getProductListItemCount({ + id: "list_5", + items: [{ id: "item_1" }, { id: "item_2" }], + }) + ).toBe(2) + }) + + it("matches products and variants from direct fields or embedded entities", () => { + const selectedItem: ProductListItemBase = { + id: "item_1", + product_id: "prod_1", + variant_id: "var_1", + } + const embeddedItem: ProductListItemBase = { + id: "item_2", + product: { id: "prod_2" } as ProductListItemBase["product"], + variant: { id: "var_2" }, + } + const list: ProductListBase = { + id: "list_1", + items: [selectedItem, embeddedItem], + } + + expect(isProductInProductList(list, "prod_1", "var_1")).toBe(true) + expect(isProductInProductList(list, "prod_1", "var_2")).toBe(false) + expect(findProductListItem(list, "prod_2", "var_2")).toEqual(embeddedItem) + }) + + it("normalizes display quantity to a positive integer", () => { + expect(resolveProductListItemQuantity({ id: "item_1", quantity: 2.8 })).toBe( + 2 + ) + expect(resolveProductListItemQuantity({ id: "item_2", quantity: 0 })).toBe(1) + expect(resolveProductListItemQuantity({ id: "item_3" })).toBe(1) + }) +}) diff --git a/libs/storefront-data/tests/regression.catalog-customers-exports.test.tsx b/libs/storefront-data/tests/regression.catalog-customers-exports.test.tsx index dd8df8d3e..d096a6760 100644 --- a/libs/storefront-data/tests/regression.catalog-customers-exports.test.tsx +++ b/libs/storefront-data/tests/regression.catalog-customers-exports.test.tsx @@ -288,6 +288,10 @@ describe("phase 1 regressions", () => { types: "./dist/src/server/get-query-client.d.ts", import: "./dist/server/get-query-client.js", }) + expect(packageJson.exports?.["./product-lists/query-options"]).toEqual({ + types: "./dist/src/product-lists/query-options.d.ts", + import: "./dist/product-lists/query-options.js", + }) expect(packageJson.exports?.["./get-query-client"]).toBeUndefined() expect(packageJson.exports?.["./medusa/cart-flow"]).toBeUndefined() expect(packageJson.exports?.["./*"]).toBeUndefined() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b690f6e34..d822d6bf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -868,6 +868,9 @@ importers: '@rslib/core': specifier: ^0.18.0 version: 0.18.0(typescript@5.9.3) + '@tanstack/intent': + specifier: ^0.0.42 + version: 0.0.42 '@testing-library/react': specifier: ^16.3.1 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -8796,6 +8799,10 @@ packages: '@tanstack/form-core@1.27.7': resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==} + '@tanstack/intent@0.0.42': + resolution: {integrity: sha512-jRjArJ8wcMmi49tByK6KCrJxPp8EqFuFH+ltDG6HTPizTrBCAXkJB3dTtTXurLJh67YsPLzU0wLPdOtWYDuGSw==} + hasBin: true + '@tanstack/pacer-lite@0.1.1': resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} @@ -10442,6 +10449,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caching-transform@4.0.0: resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==} engines: {node: '>=8'} @@ -18357,6 +18368,11 @@ packages: engines: {node: '>= 14'} hasBin: true + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -28868,6 +28884,13 @@ snapshots: '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.7.7 + '@tanstack/intent@0.0.42': + dependencies: + cac: 6.7.14 + jsonc-parser: 3.3.1 + semver: 7.8.0 + yaml: 2.8.3 + '@tanstack/pacer-lite@0.1.1': {} '@tanstack/query-core@5.64.2': {} @@ -31170,6 +31193,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + caching-transform@4.0.0: dependencies: hasha: 5.2.2 @@ -40483,6 +40508,8 @@ snapshots: yaml@2.7.1: {} + yaml@2.8.3: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1