@cartbase/storefront 0.20.0 → 0.21.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +4 -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 +181 -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/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +56 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/payment-badges.tsx +36 -96
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/icons/cartbase-mark.ts +9 -0
  49. package/src/common/icons/payment-marks.ts +73 -0
  50. package/src/common/icons/social-marks.ts +61 -0
  51. package/src/common/index.ts +35 -20
  52. package/src/common/market-select.tsx +57 -57
  53. package/src/common/payment-icons.tsx +54 -0
  54. package/src/common/powered-by-cartbase.tsx +47 -0
  55. package/src/common/social-links.tsx +65 -0
  56. package/src/index.ts +12 -12
  57. package/src/lib/country-name.ts +59 -59
  58. package/src/lib/get-product-price.ts +133 -133
  59. package/src/lib/media-image.tsx +39 -39
  60. package/src/lib/payment-constants.ts +53 -53
  61. package/src/lib/platform.ts +13 -13
  62. package/src/lib/price.tsx +39 -39
  63. package/src/lib/store-api-error.ts +36 -36
  64. package/src/lib/variant-caption.ts +32 -32
  65. package/src/locales/bg.ts +3 -0
  66. package/src/locales/context.ts +37 -37
  67. package/src/locales/en.ts +26 -26
  68. package/src/locales/es.ts +3 -0
  69. package/src/locales/index.ts +19 -19
  70. package/src/locales/provider.tsx +59 -59
  71. package/src/locales/types.ts +77 -77
  72. package/src/order/index.ts +62 -62
  73. package/src/order/labels.ts +79 -79
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-info.tsx +5 -5
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/purchase-options.tsx +130 -130
  84. package/src/products/sets.ts +110 -110
  85. package/src/products/variant-matching.ts +71 -71
  86. package/src/products/variant-url.ts +74 -74
  87. package/src/reviews-ui/lightbox-state.ts +46 -46
  88. package/src/reviews-ui/review-header.tsx +166 -166
  89. package/src/reviews-ui/review-lightbox.tsx +271 -271
  90. package/src/reviews-ui/review-list.tsx +193 -193
  91. package/src/reviews-ui/review-widget.tsx +219 -218
  92. package/src/reviews-ui/widget-options.ts +55 -55
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/store/labels.ts +10 -0
  96. package/src/tracking/chatgpt-pixel.tsx +99 -99
  97. package/src/tracking/consent-init.tsx +62 -62
  98. package/src/tracking/events.ts +348 -348
  99. package/src/tracking/ga4.tsx +93 -93
  100. package/src/tracking/google-ads.ts +84 -84
  101. package/src/tracking/gtm.tsx +60 -60
  102. package/src/tracking/inline-script.ts +49 -49
  103. package/src/tracking/oaiq.ts +206 -206
  104. package/src/tracking/tiktok-pixel.tsx +91 -91
  105. package/src/tracking/track-init.tsx +56 -56
  106. package/src/tracking/track-order-purchase.tsx +122 -122
  107. package/src/tracking/ttq.ts +180 -180
  108. package/src/tracking/use-tracking-config.ts +54 -54
  109. package/theme/index.css +25 -25
