@cartbase/storefront 0.14.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.
Files changed (46) hide show
  1. package/README.md +9 -3
  2. package/package.json +4 -1
  3. package/src/api/customers.ts +9 -13
  4. package/src/api/products.ts +330 -310
  5. package/src/cart-drawer/context.tsx +11 -1
  6. package/src/cart-drawer/item/variant.tsx +8 -3
  7. package/src/checkout/checkout-client.tsx +10 -1
  8. package/src/checkout/line-item-card.tsx +5 -2
  9. package/src/checkout/order-summary.tsx +9 -5
  10. package/src/common/country-select.tsx +11 -65
  11. package/src/common/index.ts +2 -0
  12. package/src/common/market-select.tsx +57 -0
  13. package/src/lib/variant-caption.ts +32 -0
  14. package/src/locales/bg.ts +3 -0
  15. package/src/locales/context.ts +37 -0
  16. package/src/locales/es.ts +3 -0
  17. package/src/locales/provider.tsx +17 -21
  18. package/src/locales/types.ts +24 -21
  19. package/src/order/order-completed-template.tsx +10 -4
  20. package/src/order/order-item.tsx +4 -4
  21. package/src/products/index.ts +14 -0
  22. package/src/products/labels.ts +7 -0
  23. package/src/products/product-actions-wrapper.tsx +62 -58
  24. package/src/products/product-actions.tsx +104 -177
  25. package/src/products/product-info.tsx +10 -5
  26. package/src/products/product-template.tsx +162 -147
  27. package/src/products/related-products.tsx +9 -9
  28. package/src/products/use-product-actions.ts +164 -0
  29. package/src/products/variant-url.ts +74 -0
  30. package/src/reviews-ui/photo-upload.tsx +2 -1
  31. package/src/reviews-ui/review-list.tsx +4 -2
  32. package/src/reviews-ui/review-widget.tsx +4 -1
  33. package/src/reviews-ui/review-wizard.tsx +3 -2
  34. package/src/store/category-template.tsx +8 -1
  35. package/src/store/collection-template.tsx +8 -1
  36. package/src/store/paginated-products.tsx +3 -2
  37. package/src/store/search-template.tsx +10 -4
  38. package/src/store/store-template.tsx +10 -4
  39. package/src/tracking/chatgpt-pixel.tsx +17 -10
  40. package/src/tracking/consent-init.tsx +62 -44
  41. package/src/tracking/consent.ts +58 -7
  42. package/src/tracking/index.ts +2 -0
  43. package/src/tracking/meta-pixel.tsx +27 -15
  44. package/src/tracking/storefront-tags.tsx +14 -7
  45. package/src/tracking/tiktok-pixel.tsx +15 -7
  46. package/src/tracking/types.ts +5 -2
