@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.
- package/package.json +3 -1
- package/src/api/customers.ts +6 -7
- package/src/api/products.ts +330 -314
- package/src/locales/bg.ts +3 -0
- package/src/locales/es.ts +3 -0
- package/src/products/index.ts +14 -0
- package/src/products/labels.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +62 -58
- package/src/products/product-actions.tsx +104 -177
- package/src/products/product-info.tsx +10 -5
- package/src/products/product-template.tsx +162 -149
- package/src/products/use-product-actions.ts +164 -0
- package/src/products/variant-url.ts +74 -0
- package/src/store/paginated-products.tsx +3 -2
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
52
|
-
*
|
|
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[]
|