@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.
- package/LICENSE +21 -21
- package/package.json +3 -1
- 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/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- 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 +367 -168
- 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/index.ts +73 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- 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 -0
- 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/checkout/use-checkout-orchestration.ts +1604 -1592
- 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/cookie-names.ts +14 -0
- 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/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- 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/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- 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/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- 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
- package/theme/theme.css +150 -150
- 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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
variant: StoreProductVariant
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}, [
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
}
|