@cartbase/storefront 0.20.0 → 0.20.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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- 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
|
+
}
|
package/src/products/sets.ts
CHANGED
|
@@ -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
|
+
}
|