@cartbase/storefront 0.17.0 → 0.18.1

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 (108) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +3 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +367 -168
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +73 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1604 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/cookie-names.ts +14 -0
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -39
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-totals.tsx +250 -250
  74. package/src/primitives/select-field.tsx +93 -93
  75. package/src/products/image-gallery.tsx +43 -43
  76. package/src/products/index.ts +68 -59
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-actions-wrapper.tsx +63 -62
  79. package/src/products/product-actions.tsx +151 -150
  80. package/src/products/product-price.tsx +69 -69
  81. package/src/products/product-promises.tsx +61 -61
  82. package/src/products/product-specs.tsx +65 -65
  83. package/src/products/product-tabs.tsx +123 -123
  84. package/src/products/product-template.tsx +163 -162
  85. package/src/products/purchase-options.tsx +130 -130
  86. package/src/products/sets.ts +110 -0
  87. package/src/products/use-product-actions.ts +199 -164
  88. package/src/products/variant-matching.ts +71 -71
  89. package/src/products/variant-url.ts +74 -74
  90. package/src/reviews-ui/index.ts +73 -73
  91. package/src/store/category-template.tsx +136 -136
  92. package/src/store/index.ts +40 -40
  93. package/src/tracking/chatgpt-pixel.tsx +99 -99
  94. package/src/tracking/consent-init.tsx +62 -62
  95. package/src/tracking/events.ts +348 -348
  96. package/src/tracking/ga4.tsx +93 -93
  97. package/src/tracking/google-ads.ts +84 -84
  98. package/src/tracking/gtm.tsx +60 -60
  99. package/src/tracking/inline-script.ts +49 -49
  100. package/src/tracking/oaiq.ts +206 -206
  101. package/src/tracking/tiktok-pixel.tsx +91 -91
  102. package/src/tracking/track-init.tsx +56 -56
  103. package/src/tracking/track-order-purchase.tsx +122 -122
  104. package/src/tracking/ttq.ts +180 -180
  105. package/src/tracking/use-tracking-config.ts +54 -54
  106. package/theme/index.css +25 -25
  107. package/theme/theme.css +150 -150
  108. package/theme/tokens.css +106 -106
