@cartbase/storefront 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,149 +1,162 @@
1
- /**
2
- * Full PDP layout — ported from
3
- * `@1click/ui/src/products/product-template.tsx` (v2.3.1). Data seam:
4
- * `region`/`countryCode` (Medusa region routing) are replaced by
5
- * `client` + `pricingContext`; the cart seam (`addToCart`/`openCart`)
6
- * threads through to `ProductActions` (see product-actions.tsx for the
7
- * seam rationale). Layout — sticky info column, scrolling gallery, sticky
8
- * actions column, related strip — unchanged.
9
- *
10
- * Server component: fetch the product in the page (`retrieveProduct` by
11
- * handle with the pricing context) and pass it in; the wrapper re-fetches
12
- * live prices inside Suspense.
13
- */
14
- import { Suspense } from "react"
15
- import { notFound } from "next/navigation"
16
-
17
- import type { StorefrontClient } from "../api/http"
18
- import type { PricingContextQuery } from "../api/types"
19
- import type { StoreProduct, StoreProductVariant } from "../api/products"
20
- import { ImageGallery } from "./image-gallery"
21
- import { ProductActions, type AddToCartInput } from "./product-actions"
22
- import { ProductTabs, type ProductSection } from "./product-tabs"
23
- import type { ProductPromise } from "./product-promises"
24
- import type { ProductLabels } from "./labels"
25
- import { RelatedProducts } from "./related-products"
26
- import { ProductInfo } from "./product-info"
27
- import { ProductActionsWrapper } from "./product-actions-wrapper"
28
-
29
- type ProductTemplateProps = {
30
- client: StorefrontClient
31
- product: StoreProduct
32
- /** Pricing context (currency_code/region_id) for actions + related. */
33
- pricingContext?: PricingContextQuery
34
- addToCart: (input: AddToCartInput) => Promise<void>
35
- onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
36
- openCart?: () => void
37
- /**
38
- * The store's delivery, exchange and return promises, shown in the
39
- * accordion. No default: with none, that section does not exist, because
40
- * the library cannot promise anything on a merchant's behalf.
41
- */
42
- promises?: ProductPromise[]
43
- /**
44
- * The store's product pack, REQUIRED even when a `StorefrontLocaleProvider`
45
- * is mounted: the related-products strip is a SERVER component and a
46
- * server component cannot read a client context, so its heading stays
47
- * English unless the pack arrives as a prop. Until 2026-09-13 this
48
- * template did not accept one at all and did not pass one down, which made
49
- * that strip untranslatable without ejecting the file (Alexander found it
50
- * on evoo: a Bulgarian page ending in "Related products / You might also
51
- * want to check out these products"). Optional until 2026-09-14, which is
52
- * how a page could still forget it; now the build refuses the page.
53
- * Pass `STORE_LOCALE.products` (`en.products` for an English store).
54
- */
55
- labels: ProductLabels
56
- /** Drop the physical-facts section even for a product that has facts. */
57
- hideSpecs?: boolean
58
- /** Anything else the store wants in the accordion, appended in order. */
59
- sections?: ProductSection[]
60
- }
61
-
62
- export function ProductTemplate({
63
- client,
64
- product,
65
- pricingContext,
66
- addToCart,
67
- onAddToCart,
68
- openCart,
69
- promises,
70
- labels,
71
- hideSpecs,
72
- sections,
73
- }: ProductTemplateProps) {
74
- if (!product || !product.id) {
75
- return notFound()
76
- }
77
-
78
- const images = product.images ?? []
79
-
80
- return (
81
- <>
82
- <div
83
- className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row sm:items-start py-6 relative"
84
- data-testid="product-container"
85
- >
86
- <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-6">
87
- <ProductInfo product={product} />
88
- <ProductTabs
89
- product={product}
90
- promises={promises}
91
- hideSpecs={hideSpecs}
92
- sections={sections}
93
- />
94
- </div>
95
- <div className="block w-full relative">
96
- <ImageGallery images={images} />
97
- </div>
98
- <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-12">
99
- <Suspense
100
- fallback={
101
- <ProductActions
102
- disabled={true}
103
- product={product}
104
- addToCart={addToCart}
105
- onAddToCart={onAddToCart}
106
- openCart={openCart}
107
- />
108
- }
109
- >
110
- <ProductActionsWrapper
111
- client={client}
112
- id={product.id}
113
- pricingContext={pricingContext}
114
- addToCart={addToCart}
115
- onAddToCart={onAddToCart}
116
- openCart={openCart}
117
- />
118
- </Suspense>
119
- </div>
120
- </div>
121
- <div
122
- className="max-w-7xl mx-auto px-4 my-16 sm:my-32"
123
- data-testid="related-products-container"
124
- >
125
- <Suspense
126
- fallback={
127
- <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
128
- {Array.from({ length: 4 }).map((_, i) => (
129
- <div
130
- key={i}
131
- className="aspect-[9/16] bg-muted animate-pulse rounded-lg"
132
- />
133
- ))}
134
- </div>
135
- }
136
- >
137
- <RelatedProducts
138
- client={client}
139
- product={product}
140
- pricingContext={pricingContext}
141
- labels={labels}
142
- />
143
- </Suspense>
144
- </div>
145
- </>
146
- )
147
- }
148
-
149
- export { type ProductTemplateProps }
1
+ /**
2
+ * Full PDP layout — ported from
3
+ * `@1click/ui/src/products/product-template.tsx` (v2.3.1). Data seam:
4
+ * `region`/`countryCode` (Medusa region routing) are replaced by
5
+ * `client` + `pricingContext`; the cart seam (`addToCart`/`openCart`)
6
+ * threads through to `ProductActions` (see product-actions.tsx for the
7
+ * seam rationale). Layout — sticky info column, scrolling gallery, sticky
8
+ * actions column, related strip — unchanged.
9
+ *
10
+ * Server component: fetch the product in the page (`retrieveProduct` by
11
+ * handle with the pricing context) and pass it in; the wrapper re-fetches
12
+ * live prices inside Suspense.
13
+ */
14
+ import { Suspense, type ComponentType } from "react"
15
+ import { notFound } from "next/navigation"
16
+
17
+ import type { StorefrontClient } from "../api/http"
18
+ import type { PricingContextQuery } from "../api/types"
19
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
20
+ import { ImageGallery } from "./image-gallery"
21
+ import { ProductActions, type AddToCartInput } from "./product-actions"
22
+ import { ProductTabs, type ProductSection } from "./product-tabs"
23
+ import type { ProductPromise } from "./product-promises"
24
+ import type { ProductLabels } from "./labels"
25
+ import { RelatedProducts } from "./related-products"
26
+ import { ProductInfo } from "./product-info"
27
+ import { ProductActionsWrapper } from "./product-actions-wrapper"
28
+
29
+ type ProductTemplateProps = {
30
+ client: StorefrontClient
31
+ product: StoreProduct
32
+ /** Pricing context (currency_code/region_id) for actions + related. */
33
+ pricingContext?: PricingContextQuery
34
+ addToCart: (input: AddToCartInput) => Promise<void>
35
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
36
+ openCart?: () => void
37
+ /**
38
+ * The store's delivery, exchange and return promises, shown in the
39
+ * accordion. No default: with none, that section does not exist, because
40
+ * the library cannot promise anything on a merchant's behalf.
41
+ */
42
+ promises?: ProductPromise[]
43
+ /**
44
+ * The store's product pack, REQUIRED even when a `StorefrontLocaleProvider`
45
+ * is mounted: the related-products strip is a SERVER component and a
46
+ * server component cannot read a client context, so its heading stays
47
+ * English unless the pack arrives as a prop. Until 2026-09-13 this
48
+ * template did not accept one at all and did not pass one down, which made
49
+ * that strip untranslatable without ejecting the file (Alexander found it
50
+ * on evoo: a Bulgarian page ending in "Related products / You might also
51
+ * want to check out these products"). Optional until 2026-09-14, which is
52
+ * how a page could still forget it; now the build refuses the page.
53
+ * Pass `STORE_LOCALE.products` (`en.products` for an English store).
54
+ */
55
+ labels: ProductLabels
56
+ /** Drop the physical-facts section even for a product that has facts. */
57
+ hideSpecs?: boolean
58
+ /** Anything else the store wants in the accordion, appended in order. */
59
+ sections?: ProductSection[]
60
+ /**
61
+ * The variant the address names: pass `searchParams.variant` from the
62
+ * page, so the server renders that variant's price, code and stock and
63
+ * nothing flashes (the product page contract, `use-product-actions.ts`).
64
+ */
65
+ initialVariantId?: string | null
66
+ /** The store's own card for the related strip; the library's preview without it. */
67
+ renderProduct?: ComponentType<{ product: StoreProduct }>
68
+ }
69
+
70
+ export function ProductTemplate({
71
+ client,
72
+ product,
73
+ pricingContext,
74
+ addToCart,
75
+ onAddToCart,
76
+ openCart,
77
+ promises,
78
+ labels,
79
+ hideSpecs,
80
+ sections,
81
+ initialVariantId,
82
+ renderProduct,
83
+ }: ProductTemplateProps) {
84
+ if (!product || !product.id) {
85
+ return notFound()
86
+ }
87
+
88
+ const images = product.images ?? []
89
+
90
+ return (
91
+ <>
92
+ <div
93
+ className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row sm:items-start py-6 relative"
94
+ data-testid="product-container"
95
+ >
96
+ <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-6">
97
+ <ProductInfo product={product} />
98
+ <ProductTabs
99
+ product={product}
100
+ promises={promises}
101
+ hideSpecs={hideSpecs}
102
+ sections={sections}
103
+ />
104
+ </div>
105
+ <div className="block w-full relative">
106
+ <ImageGallery images={images} />
107
+ </div>
108
+ <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-12">
109
+ <Suspense
110
+ fallback={
111
+ <ProductActions
112
+ disabled={true}
113
+ product={product}
114
+ initialVariantId={initialVariantId}
115
+ addToCart={addToCart}
116
+ onAddToCart={onAddToCart}
117
+ openCart={openCart}
118
+ />
119
+ }
120
+ >
121
+ <ProductActionsWrapper
122
+ client={client}
123
+ id={product.id}
124
+ pricingContext={pricingContext}
125
+ initialVariantId={initialVariantId}
126
+ addToCart={addToCart}
127
+ onAddToCart={onAddToCart}
128
+ openCart={openCart}
129
+ />
130
+ </Suspense>
131
+ </div>
132
+ </div>
133
+ <div
134
+ className="max-w-7xl mx-auto px-4 my-16 sm:my-32"
135
+ data-testid="related-products-container"
136
+ >
137
+ <Suspense
138
+ fallback={
139
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
140
+ {Array.from({ length: 4 }).map((_, i) => (
141
+ <div
142
+ key={i}
143
+ className="aspect-[9/16] bg-muted animate-pulse rounded-lg"
144
+ />
145
+ ))}
146
+ </div>
147
+ }
148
+ >
149
+ <RelatedProducts
150
+ client={client}
151
+ product={product}
152
+ pricingContext={pricingContext}
153
+ labels={labels}
154
+ renderProduct={renderProduct}
155
+ />
156
+ </Suspense>
157
+ </div>
158
+ </>
159
+ )
160
+ }
161
+
162
+ export { type ProductTemplateProps }
@@ -0,0 +1,164 @@
1
+ "use client"
2
+
3
+ /**
4
+ * The product page contract, as a headless hook: the BEHAVIOUR of a
5
+ * product's buy panel, with no markup, so a store draws its own panel and
6
+ * inherits the rules, and the package's own `ProductActions` is one more
7
+ * consumer of it. The contract (docs/storefront/components.md, "The product
8
+ * page contract"):
9
+ *
10
+ * 1. The address is the state: `?variant=<digits>` (variant-url.ts).
11
+ * 2. The first paint is right: the page passes the address's variant in
12
+ * as `initialVariantId`, so the server renders that variant's price,
13
+ * code and stock and nothing flashes.
14
+ * 3. A switch is a browser event: the address is rewritten with the
15
+ * browser's own history call, which Next's router picks up, and NO
16
+ * server render runs. Going through the router made every click a
17
+ * navigation: the address lagged a whole page render behind the click
18
+ * and a fast second click left it on the first variant (2026-09-15).
19
+ * 4. The default is the merchant's: each option's first value, together.
20
+ * 5. The server is asked only for what needs it: adding to the cart.
21
+ *
22
+ * Stock is the server's `in_stock` predicate on the variant, read here and
23
+ * never re-derived; a 400 `insufficient_inventory` on add flips the panel
24
+ * to out of stock until the choice changes.
25
+ */
26
+ import { useCallback, useEffect, useMemo, useState } from "react"
27
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
28
+
29
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
30
+ import { getProductPrice, type VariantPrice } from "../lib/get-product-price"
31
+ import { findMatchingVariant, optionsAsKeymap, type OptionChoices } from "./variant-matching"
32
+ import { VARIANT_PARAM, defaultVariant, findVariantByParam, variantHref, variantParamValue } from "./variant-url"
33
+
34
+ /** Input the host's cart seam receives on add. */
35
+ export type AddToCartInput = {
36
+ variantId: string
37
+ quantity: number
38
+ }
39
+
40
+ export interface UseProductActionsInput {
41
+ product: StoreProduct
42
+ /**
43
+ * The cart seam: the host's server action or cart call. May throw a
44
+ * `StoreApiError`; `insufficient_inventory` is handled here.
45
+ */
46
+ addToCart: (input: AddToCartInput) => Promise<void>
47
+ /**
48
+ * The variant the address named when the page was rendered on the
49
+ * server (`searchParams.variant`, digits or whole id). Passing it is what
50
+ * makes the first paint right; without it the merchant's default is chosen.
51
+ */
52
+ initialVariantId?: string | null
53
+ /** Fired after a successful add (analytics). */
54
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
55
+ /** Open the host's cart drawer after a successful add. */
56
+ openCart?: () => void
57
+ /** Write the choice into the address. On by default; off for a panel outside the product page (a quick-buy card). */
58
+ syncAddress?: boolean
59
+ }
60
+
61
+ export interface ProductActionsState {
62
+ /** The variant the choices name; undefined until every option is chosen. */
63
+ variant: StoreProductVariant | undefined
64
+ /** Chosen option values by option id. */
65
+ chosen: OptionChoices
66
+ choose: (optionId: string, value: string) => void
67
+ quantity: number
68
+ setQuantity: (quantity: number) => void
69
+ /** The server's availability for the chosen variant; false with no variant. */
70
+ inStock: boolean
71
+ isAdding: boolean
72
+ /** The chosen variant's price, or the cheapest while none is chosen. */
73
+ price: VariantPrice | null
74
+ /** True when every option has a value that names a real variant. */
75
+ isValidVariant: boolean
76
+ /** Add the chosen variant at the chosen quantity. Resolves after the host's cart seam. */
77
+ add: () => Promise<void>
78
+ }
79
+
80
+ export function useProductActions({
81
+ product,
82
+ addToCart,
83
+ initialVariantId,
84
+ onAddToCart,
85
+ openCart,
86
+ syncAddress = true,
87
+ }: UseProductActionsInput): ProductActionsState {
88
+ const router = useRouter()
89
+ const pathname = usePathname()
90
+ const searchParams = useSearchParams()
91
+ const variants = product.variants ?? []
92
+
93
+ const [chosen, setChosen] = useState<OptionChoices>(() => {
94
+ const start = findVariantByParam(variants, initialVariantId) ?? defaultVariant(product)
95
+ return start ? optionsAsKeymap(start.options) ?? {} : {}
96
+ })
97
+ const [quantity, setQuantityState] = useState(1)
98
+ const [isAdding, setIsAdding] = useState(false)
99
+ const [exhausted, setExhausted] = useState(false)
100
+
101
+ const variant = useMemo(
102
+ () => (variants.length === 1 ? variants[0] : findMatchingVariant(variants, chosen)),
103
+ [variants, chosen]
104
+ )
105
+
106
+ const choose = useCallback((optionId: string, value: string) => {
107
+ setExhausted(false)
108
+ setChosen((prev) => ({ ...prev, [optionId]: value }))
109
+ }, [])
110
+
111
+ const setQuantity = useCallback((next: number) => {
112
+ setQuantityState(Math.max(1, Math.floor(next) || 1))
113
+ }, [])
114
+
115
+ // Rule 3: the address follows the choice, in the browser alone.
116
+ useEffect(() => {
117
+ if (!syncAddress || variants.length < 2 || !variant) return
118
+ if (searchParams.get(VARIANT_PARAM) === variantParamValue(variant.id)) return
119
+ window.history.replaceState(null, "", variantHref(pathname, searchParams, variant.id))
120
+ }, [syncAddress, variants.length, variant, searchParams, pathname])
121
+
122
+ const inStock = useMemo(() => {
123
+ if (!variant) return false
124
+ if (typeof variant.in_stock === "boolean") return variant.in_stock && !exhausted
125
+ // A wire without the predicate: optimistic, the server still enforces stock on add.
126
+ if (!variant.manage_inventory || variant.allow_backorder) return true
127
+ return !exhausted
128
+ }, [variant, exhausted])
129
+
130
+ const { cheapestPrice, variantPrice } = getProductPrice({ product, variantId: variant?.id })
131
+ const price = variant ? variantPrice : cheapestPrice
132
+
133
+ const add = useCallback(async () => {
134
+ if (!variant?.id) return
135
+ setIsAdding(true)
136
+ try {
137
+ await addToCart({ variantId: variant.id, quantity })
138
+ } catch (e) {
139
+ if ((e as { code?: string } | null)?.code === "insufficient_inventory") {
140
+ setExhausted(true)
141
+ return
142
+ }
143
+ throw e
144
+ } finally {
145
+ setIsAdding(false)
146
+ }
147
+ onAddToCart?.(product, variant)
148
+ openCart?.()
149
+ router.refresh()
150
+ }, [variant, quantity, addToCart, onAddToCart, openCart, product, router])
151
+
152
+ return {
153
+ variant,
154
+ chosen,
155
+ choose,
156
+ quantity,
157
+ setQuantity,
158
+ inStock,
159
+ isAdding,
160
+ price,
161
+ isValidVariant: Boolean(variant),
162
+ add,
163
+ }
164
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The product page contract, part one: THE ADDRESS IS THE STATE.
3
+ *
4
+ * A product page names its chosen variant in the address, the way Shopify
5
+ * does: `/products/<handle>?variant=<id>`. One parameter name for every
6
+ * store, so a link in an email, an ad or a chat opens on the variant it was
7
+ * copied from, and a refresh never forgets a choice.
8
+ *
9
+ * The value is the id's DIGITS, never the prefixed key: the platform's law
10
+ * for every id in a URL (entity-numbers-everywhere card). `pvar_1000683784068`
11
+ * travels as `1000683784068`; reading accepts both forms, so an old link
12
+ * with the whole key still resolves.
13
+ *
14
+ * The default, when the address names nothing, is THE MERCHANT'S: the
15
+ * variant made of each option's first value, in the option order the admin
16
+ * shows. Not the first variant of the wire, which is whatever the database
17
+ * scanned first (2026-09-15, evoo: the carton before the bottle on a product
18
+ * whose option says bottle first). Pure module, no React.
19
+ */
20
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
21
+ import { findMatchingVariant } from "./variant-matching"
22
+
23
+ /** Shopify's parameter name, and ours. */
24
+ export const VARIANT_PARAM = "variant"
25
+
26
+ /** The digits of a platform id; an id without a prefix travels whole. */
27
+ export function variantParamValue(variantId: string): string {
28
+ const underscore = variantId.indexOf("_")
29
+ return underscore >= 0 ? variantId.slice(underscore + 1) : variantId
30
+ }
31
+
32
+ /** The variant an address names, by its digits or by its whole id. */
33
+ export function findVariantByParam<V extends { id: string }>(
34
+ variants: V[] | null | undefined,
35
+ value: string | null | undefined
36
+ ): V | undefined {
37
+ if (!value) return undefined
38
+ return variants?.find((v) => v.id === value || variantParamValue(v.id) === value)
39
+ }
40
+
41
+ /**
42
+ * The merchant's default variant: each option's first value, together;
43
+ * failing that, the lowest rank; failing that, the first of the wire.
44
+ */
45
+ export function defaultVariant(
46
+ product: Pick<StoreProduct, "options" | "variants">
47
+ ): StoreProductVariant | undefined {
48
+ const variants = product.variants ?? []
49
+ if (!variants.length) return undefined
50
+ if (variants.length === 1) return variants[0]
51
+
52
+ const firstValues: Record<string, string> = {}
53
+ for (const option of product.options ?? []) {
54
+ const first = option.values?.[0]?.value
55
+ if (first) firstValues[option.id] = first
56
+ }
57
+ const byOptions = Object.keys(firstValues).length
58
+ ? findMatchingVariant(variants, firstValues)
59
+ : undefined
60
+ if (byOptions) return byOptions
61
+
62
+ return [...variants].sort((a, b) => (a.variant_rank ?? 0) - (b.variant_rank ?? 0))[0]
63
+ }
64
+
65
+ /** The page's address with the variant written in, every other parameter kept. */
66
+ export function variantHref(
67
+ pathname: string,
68
+ search: string | URLSearchParams | null | undefined,
69
+ variantId: string
70
+ ): string {
71
+ const params = new URLSearchParams(search ?? "")
72
+ params.set(VARIANT_PARAM, variantParamValue(variantId))
73
+ return `${pathname}?${params.toString()}`
74
+ }
@@ -48,8 +48,9 @@ export async function PaginatedProducts({
48
48
  client: StorefrontClient
49
49
  sortBy?: SortOptions
50
50
  page: number
51
- /** Filters by PRIMARY collection (`products.collection_id`) for the
52
- * membership join use collection-template / `listCollectionProducts`. */
51
+ /** Filters by collection MEMBERSHIP since 2026-09-15 (a product in three
52
+ * collections is listed under all three). For the collection's OWN order
53
+ * use collection-template / `listCollectionProducts`. */
53
54
  collectionId?: string
54
55
  categoryId?: string
55
56
  productsIds?: string[]