@@ -1,58 +1,62 @@
1
- /**
2
- * Fresh-price fetch wrapper for ProductActions — ported from
3
- * `@1click/ui/src/products/product-actions-wrapper.tsx` (v2.3.1). Data
4
- * seam: the Medusa `listProducts({id})` + region-priced fetch becomes
5
- * `api/products` `retrieveProduct(idOrHandle, pricingContext)` — the PDP
6
- * shell renders with the (possibly cached) product while this wrapper
7
- * re-fetches `calculated_price` with the live pricing context (and the
8
- * customer's Bearer JWT via the client, for group-aware B2B prices).
9
- *
10
- * Async server component — render inside `<Suspense>` with a disabled
11
- * `<ProductActions>` fallback (see ProductTemplate).
12
- */
13
- import type { StorefrontClient } from "../api/http"
14
- import type { PricingContextQuery } from "../api/types"
15
- import type { StoreProduct, StoreProductVariant } from "../api/products"
16
- import { retrieveProduct } from "../api/products"
17
- import { StoreApiError } from "../api/types"
18
- import { ProductActions, type AddToCartInput } from "./product-actions"
19
-
20
- type ProductActionsWrapperProps = {
21
- client: StorefrontClient
22
- /** Product id (`prod_…`) or handle. */
23
- id: string
24
- /** Pricing context so `calculated_price` is present for the price panel. */
25
- pricingContext?: PricingContextQuery
26
- addToCart: (input: AddToCartInput) => Promise<void>
27
- onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
28
- openCart?: () => void
29
- }
30
-
31
- export async function ProductActionsWrapper({
32
- client,
33
- id,
34
- pricingContext,
35
- addToCart,
36
- onAddToCart,
37
- openCart,
38
- }: ProductActionsWrapperProps) {
39
- let product: StoreProduct
40
- try {
41
- const res = await retrieveProduct(client, id, pricingContext)
42
- product = res.product
43
- } catch (e) {
44
- if (e instanceof StoreApiError && e.status === 404) return null
45
- throw e
46
- }
47
-
48
- return (
49
- <ProductActions
50
- product={product}
51
- addToCart={addToCart}
52
- onAddToCart={onAddToCart}
53
- openCart={openCart}
54
- />
55
- )
56
- }
57
-
58
- export { type ProductActionsWrapperProps }
1
+ /**
2
+ * Fresh-price fetch wrapper for ProductActions — ported from
3
+ * `@1click/ui/src/products/product-actions-wrapper.tsx` (v2.3.1). Data
4
+ * seam: the Medusa `listProducts({id})` + region-priced fetch becomes
5
+ * `api/products` `retrieveProduct(idOrHandle, pricingContext)` — the PDP
6
+ * shell renders with the (possibly cached) product while this wrapper
7
+ * re-fetches `calculated_price` with the live pricing context (and the
8
+ * customer's Bearer JWT via the client, for group-aware B2B prices).
9
+ *
10
+ * Async server component — render inside `<Suspense>` with a disabled
11
+ * `<ProductActions>` fallback (see ProductTemplate).
12
+ */
13
+ import type { StorefrontClient } from "../api/http"
14
+ import type { PricingContextQuery } from "../api/types"
15
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
16
+ import { retrieveProduct } from "../api/products"
17
+ import { StoreApiError } from "../api/types"
18
+ import { ProductActions, type AddToCartInput } from "./product-actions"
19
+
20
+ type ProductActionsWrapperProps = {
21
+ client: StorefrontClient
22
+ /** Product id (`prod_…`) or handle. */
23
+ id: string
24
+ /** Pricing context so `calculated_price` is present for the price panel. */
25
+ pricingContext?: PricingContextQuery
26
+ /** The variant the address names; see the product page contract. */
27
+ initialVariantId?: string | null
28
+ addToCart: (input: AddToCartInput) => Promise<void>
29
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
30
+ openCart?: () => void
31
+ }
32
+
33
+ export async function ProductActionsWrapper({
34
+ client,
35
+ id,
36
+ pricingContext,
37
+ initialVariantId,
38
+ addToCart,
39
+ onAddToCart,
40
+ openCart,
41
+ }: ProductActionsWrapperProps) {
42
+ let product: StoreProduct
43
+ try {
44
+ const res = await retrieveProduct(client, id, pricingContext)
45
+ product = res.product
46
+ } catch (e) {
47
+ if (e instanceof StoreApiError && e.status === 404) return null
48
+ throw e
49
+ }
50
+
51
+ return (
52
+ <ProductActions
53
+ product={product}
54
+ initialVariantId={initialVariantId}
55
+ addToCart={addToCart}
56
+ onAddToCart={onAddToCart}
57
+ openCart={openCart}
58
+ />
59
+ )
60
+ }
61
+
62
+ export { type ProductActionsWrapperProps }
@@ -1,31 +1,22 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * PDP add-to-cart panel ported from
5
- * `@1click/ui/src/products/product-actions.tsx` (v2.3.1).
4
+ * The package's default buy panel: options, price, quantity, the button,
5
+ * and the sticky bar on phones. Every rule of the product page contract
6
+ * lives in `useProductActions`; this file is only the markup, and a store
7
+ * that wants its own look draws its own panel on the same hook and
8
+ * inherits the same behaviour (evoo's `features/product/buy-box.tsx` is
9
+ * the reference).
6
10
  *