@@ -0,0 +1,110 @@
1
+ /**
2
+ * SETS: what a set holds, and which sets hold a product.
3
+ *
4
+ * A set on Cartbase is an inventory kit, Medusa's model: the set's variant
5
+ * draws on the stock items of the products inside it, and the product read
6
+ * hands those links out when asked with `fields: SET_FIELDS`
7
+ * (`listProducts`, `retrieveProduct`). Every stock item is born with its own
8
+ * variant's SKU, so a link's owner is the linked variant, of another
9
+ * product, whose SKU is the item's. These are the two walks a cart makes:
10
+ * a set's line shows the products inside it, and a product's line offers
11
+ * the sets that contain it. Pure module, no React, no network.
12
+ */
13
+ import type {
14
+ StoreInventoryItemLink,
15
+ StoreInventoryLinkedVariant,
16
+ StoreProduct,
17
+ StoreProductVariant,
18
+ } from "../api/products"
19
+
20
+ /** The `fields` value that makes a product read carry its kit links. */
21
+ export const SET_FIELDS = [
22
+ "*variants.inventory_items",
23
+ "*variants.inventory_items.inventory",
24
+ "*variants.inventory_items.inventory.variants",
25
+ "*variants.inventory_items.inventory.variants.product",
26
+ ].join(",")
27
+
28
+ /** A product inside a set, and how many of it the set holds. */
29
+ export interface SetPart {
30
+ productId: string
31
+ variantId: string
32
+ title: string
33
+ handle: string
34
+ thumbnail: string | null
35
+ quantity: number
36
+ }
37
+
38
+ /** The product a stock item belongs to: the other product's variant carrying the item's SKU. */
39
+ export function linkOwner(
40
+ link: StoreInventoryItemLink,
41
+ selfProductId: string
42
+ ): StoreInventoryLinkedVariant | null {
43
+ const inventory = link.inventory
44
+ if (!inventory?.sku) return null
45
+ return (
46
+ inventory.variants.find(
47
+ (linked) => linked.product_id !== selfProductId && linked.sku === inventory.sku
48
+ ) ?? null
49
+ )
50
+ }
51
+
52
+ /**
53
+ * What a variant holds when it is a set: the products whose stock it draws
54
+ * on, in the merchant's order. Empty for a product sold on its own stock,
55
+ * and for a read made without `SET_FIELDS`.
56
+ */
57
+ export function setContents(
58
+ product: Pick<StoreProduct, "id">,
59
+ variant: Pick<StoreProductVariant, "inventory_items">
60
+ ): SetPart[] {
61
+ return (variant.inventory_items ?? []).flatMap((link) => {
62
+ const owner = linkOwner(link, product.id)
63
+ if (!owner?.product) return []
64
+ return [
65
+ {
66
+ productId: owner.product.id,
67
+ variantId: owner.id,
68
+ title: owner.product.title,
69
+ handle: owner.product.handle,
70
+ thumbnail: owner.product.thumbnail,
71
+ quantity: Number(link.required_quantity) || 1,
72
+ },
73
+ ]
74
+ })
75
+ }
76
+
77
+ /** Every set variant among these products, keyed by variant id, with what it holds. */
78
+ export function setContentsByVariant(
79
+ products: Array<Pick<StoreProduct, "id"> & { variants?: Array<Pick<StoreProductVariant, "id" | "inventory_items">> | null }>
80
+ ): Record<string, SetPart[]> {
81
+ const byVariant: Record<string, SetPart[]> = {}
82
+ for (const product of products) {
83
+ for (const variant of product.variants ?? []) {
84
+ const parts = setContents(product, variant)
85
+ if (parts.length) byVariant[variant.id] = parts
86
+ }
87
+ }
88
+ return byVariant
89
+ }
90
+
91
+ /**
92
+ * The products that hold this variant's own stock item: the sets it is in.
93
+ * Ids of other products, each once; empty when the read carried no kit
94
+ * links or the variant has no SKU.
95
+ */
96
+ export function setsContaining(
97
+ product: Pick<StoreProduct, "id">,
98
+ variant: Pick<StoreProductVariant, "sku" | "inventory_items">
99
+ ): string[] {
100
+ if (!variant.sku) return []
101
+ const found = new Set<string>()
102
+ for (const link of variant.inventory_items ?? []) {
103
+ const inventory = link.inventory
104
+ if (!inventory || inventory.sku !== variant.sku) continue
105
+ for (const linked of inventory.variants) {
106
+ if (linked.product_id !== product.id) found.add(linked.product_id)
107
+ }
108
+ }
109
+ return [...found]
110
+ }
@@ -1,164 +1,199 @@
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
- }
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
+ * 6. The add is instant (2026-09-16): with no `addToCart` seam, the add
22
+ * goes through the mounted cart drawer (`useCartDrawer().addLine`),
23
+ * the drawer opens at the click with the product already in it, the
24
+ * platform confirms behind it, and no page render runs. A store that
25
+ * passes its own `addToCart` (a server action) keeps the old path:
26
+ * await the seam, open the cart, refresh the page so its
27
+ * server-rendered cart follows.
28
+ *
29
+ * Stock is the server's `in_stock` predicate on the variant, read here and
30
+ * never re-derived; a 400 `insufficient_inventory` on add flips the panel
31
+ * to out of stock until the choice changes.
32
+ */
33
+ import { useCallback, useEffect, useMemo, useState } from "react"
34
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
35
+
36
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
37
+ import { useCartDrawer } from "../cart-drawer/context"
38
+ import { getProductPrice, type VariantPrice } from "../lib/get-product-price"
39
+ import { findMatchingVariant, optionsAsKeymap, type OptionChoices } from "./variant-matching"
40
+ import { VARIANT_PARAM, defaultVariant, findVariantByParam, variantHref, variantParamValue } from "./variant-url"
41
+
42
+ /** Input the host's cart seam receives on add. */
43
+ export type AddToCartInput = {
44
+ variantId: string
45
+ quantity: number
46
+ }
47
+
48
+ export interface UseProductActionsInput {
49
+ product: StoreProduct
50
+ /**
51
+ * The host's own cart seam (a server action). Leave it out for the
52
+ * instant add through the mounted `CartDrawerProvider` (rule 6). May throw
53
+ * a `StoreApiError`; `insufficient_inventory` is handled here.
54
+ */
55
+ addToCart?: (input: AddToCartInput) => Promise<void>
56
+ /**
57
+ * The variant the address named when the page was rendered on the
58
+ * server (`searchParams.variant`, digits or whole id). Passing it is what
59
+ * makes the first paint right; without it the merchant's default is chosen.
60
+ */
61
+ initialVariantId?: string | null
62
+ /** Fired after a successful add (analytics). */
63
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
64
+ /**
65
+ * Open the host's cart: at the click on the instant path, after the seam
66
+ * answers on the server-action path. The instant path opens the mounted
67
+ * drawer when this is left out.
68
+ */
69
+ openCart?: () => void
70
+ /** Write the choice into the address. On by default; off for a panel outside the product page (a quick-buy card). */
71
+ syncAddress?: boolean
72
+ }
73
+
74
+ export interface ProductActionsState {
75
+ /** The variant the choices name; undefined until every option is chosen. */
76
+ variant: StoreProductVariant | undefined
77
+ /** Chosen option values by option id. */
78
+ chosen: OptionChoices
79
+ choose: (optionId: string, value: string) => void
80
+ quantity: number
81
+ setQuantity: (quantity: number) => void
82
+ /** The server's availability for the chosen variant; false with no variant. */
83
+ inStock: boolean
84
+ /** True while a server-action add runs; never on the instant path, which shows the product at once. */
85
+ isAdding: boolean
86
+ /** The chosen variant's price, or the cheapest while none is chosen. */
87
+ price: VariantPrice | null
88
+ /** True when every option has a value that names a real variant. */
89
+ isValidVariant: boolean
90
+ /** Add the chosen variant at the chosen quantity. Resolves after the host's cart seam. */
91
+ add: () => Promise<void>
92
+ }
93
+
94
+ export function useProductActions({
95
+ product,
96
+ addToCart,
97
+ initialVariantId,
98
+ onAddToCart,
99
+ openCart,
100
+ syncAddress = true,
101
+ }: UseProductActionsInput): ProductActionsState {
102
+ const router = useRouter()
103
+ const drawer = useCartDrawer()
104
+ const pathname = usePathname()
105
+ const searchParams = useSearchParams()
106
+ const variants = product.variants ?? []
107
+
108
+ const [chosen, setChosen] = useState<OptionChoices>(() => {
109
+ const start = findVariantByParam(variants, initialVariantId) ?? defaultVariant(product)
110
+ return start ? optionsAsKeymap(start.options) ?? {} : {}
111
+ })
112
+ const [quantity, setQuantityState] = useState(1)
113
+ const [isAdding, setIsAdding] = useState(false)
114
+ const [exhausted, setExhausted] = useState(false)
115
+
116
+ const variant = useMemo(
117
+ () => (variants.length === 1 ? variants[0] : findMatchingVariant(variants, chosen)),
118
+ [variants, chosen]
119
+ )
120
+
121
+ const choose = useCallback((optionId: string, value: string) => {
122
+ setExhausted(false)
123
+ setChosen((prev) => ({ ...prev, [optionId]: value }))
124
+ }, [])
125
+
126
+ const setQuantity = useCallback((next: number) => {
127
+ setQuantityState(Math.max(1, Math.floor(next) || 1))
128
+ }, [])
129
+
130
+ // Rule 3: the address follows the choice, in the browser alone.
131
+ useEffect(() => {
132
+ if (!syncAddress || variants.length < 2 || !variant) return
133
+ if (searchParams.get(VARIANT_PARAM) === variantParamValue(variant.id)) return
134
+ window.history.replaceState(null, "", variantHref(pathname, searchParams, variant.id))
135
+ }, [syncAddress, variants.length, variant, searchParams, pathname])
136
+
137
+ const inStock = useMemo(() => {
138
+ if (!variant) return false
139
+ if (typeof variant.in_stock === "boolean") return variant.in_stock && !exhausted
140
+ // A wire without the predicate: optimistic, the server still enforces stock on add.
141
+ if (!variant.manage_inventory || variant.allow_backorder) return true
142
+ return !exhausted
143
+ }, [variant, exhausted])
144
+
145
+ const { cheapestPrice, variantPrice } = getProductPrice({ product, variantId: variant?.id })
146
+ const price = variant ? variantPrice : cheapestPrice
147
+
148
+ const add = useCallback(async () => {
149
+ if (!variant?.id) return
150
+
151
+ if (!addToCart) {
152
+ // Rule 6: the instant add. The drawer opens now, with the line in it.
153
+ ;(openCart ?? drawer.open)()
154
+ const result = await drawer.addLine(variant.id, quantity, {
155
+ product_id: product.id,
156
+ product_title: product.title,
157
+ product_handle: product.handle,
158
+ thumbnail: variant.thumbnail ?? product.thumbnail ?? product.images?.[0]?.url ?? null,
159
+ variant_title: variant.title ?? null,
160
+ unit_price: price?.calculated_price_number ?? 0,
161
+ })
162
+ if (result.ok) {
163
+ onAddToCart?.(product, variant)
164
+ } else if ((result.error as { code?: string } | null)?.code === "insufficient_inventory") {
165
+ setExhausted(true)
166
+ }
167
+ return
168
+ }
169
+
170
+ setIsAdding(true)
171
+ try {
172
+ await addToCart({ variantId: variant.id, quantity })
173
+ } catch (e) {
174
+ if ((e as { code?: string } | null)?.code === "insufficient_inventory") {
175
+ setExhausted(true)
176
+ return
177
+ }
178
+ throw e
179
+ } finally {
180
+ setIsAdding(false)
181
+ }
182
+ onAddToCart?.(product, variant)
183
+ openCart?.()
184
+ router.refresh()
185
+ }, [variant, quantity, addToCart, onAddToCart, openCart, product, router, drawer, price])
186
+
187
+ return {
188
+ variant,
189
+ chosen,
190
+ choose,
191
+ quantity,
192
+ setQuantity,
193
+ inStock,
194
+ isAdding,
195
+ price,
196
+ isValidVariant: Boolean(variant),
197
+ add,
198
+ }
199
+ }
@@ -1,71 +1,71 @@
1
- /**
2
- * Pure variant-matching logic — extracted from
3
- * `@1click/ui/src/products/product-actions.tsx` (v2.3.1) so the
4
- * option-choice → variant resolution is testable without React
5
- * (tests/unit/storefront-catalog.test.ts).
6
- *
7
- * Data-seam translation: Medusa served `variant.options[]` as flat
8
- * `{option_id, value}` rows; Cartbase serves option-value LINKS
9
- * (`{value: {option_id, value, option}}` — see
10
- * `@cartbase/storefront/api/products` `StoreVariantOptionValueLink`).
11
- * `optionsAsKeymap` reads the Cartbase link shape (null-safe) and falls back
12
- * to the flat Medusa row shape so both wire formats resolve identically.
13
- */
14
-
15
- /** Either Cartbase's option-value link or Medusa's flat option-value row. */
16
- export type VariantOptionLike =
17
- | {
18
- option_id?: string
19
- value?: string | { option_id: string; value: string } | null
20
- }
21
- | null
22
-
23
- /** Chosen options on a PDP: option_id → chosen value (undefined = not chosen). */
24
- export type OptionChoices = Record<string, string | undefined>
25
-
26
- /**
27
- * Flatten a variant's option values into `{[option_id]: value}` for
28
- * comparison against the user's current choices.
29
- */
30
- export function optionsAsKeymap(
31
- variantOptions: VariantOptionLike[] | null | undefined
32
- ): Record<string, string> | undefined {
33
- return variantOptions?.reduce((acc: Record<string, string>, varopt) => {
34
- if (!varopt) return acc
35
- const v = varopt.value
36
- if (v && typeof v === "object") {
37
- // Cartbase link shape: { value: { option_id, value } }
38
- if (v.option_id) acc[v.option_id] = v.value
39
- } else if (varopt.option_id && typeof v === "string") {
40
- // Medusa flat row shape: { option_id, value }
41
- acc[varopt.option_id] = v
42
- }
43
- return acc
44
- }, {})
45
- }
46
-
47
- /**
48
- * True when the chosen options exactly cover the variant's keymap — same
49
- * key count, same values. Partial choices never match (the add-to-cart
50
- * button stays disabled until every option is picked).
51
- */
52
- export function optionsMatch(
53
- a: OptionChoices,
54
- b: Record<string, string> | undefined
55
- ): boolean {
56
- if (!b) return false
57
- const keys = Object.keys(a)
58
- if (keys.length !== Object.keys(b).length) return false
59
- return keys.every((k) => a[k] === b[k])
60
- }
61
-
62
- /** Resolve the variant whose options exactly match the current choices. */
63
- export function findMatchingVariant<
64
- V extends { options?: VariantOptionLike[] | null }
65
- >(
66
- variants: V[] | null | undefined,
67
- options: OptionChoices
68
- ): V | undefined {
69
- if (!variants || variants.length === 0) return undefined
70
- return variants.find((v) => optionsMatch(options, optionsAsKeymap(v.options)))
71
- }
1
+ /**
2
+ * Pure variant-matching logic — extracted from
3
+ * `@1click/ui/src/products/product-actions.tsx` (v2.3.1) so the
4
+ * option-choice → variant resolution is testable without React
5
+ * (tests/unit/storefront-catalog.test.ts).
6
+ *
7
+ * Data-seam translation: Medusa served `variant.options[]` as flat
8
+ * `{option_id, value}` rows; Cartbase serves option-value LINKS
9
+ * (`{value: {option_id, value, option}}` — see
10
+ * `@cartbase/storefront/api/products` `StoreVariantOptionValueLink`).
11
+ * `optionsAsKeymap` reads the Cartbase link shape (null-safe) and falls back
12
+ * to the flat Medusa row shape so both wire formats resolve identically.
13
+ */
14
+
15
+ /** Either Cartbase's option-value link or Medusa's flat option-value row. */
16
+ export type VariantOptionLike =
17
+ | {
18
+ option_id?: string
19
+ value?: string | { option_id: string; value: string } | null
20
+ }
21
+ | null
22
+
23
+ /** Chosen options on a PDP: option_id → chosen value (undefined = not chosen). */
24
+ export type OptionChoices = Record<string, string | undefined>
25
+
26
+ /**
27
+ * Flatten a variant's option values into `{[option_id]: value}` for
28
+ * comparison against the user's current choices.
29
+ */
30
+ export function optionsAsKeymap(
31
+ variantOptions: VariantOptionLike[] | null | undefined
32
+ ): Record<string, string> | undefined {
33
+ return variantOptions?.reduce((acc: Record<string, string>, varopt) => {
34
+ if (!varopt) return acc
35
+ const v = varopt.value
36
+ if (v && typeof v === "object") {
37
+ // Cartbase link shape: { value: { option_id, value } }
38
+ if (v.option_id) acc[v.option_id] = v.value
39
+ } else if (varopt.option_id && typeof v === "string") {
40
+ // Medusa flat row shape: { option_id, value }
41
+ acc[varopt.option_id] = v
42
+ }
43
+ return acc
44
+ }, {})
45
+ }
46
+
47
+ /**
48
+ * True when the chosen options exactly cover the variant's keymap — same
49
+ * key count, same values. Partial choices never match (the add-to-cart
50
+ * button stays disabled until every option is picked).
51
+ */
52
+ export function optionsMatch(
53
+ a: OptionChoices,
54
+ b: Record<string, string> | undefined
55
+ ): boolean {
56
+ if (!b) return false
57
+ const keys = Object.keys(a)
58
+ if (keys.length !== Object.keys(b).length) return false
59
+ return keys.every((k) => a[k] === b[k])
60
+ }
61
+
62
+ /** Resolve the variant whose options exactly match the current choices. */
63
+ export function findMatchingVariant<
64
+ V extends { options?: VariantOptionLike[] | null }
65
+ >(
66
+ variants: V[] | null | undefined,
67
+ options: OptionChoices
68
+ ): V | undefined {
69
+ if (!variants || variants.length === 0) return undefined
70
+ return variants.find((v) => optionsMatch(options, optionsAsKeymap(v.options)))
71
+ }