@@ -1,130 +1,130 @@
1
- "use client"
2
-
3
- /**
4
- * PDP purchase options — one-time vs subscription plans (subscriptions-core
5
- * card, step 4; Cartbase-native, no @1click/ui ancestor). Presentational and
6
- * controlled: the host owns the selection and passes the chosen plan id
7
- * into its add-to-cart seam (`carts.addLineItem` `selling_plan_id`). The
8
- * SERVER prices the line — the preview here is display-only and computed
9
- * from the same plan fields the server applies.
10
- *
11
- * Consent note (docs/storefront/checkout.md): a plan selection later
12
- * requires a logged-in customer at payment (`save_payment_method` →
13
- * 400 `customer_required` for guests) — surface login before checkout when
14
- * a plan line is in the cart.
15
- */
16
- import React from "react"
17
- import type { StoreSellingPlan } from "../api/products"
18
- import { cn } from "../lib/utils"
19
-
20
- export type PurchaseOptionsLabels = {
21
- title: string
22
- oneTime: string
23
- /** Interval nouns, singular/plural: {day: ["day","days"], …} */
24
- every: string
25
- }
26
-
27
- const DEFAULT_LABELS: PurchaseOptionsLabels = {
28
- title: "Purchase options",
29
- oneTime: "One-time purchase",
30
- every: "Every",
31
- }
32
-
33
- /** Display-only mirror of the server's plan pricing (the server is truth). */
34
- export function previewPlanPrice(plan: StoreSellingPlan, basePrice: number): number {
35
- if (plan.pricing_type === "percent" && plan.pricing_value != null) {
36
- return Math.max(0, Math.round(basePrice * (1 - plan.pricing_value / 100) * 100) / 100)
37
- }
38
- if (plan.pricing_type === "fixed" && plan.pricing_value != null) {
39
- return plan.pricing_value
40
- }
41
- return basePrice
42
- }
43
-
44
- export function cadenceLabel(plan: StoreSellingPlan, everyWord = "Every"): string {
45
- const unit =
46
- plan.interval_count === 1 ? plan.interval : `${plan.interval_count} ${plan.interval}s`
47
- return `${everyWord} ${unit}`
48
- }
49
-
50
- type PurchaseOptionsProps = {
51
- plans: StoreSellingPlan[]
52
- /** Selected plan id; null = one-time. */
53
- value: string | null
54
- onChange: (sellingPlanId: string | null) => void
55
- /** The one-time unit price (from the product's calculated price). */
56
- basePrice: number
57
- /** Format an amount for display (host's currency formatting). */
58
- formatPrice: (amount: number) => string
59
- disabled?: boolean
60
- labels?: Partial<PurchaseOptionsLabels>
61
- "data-testid"?: string
62
- }
63
-
64
- export function PurchaseOptions({
65
- plans,
66
- value,
67
- onChange,
68
- basePrice,
69
- formatPrice,
70
- disabled,
71
- labels: labelOverrides,
72
- "data-testid": dataTestId,
73
- }: PurchaseOptionsProps) {
74
- const labels = { ...DEFAULT_LABELS, ...labelOverrides }
75
- if (plans.length === 0) return null
76
-
77
- const options: Array<{
78
- id: string | null
79
- title: string
80
- subtitle: string | null
81
- price: number
82
- }> = [
83
- { id: null, title: labels.oneTime, subtitle: null, price: basePrice },
84
- ...plans.map((plan) => ({
85
- id: plan.id,
86
- title: plan.name,
87
- subtitle: cadenceLabel(plan, labels.every),
88
- price: previewPlanPrice(plan, basePrice),
89
- })),
90
- ]
91
-
92
- return (
93
- <div className="flex flex-col gap-y-3" data-testid={dataTestId}>
94
- <span className="text-sm text-foreground">{labels.title}</span>
95
- <div role="radiogroup" aria-label={labels.title} className="flex flex-col gap-2">
96
- {options.map((opt) => {
97
- const selected = value === opt.id
98
- return (
99
- <button
100
- key={opt.id ?? "one-time"}
101
- type="button"
102
- role="radio"
103
- aria-checked={selected}
104
- onClick={() => onChange(opt.id)}
105
- disabled={disabled}
106
- className={cn(
107
- "border border-border bg-muted rounded-lg p-3 flex items-center justify-between gap-3 text-left",
108
- {
109
- "border-primary": selected,
110
- "hover:shadow-sm transition-shadow ease-in-out duration-150": !selected,
111
- }
112
- )}
113
- data-testid="purchase-option"
114
- >
115
- <span className="flex flex-col">
116
- <span className="text-sm text-foreground">{opt.title}</span>
117
- {opt.subtitle && (
118
- <span className="text-xs text-muted-foreground">{opt.subtitle}</span>
119
- )}
120
- </span>
121
- <span className="text-sm text-foreground whitespace-nowrap">
122
- {formatPrice(opt.price)}
123
- </span>
124
- </button>
125
- )
126
- })}
127
- </div>
128
- </div>
129
- )
130
- }
1
+ "use client"
2
+
3
+ /**
4
+ * PDP purchase options — one-time vs subscription plans (subscriptions-core
5
+ * card, step 4; Cartbase-native, no @1click/ui ancestor). Presentational and
6
+ * controlled: the host owns the selection and passes the chosen plan id
7
+ * into its add-to-cart seam (`carts.addLineItem` `selling_plan_id`). The
8
+ * SERVER prices the line — the preview here is display-only and computed
9
+ * from the same plan fields the server applies.
10
+ *
11
+ * Consent note (docs/storefront/checkout.md): a plan selection later
12
+ * requires a logged-in customer at payment (`save_payment_method` →
13
+ * 400 `customer_required` for guests) — surface login before checkout when
14
+ * a plan line is in the cart.
15
+ */
16
+ import React from "react"
17
+ import type { StoreSellingPlan } from "../api/products"
18
+ import { cn } from "../lib/utils"
19
+
20
+ export type PurchaseOptionsLabels = {
21
+ title: string
22
+ oneTime: string
23
+ /** Interval nouns, singular/plural: {day: ["day","days"], …} */
24
+ every: string
25
+ }
26
+
27
+ const DEFAULT_LABELS: PurchaseOptionsLabels = {
28
+ title: "Purchase options",
29
+ oneTime: "One-time purchase",
30
+ every: "Every",
31
+ }
32
+
33
+ /** Display-only mirror of the server's plan pricing (the server is truth). */
34
+ export function previewPlanPrice(plan: StoreSellingPlan, basePrice: number): number {
35
+ if (plan.pricing_type === "percent" && plan.pricing_value != null) {
36
+ return Math.max(0, Math.round(basePrice * (1 - plan.pricing_value / 100) * 100) / 100)
37
+ }
38
+ if (plan.pricing_type === "fixed" && plan.pricing_value != null) {
39
+ return plan.pricing_value
40
+ }
41
+ return basePrice
42
+ }
43
+
44
+ export function cadenceLabel(plan: StoreSellingPlan, everyWord = "Every"): string {
45
+ const unit =
46
+ plan.interval_count === 1 ? plan.interval : `${plan.interval_count} ${plan.interval}s`
47
+ return `${everyWord} ${unit}`
48
+ }
49
+
50
+ type PurchaseOptionsProps = {
51
+ plans: StoreSellingPlan[]
52
+ /** Selected plan id; null = one-time. */
53
+ value: string | null
54
+ onChange: (sellingPlanId: string | null) => void
55
+ /** The one-time unit price (from the product's calculated price). */
56
+ basePrice: number
57
+ /** Format an amount for display (host's currency formatting). */
58
+ formatPrice: (amount: number) => string
59
+ disabled?: boolean
60
+ labels?: Partial<PurchaseOptionsLabels>
61
+ "data-testid"?: string
62
+ }
63
+
64
+ export function PurchaseOptions({
65
+ plans,
66
+ value,
67
+ onChange,
68
+ basePrice,
69
+ formatPrice,
70
+ disabled,
71
+ labels: labelOverrides,
72
+ "data-testid": dataTestId,
73
+ }: PurchaseOptionsProps) {
74
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides }
75
+ if (plans.length === 0) return null
76
+
77
+ const options: Array<{
78
+ id: string | null
79
+ title: string
80
+ subtitle: string | null
81
+ price: number
82
+ }> = [
83
+ { id: null, title: labels.oneTime, subtitle: null, price: basePrice },
84
+ ...plans.map((plan) => ({
85
+ id: plan.id,
86
+ title: plan.name,
87
+ subtitle: cadenceLabel(plan, labels.every),
88
+ price: previewPlanPrice(plan, basePrice),
89
+ })),
90
+ ]
91
+
92
+ return (
93
+ <div className="flex flex-col gap-y-3" data-testid={dataTestId}>
94
+ <span className="text-sm text-foreground">{labels.title}</span>
95
+ <div role="radiogroup" aria-label={labels.title} className="flex flex-col gap-2">
96
+ {options.map((opt) => {
97
+ const selected = value === opt.id
98
+ return (
99
+ <button
100
+ key={opt.id ?? "one-time"}
101
+ type="button"
102
+ role="radio"
103
+ aria-checked={selected}
104
+ onClick={() => onChange(opt.id)}
105
+ disabled={disabled}
106
+ className={cn(
107
+ "border border-border bg-muted rounded-lg p-3 flex items-center justify-between gap-3 text-left",
108
+ {
109
+ "border-primary": selected,
110
+ "hover:shadow-sm transition-shadow ease-in-out duration-150": !selected,
111
+ }
112
+ )}
113
+ data-testid="purchase-option"
114
+ >
115
+ <span className="flex flex-col">
116
+ <span className="text-sm text-foreground">{opt.title}</span>
117
+ {opt.subtitle && (
118
+ <span className="text-xs text-muted-foreground">{opt.subtitle}</span>
119
+ )}
120
+ </span>
121
+ <span className="text-sm text-foreground whitespace-nowrap">
122
+ {formatPrice(opt.price)}
123
+ </span>
124
+ </button>
125
+ )
126
+ })}
127
+ </div>
128
+ </div>
129
+ )
130
+ }
@@ -1,110 +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
+ /**
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,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
+ }