7
- * Data-seam adaptations (batch-10 port):
8
- * - `HttpTypes.*` Cartbase SDK DTOs (`@cartbase/storefront/api/products`).
9
- * - The `addToCart` server action (Medusa js-sdk, cart-cookie owner) is now
10
- * an injected prop: the host app wires it to its own cart orchestration
11
- * (typically `api/carts` `addLineItem` + its cart-id cookie). Same for the
12
- * cart drawer: `openCart` replaces the `useCartDrawer()` context import so
13
- * the products family has no hard dependency on the cart-drawer family.
14
- * - `countryCode` (Medusa region routing) is dropped — Cartbase pricing
15
- * context travels with the product fetch, and the host's `addToCart`
16
- * closes over whatever routing it needs.
17
- * - Stock: the variant DTO carries `in_stock`, computed server-side by THE
18
- * availability predicate — this component only reads it. The server still
19
- * enforces stock at add (400 `insufficient_inventory` flips the button to
20
- * the out-of-stock state). On a legacy wire without the field, managed
21
- * variants fall back to the old optimistic behavior; richer DTOs carrying
22
- * `inventory_quantity` keep the original quantity check.
23
- *
24
- * Variant matching is the extracted pure module `./variant-matching`
25
- * (handles Cartbase's option-value link shape).
11
+ * Until 2026-09-15 this panel carried the behaviour itself, and carried it
12
+ * wrong: it wrote the variant into the address through the router as
13
+ * `v_id=<whole key>`, so every click was a server render and the address
14
+ * lagged; it chose nothing on a product with several variants until the
15
+ * shopper clicked; and it added one unit, always. The hook fixed all three
16
+ * for every consumer at once.
26
17
  */
27
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
28
- import { useEffect, useMemo, useRef, useState } from "react"
18
+ import { useRef } from "react"
19
+ import { Minus, Plus } from "lucide-react"
29
20
 
30
21
  import type { StoreProduct, StoreProductVariant } from "../api/products"
31
22
  import { useIntersection } from "../lib/hooks/use-intersection"
@@ -34,189 +25,125 @@ import { useProductLabels } from "./context"
34
25
  import { OptionSelect } from "./option-select"
35
26
  import { ProductPrice } from "./product-price"
36
27
  import { MobileActions } from "./mobile-actions"
37
- import { findMatchingVariant, optionsAsKeymap, optionsMatch } from "./variant-matching"
28
+ import { useProductActions, type AddToCartInput } from "./use-product-actions"
38
29
 
39
- /** Input the host's cart seam receives on add. */
40
- export type AddToCartInput = {
41
- variantId: string
42
- quantity: number
43
- }
30
+ export type { AddToCartInput }
44
31
 
45
32
  type ProductActionsProps = {
46
33
  product: StoreProduct
47
34
  /**
48
- * The cart seam: called with the selected variant. Wire to the host's
49
- * cart orchestration (`api/carts` `addLineItem` behind the host's cart-id
50
- * storage). May throw `StoreApiError` — `insufficient_inventory` is
51
- * handled here (button flips to out-of-stock).
35
+ * The cart seam: called with the selected variant and quantity. Wire to
36
+ * the host's cart orchestration (`api/carts` `addLineItem` behind the
37
+ * host's cart-id storage). May throw `StoreApiError`;
38
+ * `insufficient_inventory` is handled here (button flips to out of stock).
52
39
  */
53
40
  addToCart: (input: AddToCartInput) => Promise<void>
41
+ /**
42
+ * The variant the address names (`searchParams.variant`), passed from the
43
+ * page so the first paint is the right variant. See the product page
44
+ * contract in `use-product-actions.ts`.
45
+ */
46
+ initialVariantId?: string | null
54
47
  disabled?: boolean
55
48
  /** Fired after a successful add (analytics: trackAddToCart trio). */
56
49
  onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
57
50
  /** Open the host's cart drawer after a successful add. */
58
51
  openCart?: () => void
52
+ /** The quantity stepper; on by default. */
53
+ showQuantity?: boolean
59
54
  }
60
55
 
61
56
  export function ProductActions({
62
57
  product,
63
58
  addToCart,
59
+ initialVariantId,
64
60
  disabled,
65
61
  onAddToCart,
66
62
  openCart,
63
+ showQuantity = true,
67
64
  }: ProductActionsProps) {
68
65
  const labels = useProductLabels()
69
- const router = useRouter()
70
- const pathname = usePathname()
71
- const searchParams = useSearchParams()
72
-
73
- const [options, setOptions] = useState<Record<string, string | undefined>>({})
74
- const [isAdding, setIsAdding] = useState(false)
75
- // Set when the server rejected an add with `insufficient_inventory`
76
- // (Cartbase exposes no inventory_quantity on the store surface — the server
77
- // is the stock truth). Cleared when the selection changes.
78
- const [stockExhausted, setStockExhausted] = useState(false)
79
-
80
- useEffect(() => {
81
- if (product.variants?.length === 1) {
82
- const variantOptions = optionsAsKeymap(product.variants[0]?.options ?? null)
83
- setOptions(variantOptions ?? {})
84
- }
85
- }, [product.variants])
86
-
87
- const selectedVariant = useMemo(
88
- () => findMatchingVariant(product.variants, options),
89
- [product.variants, options]
90
- )
91
-
92
- const setOptionValue = (optionId: string, value: string) => {
93
- setOptions((prev) => ({ ...prev, [optionId]: value }))
94
- }
95
-
96
- const isValidVariant = useMemo(() => {
97
- return product.variants?.some((v) =>
98
- optionsMatch(options, optionsAsKeymap(v.options))
99
- )
100
- }, [product.variants, options])
101
-
102
- useEffect(() => {
103
- setStockExhausted(false)
104
- }, [selectedVariant?.id])
105
-
106
- useEffect(() => {
107
- const params = new URLSearchParams(searchParams.toString())
108
- const value = isValidVariant ? selectedVariant?.id : null
109
-
110
- if (params.get("v_id") === value) return
111
-
112
- if (value) {
113
- params.set("v_id", value)
114
- } else {
115
- params.delete("v_id")
116
- }
117
-
118
- router.replace(pathname + "?" + params.toString())
119
- }, [selectedVariant, isValidVariant, searchParams, pathname, router])
120
-
121
- const inStock = useMemo(() => {
122
- if (!selectedVariant) return false
123
- // The server computes THE availability predicate and emits it as
124
- // `in_stock` on the variant DTO — read it, never re-derive it here
125
- // (the hand-copied clauses this replaced could not see stock levels and
126
- // showed an enabled Add to cart on sold-out variants until a 400).
127
- if (typeof selectedVariant.in_stock === "boolean") {
128
- return selectedVariant.in_stock && !stockExhausted
129
- }
130
- // Legacy wire (platform predates the field): optimistic fallback — the
131
- // server still enforces stock on add.
132
- if (!selectedVariant.manage_inventory) return true
133
- if (selectedVariant.allow_backorder) return true
134
- const qty = (selectedVariant as { inventory_quantity?: number })
135
- .inventory_quantity
136
- if (qty === undefined) return !stockExhausted
137
- return qty > 0
138
- }, [selectedVariant, stockExhausted])
66
+ const state = useProductActions({ product, addToCart, initialVariantId, onAddToCart, openCart })
67
+ const { variant, chosen, choose, quantity, setQuantity, inStock, isAdding, isValidVariant, add } = state
139
68
 
140
69
  const actionsRef = useRef<HTMLDivElement>(null)
141
70
  const inView = useIntersection(actionsRef, "0px")
142
-
143
- const handleAddToCart = async () => {
144
- if (!selectedVariant?.id) return
145
-
146
- setIsAdding(true)
147
- try {
148
- await addToCart({ variantId: selectedVariant.id, quantity: 1 })
149
- } catch (e) {
150
- if ((e as { code?: string } | null)?.code === "insufficient_inventory") {
151
- setStockExhausted(true)
152
- return
153
- }
154
- throw e
155
- } finally {
156
- setIsAdding(false)
157
- }
158
-
159
- onAddToCart?.(product, selectedVariant)
160
- openCart?.()
161
- router.refresh()
162
- }
71
+ const busy = !!disabled || isAdding
163
72
 
164
73
  return (
165
- <>
166
- <div className="flex flex-col gap-y-2" ref={actionsRef}>
167
- <div>
168
- {(product.variants?.length ?? 0) > 1 && (
169
- <div className="flex flex-col gap-y-4">
170
- {(product.options || []).map((option) => (
171
- <div key={option.id}>
172
- <OptionSelect
173
- option={option}
174
- current={options[option.id]}
175
- updateOption={setOptionValue}
176
- title={option.title ?? ""}
177
- data-testid="product-options"
178
- disabled={!!disabled || isAdding}
179
- />
180
- </div>
181
- ))}
182
- <div className="h-px bg-border" />
74
+ <div className="flex flex-col gap-y-2" ref={actionsRef}>
75
+ {(product.variants?.length ?? 0) > 1 && (
76
+ <div className="flex flex-col gap-y-4">
77
+ {(product.options || []).map((option) => (
78
+ <div key={option.id}>
79
+ <OptionSelect
80
+ option={option}
81
+ current={chosen[option.id]}
82
+ updateOption={choose}
83
+ title={option.title ?? ""}
84
+ data-testid="product-options"
85
+ disabled={busy}
86
+ />
183
87
  </div>
184
- )}
88
+ ))}
89
+ <div className="h-px bg-border" />
185
90
  </div>
186
-
187
- <ProductPrice product={product} variant={selectedVariant} />
188
-
189
- <Button
190
- onClick={handleAddToCart}
191
- disabled={
192
- !inStock ||
193
- !selectedVariant ||
194
- !!disabled ||
195
- isAdding ||
196
- !isValidVariant
197
- }
198
- className="w-full h-10"
199
- data-testid="add-product-button"
200
- >
201
- {!selectedVariant && !options
202
- ? labels.selectVariant
203
- : !inStock || !isValidVariant
204
- ? labels.outOfStock
205
- : labels.addToCart}
206
- </Button>
207
- <MobileActions
208
- product={product}
209
- variant={selectedVariant}
210
- options={options}
211
- updateOptions={setOptionValue}
212
- inStock={inStock}
213
- handleAddToCart={handleAddToCart}
214
- isAdding={isAdding}
215
- show={!inView}
216
- optionsDisabled={!!disabled || isAdding}
217
- />
218
- </div>
219
- </>
91
+ )}
92
+
93
+ <ProductPrice product={product} variant={variant} />
94
+
95
+ {showQuantity && (
96
+ <div className="flex flex-col gap-y-2">
97
+ <span className="text-sm text-foreground">{labels.quantity}</span>
98
+ <div className="inline-flex w-fit items-stretch rounded-lg border border-border bg-card">
99
+ <button
100
+ type="button"
101
+ onClick={() => setQuantity(quantity - 1)}
102
+ aria-label={labels.decreaseQuantity}
103
+ disabled={quantity <= 1 || busy}
104
+ className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
105
+ data-testid="quantity-decrease"
106
+ >
107
+ <Minus className="size-4" aria-hidden />
108
+ </button>
109
+ <output className="min-w-10 py-2 text-center text-sm text-foreground" aria-live="polite" data-testid="quantity">
110
+ {quantity}
111
+ </output>
112
+ <button
113
+ type="button"
114
+ onClick={() => setQuantity(quantity + 1)}
115
+ aria-label={labels.increaseQuantity}
116
+ disabled={busy}
117
+ className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
118
+ data-testid="quantity-increase"
119
+ >
120
+ <Plus className="size-4" aria-hidden />
121
+ </button>
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ <Button
127
+ onClick={add}
128
+ disabled={!inStock || !variant || busy || !isValidVariant}
129
+ className="w-full h-10"
130
+ data-testid="add-product-button"
131
+ >
132
+ {!variant ? labels.selectVariant : !inStock ? labels.outOfStock : labels.addToCart}
133
+ </Button>
134
+
135
+ <MobileActions
136
+ product={product}
137
+ variant={variant}
138
+ options={chosen}
139
+ updateOptions={choose}
140
+ inStock={inStock}
141
+ handleAddToCart={add}
142
+ isAdding={isAdding}
143
+ show={!inView}
144
+ optionsDisabled={busy}
145
+ />
146
+ </div>
220
147
  )
221
148
  }
222
149
 
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * PDP title/collection/description block — ported from
3
3
  * `@1click/ui/src/products/product-info.tsx` (v2.3.1). Data seam:
4
- * `HttpTypes.StoreProduct` → Cartbase `StoreProduct` (the collection embed is
5
- * `{id, title, handle}` exactly what the link needs). Server-safe.
4
+ * `HttpTypes.StoreProduct` → Cartbase `StoreProduct`. The product carries
5
+ * EVERY collection it belongs to since 2026-09-15 (the membership join is
6
+ * the one store); the line above the title names the first of them, which is
7
+ * what the single `collection` object used to be. Server-safe.
6
8
  */
7
9
  import Link from "next/link"
8
10
  import type { StoreProduct } from "../api/products"
@@ -12,15 +14,18 @@ type ProductInfoProps = {
12
14
  }
13
15
 
14
16
  export function ProductInfo({ product }: ProductInfoProps) {
17
+ const collection = (product.collections ?? [])
18
+ .map((m) => m.collection)
19
+ .find((c): c is NonNullable<typeof c> => Boolean(c))
15
20
  return (
16
21
  <div id="product-info">
17
22
  <div className="flex flex-col gap-y-4 lg:max-w-[500px] mx-auto">
18
- {product.collection && (
23
+ {collection && (
19
24
  <Link
20
- href={`/collections/${product.collection.handle}`}
25
+ href={`/collections/${collection.handle}`}
21
26
  className="text-sm text-muted-foreground hover:text-muted-foreground"
22
27
  >
23
- {product.collection.title}
28
+ {collection.title}
24
29
  </Link>
25
30
  )}
26
31
  <h2