= {
+ 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