@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,778 +1,778 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createContext,
|
|
5
|
-
startTransition,
|
|
6
|
-
useCallback,
|
|
7
|
-
useContext,
|
|
8
|
-
useEffect,
|
|
9
|
-
useOptimistic,
|
|
10
|
-
useRef,
|
|
11
|
-
useState,
|
|
12
|
-
type ReactNode,
|
|
13
|
-
} from "react"
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
addLineItem as sdkAddLineItem,
|
|
17
|
-
createCart as sdkCreateCart,
|
|
18
|
-
deleteLineItem as sdkDeleteLineItem,
|
|
19
|
-
retrieveCart as sdkRetrieveCart,
|
|
20
|
-
updateLineItem as sdkUpdateLineItem,
|
|
21
|
-
type Cart,
|
|
22
|
-
type CartLineItem,
|
|
23
|
-
} from "../api/carts"
|
|
24
|
-
import type { StorefrontClient } from "../api/http"
|
|
25
|
-
import { productItemCount } from "../lib/cart-helpers"
|
|
26
|
-
import { defaultCartDrawerLabels, type CartDrawerLabels } from "./labels"
|
|
27
|
-
import { useLocaleArea } from "../locales/context"
|
|
28
|
-
import { createCartMutationQueue, type CartMutationQueue } from "./mutation-queue"
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Cart drawer context — open state + cart snapshot + labels + link hrefs.
|
|
32
|
-
*
|
|
33
|
-
* Stores wrap their root layout with `<CartDrawerProvider client={…}>`,
|
|
34
|
-
* handing either the server-read cart (`cart`) or the cart id (`cartId`,
|
|
35
|
-
* read in the browser on mount). The provider manages open/close state,
|
|
36
|
-
* auto-opens when the item count rises, locks body scroll while open, and
|
|
37
|
-
* closes on escape.
|
|
38
|
-
*
|
|
39
|
-
* THE INSTANT CART. The cart exposed via `useCartDrawer().cart` is wrapped
|
|
40
|
-
* in React 19's `useOptimistic`: every change (`addItem`, `addLine`,
|
|
41
|
-
* `updateQuantity`, `removeItem`) shows at React update speed and the
|
|
42
|
-
* platform confirms behind it; a failure reverts on its own, because the
|
|
43
|
-
* confirmed snapshot never moved. Every mutation answers with the FULL
|
|
44
|
-
* decorated cart (totals, gift cards: SERVER truth), which becomes the new
|
|
45
|
-
* confirmed snapshot, so no page refresh is needed for the drawer to
|
|
46
|
-
* settle.
|
|
47
|
-
*
|
|
48
|
-
* The changes reach the platform through ONE queue (`mutation-queue.ts`),
|
|
49
|
-
* one call at a time, each reading the cart as the answer before it left
|
|
50
|
-
* it: two answers can never cross and overwrite each other, a second add
|
|
51
|
-
* never creates a second cart, and fast quantity clicks on one line are
|
|
52
|
-
* combined into the number the shopper stopped at. A line added a moment
|
|
53
|
-
* ago carries `pending: true` (`isPendingLine`) and a placeholder id until
|
|
54
|
-
* the platform confirms it; a change made to it before then is held in the
|
|
55
|
-
* queue and sent to the real line.
|
|
56
|
-
*
|
|
57
|
-
* Ported from `@1click/ui/src/cart-drawer/context.tsx` (v2.3.1). Cartbase
|
|
58
|
-
* data seam: the Medusa server actions are replaced by
|
|
59
|
-
* `@cartbase/storefront/api/carts` calls made through a `StorefrontClient`
|
|
60
|
-
* passed to the provider. Amounts are EUR decimal major units.
|
|
61
|
-
*
|
|
62
|
-
* Every failed change flows through `onOptimisticError` with per-action
|
|
63
|
-
* context. Wire it to the store's ops logging; the console.error stays
|
|
64
|
-
* regardless.
|
|
65
|
-
*/
|
|
66
|
-
|
|
67
|
-
type CartDrawerHrefs = {
|
|
68
|
-
/** Href for the checkout button in the sticky footer */
|
|
69
|
-
checkout: string
|
|
70
|
-
/** Href for the "browse products" CTA on the empty state */
|
|
71
|
-
browse: string
|
|
72
|
-
/** Prefix for product links — full href is `${productPrefix}/${handle}` */
|
|
73
|
-
productPrefix: string
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const defaultHrefs: CartDrawerHrefs = {
|
|
77
|
-
checkout: "/checkout",
|
|
78
|
-
browse: "/store",
|
|
79
|
-
productPrefix: "/products",
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Context payload passed to `onOptimisticError` for every failed mutation. */
|
|
83
|
-
export type OptimisticCartError = {
|
|
84
|
-
actionType: OptimisticCartAction["type"]
|
|
85
|
-
error: unknown
|
|
86
|
-
/** Structured per-action fields (quantity, line_id, variant/product ids). */
|
|
87
|
-
context: Record<string, unknown>
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Display fields an "add" action carries so the drawer can render the row
|
|
92
|
-
* immediately, before the server confirms.
|
|
93
|
-
*/
|
|
94
|
-
export type OptimisticAddDisplay = {
|
|
95
|
-
product_id?: string
|
|
96
|
-
product_title?: string
|
|
97
|
-
product_handle?: string
|
|
98
|
-
thumbnail?: string | null
|
|
99
|
-
variant_title?: string | null
|
|
100
|
-
/** Per-unit price in EUR major units. total = price × qty. */
|
|
101
|
-
unit_price?: number
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Optimistic action shapes — what callers can dispatch to update the
|
|
106
|
-
* cart UI ahead of the server confirmation.
|
|
107
|
-
*/
|
|
108
|
-
export type OptimisticCartAction =
|
|
109
|
-
| ({
|
|
110
|
-
type: "add"
|
|
111
|
-
/** Variant being added. We use this to merge with existing lines. */
|
|
112
|
-
variant_id: string
|
|
113
|
-
quantity: number
|
|
114
|
-
} & OptimisticAddDisplay)
|
|
115
|
-
| {
|
|
116
|
-
type: "remove"
|
|
117
|
-
lineId: string
|
|
118
|
-
}
|
|
119
|
-
| {
|
|
120
|
-
type: "update_quantity"
|
|
121
|
-
lineId: string
|
|
122
|
-
quantity: number
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** What an add answers: the confirmed cart, or why it was refused. */
|
|
126
|
-
export type CartMutationResult =
|
|
127
|
-
| { ok: true; cart: Cart }
|
|
128
|
-
| {
|
|
129
|
-
ok: false
|
|
130
|
-
/** The platform's refusal (a `StoreApiError`: `code` names it, e.g. `insufficient_inventory`). */
|
|
131
|
-
error: unknown
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The id prefix of a line the platform has not confirmed yet. */
|
|
135
|
-
const PENDING_PREFIX = "optimistic-"
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* A cart that can still be shopped. A completed cart is an order now: the
|
|
139
|
-
* platform still answers it, lines and all, but refuses every change to it.
|
|
140
|
-
*/
|
|
141
|
-
export function isLiveCart(cart: Cart | null | undefined): cart is Cart {
|
|
142
|
-
return Boolean(cart && !cart.completed_at)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** The platform's answer that the cart a change was aimed at is over (ordered, or gone). */
|
|
146
|
-
export function isEndedCartError(err: unknown): boolean {
|
|
147
|
-
const code = (err as { code?: unknown } | null)?.code
|
|
148
|
-
return code === "cart_completed" || code === "cart_not_found"
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A line the platform has not confirmed yet: it was added a moment ago and
|
|
153
|
-
* carries a placeholder id. Its quantity and removal work already (the
|
|
154
|
-
* provider holds them until the line exists); anything that needs the real
|
|
155
|
-
* line (a server read keyed by the line id) should wait until this is false.
|
|
156
|
-
*/
|
|
157
|
-
export function isPendingLine(line: Pick<CartLineItem, "id">): boolean {
|
|
158
|
-
return (line as { pending?: unknown }).pending === true || line.id.startsWith(PENDING_PREFIX)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The variant a placeholder line id stands for; null for a real line id. */
|
|
162
|
-
function pendingVariant(lineId: string): string | null {
|
|
163
|
-
return lineId.startsWith(PENDING_PREFIX) ? lineId.slice(PENDING_PREFIX.length) : null
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
type CartDrawerContextValue = {
|
|
167
|
-
isOpen: boolean
|
|
168
|
-
open: () => void
|
|
169
|
-
close: () => void
|
|
170
|
-
toggle: () => void
|
|
171
|
-
/** Cart snapshot AFTER any pending optimistic updates. */
|
|
172
|
-
cart: Cart | null
|
|
173
|
-
/**
|
|
174
|
-
* Dispatch an optimistic update. MUST be called inside a
|
|
175
|
-
* `startTransition` (React requirement for useOptimistic setters
|
|
176
|
-
* outside Action props). Prefer `applyOptimistic`.
|
|
177
|
-
*/
|
|
178
|
-
dispatchOptimistic: (action: OptimisticCartAction) => void
|
|
179
|
-
/**
|
|
180
|
-
* Dispatches the optimistic action AND awaits the provided server
|
|
181
|
-
* mutation inside a single transition. For a store that owns the server
|
|
182
|
-
* call itself (a Next.js server action); the built-in methods below cover
|
|
183
|
-
* the SDK-wired path and are the fast one.
|
|
184
|
-
*/
|
|
185
|
-
applyOptimistic: (
|
|
186
|
-
action: OptimisticCartAction,
|
|
187
|
-
serverAction: () => Promise<unknown>
|
|
188
|
-
) => void
|
|
189
|
-
/**
|
|
190
|
-
* Add a variant: the line shows at once, the platform confirms behind it.
|
|
191
|
-
* Answers with the confirmed cart or the refusal (require the provider's
|
|
192
|
-
* `client` prop).
|
|
193
|
-
*/
|
|
194
|
-
addLine: (
|
|
195
|
-
variantId: string,
|
|
196
|
-
quantity?: number,
|
|
197
|
-
display?: OptimisticAddDisplay
|
|
198
|
-
) => Promise<CartMutationResult>
|
|
199
|
-
/** `addLine`, answering only whether it was confirmed. */
|
|
200
|
-
addItem: (
|
|
201
|
-
variantId: string,
|
|
202
|
-
quantity?: number,
|
|
203
|
-
display?: OptimisticAddDisplay
|
|
204
|
-
) => Promise<boolean>
|
|
205
|
-
/**
|
|
206
|
-
* Set a line's quantity (0 deletes, the Cartbase contract). Every click
|
|
207
|
-
* shows at once; fast clicks on one line send the last number.
|
|
208
|
-
*/
|
|
209
|
-
updateQuantity: (lineId: string, quantity: number) => Promise<boolean>
|
|
210
|
-
removeItem: (lineId: string) => Promise<boolean>
|
|
211
|
-
/** Re-read the decorated cart (totals/gift-card tender are live per read). */
|
|
212
|
-
refresh: () => Promise<void>
|
|
213
|
-
/**
|
|
214
|
-
* The cart is over (the order was placed): the drawer empties and the
|
|
215
|
-
* store's `onCartEnd` clears its cookie. The checkout calls it; the next
|
|
216
|
-
* add starts a new cart.
|
|
217
|
-
*/
|
|
218
|
-
forget: () => Promise<void>
|
|
219
|
-
/** True when the provider was given a client, so the methods above reach the platform. */
|
|
220
|
-
canMutate: boolean
|
|
221
|
-
labels: CartDrawerLabels
|
|
222
|
-
hrefs: CartDrawerHrefs
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const CartDrawerContext = createContext<CartDrawerContextValue>({
|
|
226
|
-
isOpen: false,
|
|
227
|
-
open: () => {},
|
|
228
|
-
close: () => {},
|
|
229
|
-
toggle: () => {},
|
|
230
|
-
cart: null,
|
|
231
|
-
dispatchOptimistic: () => {},
|
|
232
|
-
applyOptimistic: () => {},
|
|
233
|
-
addLine: async () => ({ ok: false, error: new Error("No CartDrawerProvider with a client is mounted") }),
|
|
234
|
-
addItem: async () => false,
|
|
235
|
-
updateQuantity: async () => false,
|
|
236
|
-
removeItem: async () => false,
|
|
237
|
-
refresh: async () => {},
|
|
238
|
-
forget: async () => {},
|
|
239
|
-
canMutate: false,
|
|
240
|
-
labels: defaultCartDrawerLabels,
|
|
241
|
-
hrefs: defaultHrefs,
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
export function useCartDrawer(): CartDrawerContextValue {
|
|
245
|
-
return useContext(CartDrawerContext)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Reducer for the useOptimistic cart. Pure function. Returns the next
|
|
250
|
-
* cart shape after applying the action. Falls back to a synthetic empty
|
|
251
|
-
* cart when there's no current cart yet so optimistic adds work even on
|
|
252
|
-
* first-add (the confirmed cart replaces it).
|
|
253
|
-
*
|
|
254
|
-
* A change aimed at a placeholder line also finds the real line of the
|
|
255
|
-
* same variant: the confirmed cart can arrive while the change is still
|
|
256
|
-
* pending, and the line must not come back or lose its new number.
|
|
257
|
-
*
|
|
258
|
-
* Exported for unit tests (pure logic, no React).
|
|
259
|
-
*/
|
|
260
|
-
export function reduceCart(
|
|
261
|
-
current: Cart | null,
|
|
262
|
-
action: OptimisticCartAction
|
|
263
|
-
): Cart | null {
|
|
264
|
-
if (!current) {
|
|
265
|
-
if (action.type !== "add") return current
|
|
266
|
-
return {
|
|
267
|
-
id: "optimistic-cart",
|
|
268
|
-
items: [makeOptimisticLine(action)],
|
|
269
|
-
} as unknown as Cart
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
switch (action.type) {
|
|
273
|
-
case "add": {
|
|
274
|
-
const items = current.items ?? []
|
|
275
|
-
const existing = items.find((i) => i.variant_id === action.variant_id)
|
|
276
|
-
if (existing) {
|
|
277
|
-
return {
|
|
278
|
-
...current,
|
|
279
|
-
items: items.map((i) =>
|
|
280
|
-
i.variant_id === action.variant_id
|
|
281
|
-
? {
|
|
282
|
-
...i,
|
|
283
|
-
quantity: i.quantity + action.quantity,
|
|
284
|
-
total:
|
|
285
|
-
(i.unit_price ?? 0) * (i.quantity + action.quantity),
|
|
286
|
-
}
|
|
287
|
-
: i
|
|
288
|
-
),
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return {
|
|
292
|
-
...current,
|
|
293
|
-
items: [...items, makeOptimisticLine(action)],
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
case "remove": {
|
|
297
|
-
const variant = pendingVariant(action.lineId)
|
|
298
|
-
return {
|
|
299
|
-
...current,
|
|
300
|
-
items: (current.items ?? []).filter(
|
|
301
|
-
(i) => i.id !== action.lineId && !(variant && i.variant_id === variant)
|
|
302
|
-
),
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
case "update_quantity": {
|
|
306
|
-
const variant = pendingVariant(action.lineId)
|
|
307
|
-
return {
|
|
308
|
-
...current,
|
|
309
|
-
items: (current.items ?? []).map((i) =>
|
|
310
|
-
i.id === action.lineId || (variant && i.variant_id === variant)
|
|
311
|
-
? {
|
|
312
|
-
...i,
|
|
313
|
-
quantity: action.quantity,
|
|
314
|
-
total: (i.unit_price ?? 0) * action.quantity,
|
|
315
|
-
}
|
|
316
|
-
: i
|
|
317
|
-
),
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Build a synthetic line item from an "add" action: a placeholder id and
|
|
325
|
-
* `pending: true` (see `isPendingLine`). The confirmed cart replaces it.
|
|
326
|
-
* Cast through `unknown` because `CartLineItem` has many fields we don't
|
|
327
|
-
* have at click time (created_at, tax_lines, adjustments…) — the renderer
|
|
328
|
-
* is lenient about missing optional fields.
|
|
329
|
-
*/
|
|
330
|
-
export function makeOptimisticLine(
|
|
331
|
-
action: Extract<OptimisticCartAction, { type: "add" }>
|
|
332
|
-
): CartLineItem {
|
|
333
|
-
const unitPrice = action.unit_price ?? 0
|
|
334
|
-
return {
|
|
335
|
-
id: `${PENDING_PREFIX}${action.variant_id}`,
|
|
336
|
-
pending: true,
|
|
337
|
-
variant_id: action.variant_id,
|
|
338
|
-
product_id: action.product_id,
|
|
339
|
-
product_title: action.product_title,
|
|
340
|
-
product_handle: action.product_handle,
|
|
341
|
-
thumbnail: action.thumbnail,
|
|
342
|
-
variant_title: action.variant_title,
|
|
343
|
-
quantity: action.quantity,
|
|
344
|
-
unit_price: unitPrice,
|
|
345
|
-
total: unitPrice * action.quantity,
|
|
346
|
-
original_total: unitPrice * action.quantity,
|
|
347
|
-
} as unknown as CartLineItem
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** The per-action fields every failure report carries. */
|
|
351
|
-
function failureContext(action: OptimisticCartAction, err: unknown): Record<string, unknown> {
|
|
352
|
-
const err_ = err as { message?: string; name?: string; code?: string } | null
|
|
353
|
-
const ctx: Record<string, unknown> = { action_type: action.type }
|
|
354
|
-
if (action.type === "add") {
|
|
355
|
-
ctx.quantity = action.quantity
|
|
356
|
-
ctx.product_id = action.product_id
|
|
357
|
-
ctx.variant_id = action.variant_id
|
|
358
|
-
} else if (action.type === "remove") {
|
|
359
|
-
ctx.line_id = action.lineId
|
|
360
|
-
} else {
|
|
361
|
-
ctx.line_id = action.lineId
|
|
362
|
-
ctx.quantity = action.quantity
|
|
363
|
-
}
|
|
364
|
-
ctx.err_name = err_?.name
|
|
365
|
-
ctx.err_code = err_?.code
|
|
366
|
-
ctx.err_message = err_?.message ?? String(err)
|
|
367
|
-
return ctx
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export function CartDrawerProvider({
|
|
371
|
-
cart,
|
|
372
|
-
client,
|
|
373
|
-
cartId,
|
|
374
|
-
onCartChange,
|
|
375
|
-
onCartEnd,
|
|
376
|
-
onOptimisticError,
|
|
377
|
-
labels: labelOverrides,
|
|
378
|
-
hrefs: hrefOverrides,
|
|
379
|
-
children,
|
|
380
|
-
}: {
|
|
381
|
-
/**
|
|
382
|
-
* Server-fetched cart snapshot (RSC/server-action pattern). When the
|
|
383
|
-
* prop changes (e.g. after a router refresh) it becomes the confirmed
|
|
384
|
-
* snapshot, unless a change of the provider's own is still on its way.
|
|
385
|
-
* Leave it out and pass `client` + `cartId` to read the cart in the
|
|
386
|
-
* browser instead, so the page never waits for it.
|
|
387
|
-
*/
|
|
388
|
-
cart?: Cart | null
|
|
389
|
-
/** StorefrontClient for the SDK-wired mutations (addLine/addItem/updateQuantity/removeItem/refresh). */
|
|
390
|
-
client?: StorefrontClient
|
|
391
|
-
/** Known cart id to retrieve when no `cart` snapshot is passed. */
|
|
392
|
-
cartId?: string | null
|
|
393
|
-
/**
|
|
394
|
-
* Fires with the decorated cart after every server-confirmed change,
|
|
395
|
-
* including first-add cart creation — persist `cart.id` (cookie/storage)
|
|
396
|
-
* here so the cart survives reloads.
|
|
397
|
-
*/
|
|
398
|
-
onCartChange?: (cart: Cart) => void
|
|
399
|
-
/**
|
|
400
|
-
* Fires when the cart is over: the order was placed, or the platform
|
|
401
|
-
* answered that the stored cart is completed or gone. Clear the stored
|
|
402
|
-
* cart id (the cookie) here; the next add creates a new cart and
|
|
403
|
-
* `onCartChange` stores its id.
|
|
404
|
-
*/
|
|
405
|
-
onCartEnd?: () => void
|
|
406
|
-
/** Ops funnel for every failed optimistic mutation (see module JSDoc). */
|
|
407
|
-
onOptimisticError?: (failure: OptimisticCartError) => void
|
|
408
|
-
/**
|
|
409
|
-
* Overrides. The default is the language mounted by
|
|
410
|
-
* `StorefrontLocaleProvider`, English when none is mounted, so a store
|
|
411
|
-
* that mounts its pack has nothing to hand over here (2026-09-14).
|
|
412
|
-
*/
|
|
413
|
-
labels?: Partial<CartDrawerLabels>
|
|
414
|
-
hrefs?: Partial<CartDrawerHrefs>
|
|
415
|
-
children: ReactNode
|
|
416
|
-
}) {
|
|
417
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
418
|
-
const prevItemCount = useRef(0)
|
|
419
|
-
|
|
420
|
-
// The queue every SDK change goes through, and the latest confirmed cart
|
|
421
|
-
// it reads when a change's turn comes (state lags a render behind).
|
|
422
|
-
const queue = useRef<CartMutationQueue | null>(null)
|
|
423
|
-
if (!queue.current) queue.current = createCartMutationQueue()
|
|
424
|
-
// A completed cart is an order: the drawer never holds one.
|
|
425
|
-
const latest = useRef<Cart | null>(isLiveCart(cart) ? cart : null)
|
|
426
|
-
|
|
427
|
-
// The store's hooks, read through refs so an inline handler never re-runs an effect.
|
|
428
|
-
const cartEnded = useRef(onCartEnd)
|
|
429
|
-
cartEnded.current = onCartEnd
|
|
430
|
-
|
|
431
|
-
// Confirmed snapshot: seeded from the prop, replaced by every SDK
|
|
432
|
-
// mutation response and by prop updates from server refreshes.
|
|
433
|
-
const [serverCart, setServerCart] = useState<Cart | null>(latest.current)
|
|
434
|
-
|
|
435
|
-
/** The cart is over: the drawer empties and the store forgets the id. */
|
|
436
|
-
const end = useCallback(() => {
|
|
437
|
-
latest.current = null
|
|
438
|
-
startTransition(() => setServerCart(null))
|
|
439
|
-
try {
|
|
440
|
-
cartEnded.current?.()
|
|
441
|
-
} catch {
|
|
442
|
-
// A store's handler must never break the drawer.
|
|
443
|
-
}
|
|
444
|
-
}, [])
|
|
445
|
-
|
|
446
|
-
useEffect(() => {
|
|
447
|
-
if (cart === undefined) return
|
|
448
|
-
// A refresh carrying the cart as it was must not overwrite an answer
|
|
449
|
-
// still on its way; the queue's own answers are newer.
|
|
450
|
-
if (queue.current?.busy()) return
|
|
451
|
-
if (cart && !isLiveCart(cart)) {
|
|
452
|
-
end()
|
|
453
|
-
return
|
|
454
|
-
}
|
|
455
|
-
latest.current = cart
|
|
456
|
-
setServerCart(cart)
|
|
457
|
-
}, [cart, end])
|
|
458
|
-
|
|
459
|
-
// A confirmed cart becomes the snapshot inside a transition, so it lands
|
|
460
|
-
// together with the end of the change that asked for it and a pending
|
|
461
|
-
// line is never shown twice. A completed one ends the cart instead.
|
|
462
|
-
const adopt = useCallback(
|
|
463
|
-
(next: Cart) => {
|
|
464
|
-
if (!isLiveCart(next)) {
|
|
465
|
-
end()
|
|
466
|
-
return
|
|
467
|
-
}
|
|
468
|
-
latest.current = next
|
|
469
|
-
startTransition(() => setServerCart(next))
|
|
470
|
-
},
|
|
471
|
-
[end]
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
// Client+cartId mode: read the cart on mount, through the queue, so an
|
|
475
|
-
// add made before the read answers goes to this cart, not a new one.
|
|
476
|
-
useEffect(() => {
|
|
477
|
-
if (cart !== undefined || !client || !cartId) return
|
|
478
|
-
let cancelled = false
|
|
479
|
-
void queue.current!.run(async () => {
|
|
480
|
-
try {
|
|
481
|
-
const res = await sdkRetrieveCart(client, cartId)
|
|
482
|
-
if (!cancelled) adopt(res.cart)
|
|
483
|
-
} catch (err) {
|
|
484
|
-
// A stored id the platform no longer knows: forget it. Any other
|
|
485
|
-
// failure (a network blip) keeps the id for the next visit.
|
|
486
|
-
if (!cancelled && isEndedCartError(err)) end()
|
|
487
|
-
}
|
|
488
|
-
})
|
|
489
|
-
return () => {
|
|
490
|
-
cancelled = true
|
|
491
|
-
}
|
|
492
|
-
}, [cart, client, cartId, adopt, end])
|
|
493
|
-
|
|
494
|
-
const [optimisticCart, dispatchOptimistic] = useOptimistic(
|
|
495
|
-
serverCart,
|
|
496
|
-
reduceCart
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
// totalItems counts PRODUCT lines only. Backend-injected fee lines
|
|
500
|
-
// (e.g. the COD fee added by the payment-session flow) are excluded
|
|
501
|
-
// — otherwise the auto-open useEffect below treats a fee insertion
|
|
502
|
-
// as "a new item was added," opens the drawer mid-checkout, and
|
|
503
|
-
// locks body scroll. See @cartbase/storefront/lib/cart-helpers for the
|
|
504
|
-
// shared predicate.
|
|
505
|
-
const totalItems = productItemCount(optimisticCart?.items)
|
|
506
|
-
|
|
507
|
-
// Auto-open when items are added (count increases AFTER initial load)
|
|
508
|
-
useEffect(() => {
|
|
509
|
-
if (totalItems > prevItemCount.current && prevItemCount.current > 0) {
|
|
510
|
-
setIsOpen(true)
|
|
511
|
-
}
|
|
512
|
-
prevItemCount.current = totalItems
|
|
513
|
-
}, [totalItems])
|
|
514
|
-
|
|
515
|
-
// Lock body scroll while open
|
|
516
|
-
useEffect(() => {
|
|
517
|
-
if (isOpen) {
|
|
518
|
-
document.body.style.overflow = "hidden"
|
|
519
|
-
} else {
|
|
520
|
-
document.body.style.overflow = ""
|
|
521
|
-
}
|
|
522
|
-
return () => {
|
|
523
|
-
document.body.style.overflow = ""
|
|
524
|
-
}
|
|
525
|
-
}, [isOpen])
|
|
526
|
-
|
|
527
|
-
// Escape closes
|
|
528
|
-
useEffect(() => {
|
|
529
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
530
|
-
if (e.key === "Escape" && isOpen) setIsOpen(false)
|
|
531
|
-
}
|
|
532
|
-
document.addEventListener("keydown", handleEscape)
|
|
533
|
-
return () => document.removeEventListener("keydown", handleEscape)
|
|
534
|
-
}, [isOpen])
|
|
535
|
-
|
|
536
|
-
const open = useCallback(() => setIsOpen(true), [])
|
|
537
|
-
const close = useCallback(() => setIsOpen(false), [])
|
|
538
|
-
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
|
539
|
-
|
|
540
|
-
/** The single funnel for every failed change: console + `onOptimisticError`. */
|
|
541
|
-
const report = useCallback(
|
|
542
|
-
(action: OptimisticCartAction, err: unknown) => {
|
|
543
|
-
// eslint-disable-next-line no-console
|
|
544
|
-
console.error("[cart-optimistic]", action.type, err)
|
|
545
|
-
try {
|
|
546
|
-
onOptimisticError?.({ actionType: action.type, error: err, context: failureContext(action, err) })
|
|
547
|
-
} catch {
|
|
548
|
-
// The ops funnel itself must never break the UI.
|
|
549
|
-
}
|
|
550
|
-
},
|
|
551
|
-
[onOptimisticError]
|
|
552
|
-
)
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* Show an action for as long as its work runs. useOptimistic setters
|
|
556
|
-
* MUST be called inside a transition; the transition stays pending for
|
|
557
|
-
* the whole round-trip and the action falls away when it ends, by which
|
|
558
|
-
* time the confirmed cart carries it (or, on failure, it simply reverts).
|
|
559
|
-
* `work` settles without throwing; its answer is passed through.
|
|
560
|
-
*/
|
|
561
|
-
const showWhile = useCallback(
|
|
562
|
-
<T,>(action: OptimisticCartAction, work: Promise<T>): Promise<T> =>
|
|
563
|
-
new Promise<T>((resolve) => {
|
|
564
|
-
startTransition(async () => {
|
|
565
|
-
dispatchOptimistic(action)
|
|
566
|
-
resolve(await work)
|
|
567
|
-
})
|
|
568
|
-
}),
|
|
569
|
-
[dispatchOptimistic]
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
/** The store-owned path: its own server call, reverted on throw. */
|
|
573
|
-
const applyOptimistic = useCallback(
|
|
574
|
-
(action: OptimisticCartAction, serverAction: () => Promise<unknown>) => {
|
|
575
|
-
void showWhile(
|
|
576
|
-
action,
|
|
577
|
-
serverAction().then(
|
|
578
|
-
() => true,
|
|
579
|
-
(err) => {
|
|
580
|
-
report(action, err)
|
|
581
|
-
return false
|
|
582
|
-
}
|
|
583
|
-
)
|
|
584
|
-
)
|
|
585
|
-
},
|
|
586
|
-
[showWhile, report]
|
|
587
|
-
)
|
|
588
|
-
|
|
589
|
-
/** Adopt a server-confirmed decorated cart and tell the store. */
|
|
590
|
-
const confirm = useCallback(
|
|
591
|
-
(next: Cart) => {
|
|
592
|
-
adopt(next)
|
|
593
|
-
// A completed cart has ended the cart; its id is never stored again.
|
|
594
|
-
if (isLiveCart(next)) onCartChange?.(next)
|
|
595
|
-
},
|
|
596
|
-
[adopt, onCartChange]
|
|
597
|
-
)
|
|
598
|
-
|
|
599
|
-
/** The line a change is for, as the latest confirmed cart knows it; null when it is gone. */
|
|
600
|
-
const lineNow = useCallback((lineId: string): string | null => {
|
|
601
|
-
const items = latest.current?.items ?? []
|
|
602
|
-
const variant = pendingVariant(lineId)
|
|
603
|
-
if (variant) return items.find((i) => i.variant_id === variant)?.id ?? null
|
|
604
|
-
return items.some((i) => i.id === lineId) ? lineId : null
|
|
605
|
-
}, [])
|
|
606
|
-
|
|
607
|
-
const addLine = useCallback(
|
|
608
|
-
(
|
|
609
|
-
variantId: string,
|
|
610
|
-
quantity = 1,
|
|
611
|
-
display: OptimisticAddDisplay = {}
|
|
612
|
-
): Promise<CartMutationResult> => {
|
|
613
|
-
if (!client) {
|
|
614
|
-
return Promise.resolve({ ok: false, error: new Error("CartDrawerProvider has no client") })
|
|
615
|
-
}
|
|
616
|
-
const action: OptimisticCartAction = {
|
|
617
|
-
type: "add",
|
|
618
|
-
variant_id: variantId,
|
|
619
|
-
quantity,
|
|
620
|
-
...display,
|
|
621
|
-
}
|
|
622
|
-
const work = queue.current!.run(async (): Promise<CartMutationResult> => {
|
|
623
|
-
// First add with no cart: create one with the item in a single
|
|
624
|
-
// call. region_id falls back to the store default server-side.
|
|
625
|
-
const create = () => sdkCreateCart(client, { items: [{ variant_id: variantId, quantity }] })
|
|
626
|
-
try {
|
|
627
|
-
const cartId = latest.current?.id
|
|
628
|
-
let res
|
|
629
|
-
if (!cartId) {
|
|
630
|
-
res = await create()
|
|
631
|
-
} else {
|
|
632
|
-
try {
|
|
633
|
-
res = await sdkAddLineItem(client, cartId, { variant_id: variantId, quantity })
|
|
634
|
-
} catch (error) {
|
|
635
|
-
// The stored cart is over (ordered in another tab, or gone):
|
|
636
|
-
// Medusa's starter treats it as no cart, so a new one starts
|
|
637
|
-
// with this item.
|
|
638
|
-
if (!isEndedCartError(error)) throw error
|
|
639
|
-
end()
|
|
640
|
-
res = await create()
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
confirm(res.cart)
|
|
644
|
-
return { ok: true, cart: res.cart }
|
|
645
|
-
} catch (error) {
|
|
646
|
-
report(action, error)
|
|
647
|
-
return { ok: false, error }
|
|
648
|
-
}
|
|
649
|
-
})
|
|
650
|
-
return showWhile(action, work)
|
|
651
|
-
},
|
|
652
|
-
[client, confirm, report, showWhile, end]
|
|
653
|
-
)
|
|
654
|
-
|
|
655
|
-
const addItem = useCallback(
|
|
656
|
-
(variantId: string, quantity = 1, display: OptimisticAddDisplay = {}): Promise<boolean> =>
|
|
657
|
-
addLine(variantId, quantity, display).then((result) => result.ok),
|
|
658
|
-
[addLine]
|
|
659
|
-
)
|
|
660
|
-
|
|
661
|
-
const updateQuantity = useCallback(
|
|
662
|
-
(lineId: string, quantity: number): Promise<boolean> => {
|
|
663
|
-
if (!client) return Promise.resolve(false)
|
|
664
|
-
const work = queue.current!.quantity(lineId, quantity, async (latestQuantity) => {
|
|
665
|
-
const id = lineNow(lineId)
|
|
666
|
-
const cartId = latest.current?.id
|
|
667
|
-
if (!id || !cartId) return false
|
|
668
|
-
try {
|
|
669
|
-
// Cartbase contract: body is `{quantity}` only; 0 deletes the line.
|
|
670
|
-
const res = await sdkUpdateLineItem(client, cartId, id, { quantity: latestQuantity })
|
|
671
|
-
confirm(res.cart)
|
|
672
|
-
return true
|
|
673
|
-
} catch (err) {
|
|
674
|
-
if (isEndedCartError(err)) end()
|
|
675
|
-
else report({ type: "update_quantity", lineId, quantity: latestQuantity }, err)
|
|
676
|
-
return false
|
|
677
|
-
}
|
|
678
|
-
})
|
|
679
|
-
return showWhile(
|
|
680
|
-
{ type: "update_quantity", lineId, quantity },
|
|
681
|
-
work.then((sent) => sent === true)
|
|
682
|
-
)
|
|
683
|
-
},
|
|
684
|
-
[client, lineNow, confirm, report, showWhile, end]
|
|
685
|
-
)
|
|
686
|
-
|
|
687
|
-
const removeItem = useCallback(
|
|
688
|
-
(lineId: string): Promise<boolean> => {
|
|
689
|
-
if (!client) return Promise.resolve(false)
|
|
690
|
-
const action: OptimisticCartAction = { type: "remove", lineId }
|
|
691
|
-
// A quantity change still waiting for this line has nothing to change.
|
|
692
|
-
queue.current!.forget(lineId)
|
|
693
|
-
const work = queue.current!.run(async () => {
|
|
694
|
-
const id = lineNow(lineId)
|
|
695
|
-
const cartId = latest.current?.id
|
|
696
|
-
// Already gone (or never confirmed): nothing to delete.
|
|
697
|
-
if (!id || !cartId) return true
|
|
698
|
-
try {
|
|
699
|
-
const res = await sdkDeleteLineItem(client, cartId, id)
|
|
700
|
-
confirm(res.cart)
|
|
701
|
-
return true
|
|
702
|
-
} catch (err) {
|
|
703
|
-
if (isEndedCartError(err)) {
|
|
704
|
-
end()
|
|
705
|
-
return true
|
|
706
|
-
}
|
|
707
|
-
report(action, err)
|
|
708
|
-
return false
|
|
709
|
-
}
|
|
710
|
-
})
|
|
711
|
-
return showWhile(action, work)
|
|
712
|
-
},
|
|
713
|
-
[client, lineNow, confirm, report, showWhile, end]
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
const refresh = useCallback(
|
|
717
|
-
(): Promise<void> => {
|
|
718
|
-
if (!client) return Promise.resolve()
|
|
719
|
-
return queue.current!.run(async () => {
|
|
720
|
-
const id = latest.current?.id
|
|
721
|
-
if (!id) return
|
|
722
|
-
try {
|
|
723
|
-
const res = await sdkRetrieveCart(client, id)
|
|
724
|
-
confirm(res.cart)
|
|
725
|
-
} catch (err) {
|
|
726
|
-
// A cart the platform no longer knows ends; a transient failure
|
|
727
|
-
// keeps the last snapshot.
|
|
728
|
-
if (isEndedCartError(err)) end()
|
|
729
|
-
}
|
|
730
|
-
})
|
|
731
|
-
},
|
|
732
|
-
[client, confirm, end]
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
/** The order was placed: empty the drawer and let the store forget the id. */
|
|
736
|
-
const forget = useCallback(
|
|
737
|
-
(): Promise<void> => queue.current!.run(async () => end()),
|
|
738
|
-
[end]
|
|
739
|
-
)
|
|
740
|
-
|
|
741
|
-
// The mounted language is the default (2026-09-14): a store that mounts
|
|
742
|
-
// StorefrontLocaleProvider has nothing to hand over here, and a `labels`
|
|
743
|
-
// prop overrides only the keys it names.
|
|
744
|
-
const inherited = useLocaleArea("cartDrawer", defaultCartDrawerLabels)
|
|
745
|
-
const labels: CartDrawerLabels = {
|
|
746
|
-
...inherited,
|
|
747
|
-
...labelOverrides,
|
|
748
|
-
}
|
|
749
|
-
const hrefs: CartDrawerHrefs = {
|
|
750
|
-
...defaultHrefs,
|
|
751
|
-
...hrefOverrides,
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
return (
|
|
755
|
-
<CartDrawerContext.Provider
|
|
756
|
-
value={{
|
|
757
|
-
isOpen,
|
|
758
|
-
open,
|
|
759
|
-
close,
|
|
760
|
-
toggle,
|
|
761
|
-
cart: optimisticCart,
|
|
762
|
-
dispatchOptimistic,
|
|
763
|
-
applyOptimistic,
|
|
764
|
-
addLine,
|
|
765
|
-
addItem,
|
|
766
|
-
updateQuantity,
|
|
767
|
-
removeItem,
|
|
768
|
-
refresh,
|
|
769
|
-
forget,
|
|
770
|
-
canMutate: Boolean(client),
|
|
771
|
-
labels,
|
|
772
|
-
hrefs,
|
|
773
|
-
}}
|
|
774
|
-
>
|
|
775
|
-
{children}
|
|
776
|
-
</CartDrawerContext.Provider>
|
|
777
|
-
)
|
|
778
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
startTransition,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useOptimistic,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from "react"
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
addLineItem as sdkAddLineItem,
|
|
17
|
+
createCart as sdkCreateCart,
|
|
18
|
+
deleteLineItem as sdkDeleteLineItem,
|
|
19
|
+
retrieveCart as sdkRetrieveCart,
|
|
20
|
+
updateLineItem as sdkUpdateLineItem,
|
|
21
|
+
type Cart,
|
|
22
|
+
type CartLineItem,
|
|
23
|
+
} from "../api/carts"
|
|
24
|
+
import type { StorefrontClient } from "../api/http"
|
|
25
|
+
import { productItemCount } from "../lib/cart-helpers"
|
|
26
|
+
import { defaultCartDrawerLabels, type CartDrawerLabels } from "./labels"
|
|
27
|
+
import { useLocaleArea } from "../locales/context"
|
|
28
|
+
import { createCartMutationQueue, type CartMutationQueue } from "./mutation-queue"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cart drawer context — open state + cart snapshot + labels + link hrefs.
|
|
32
|
+
*
|
|
33
|
+
* Stores wrap their root layout with `<CartDrawerProvider client={…}>`,
|
|
34
|
+
* handing either the server-read cart (`cart`) or the cart id (`cartId`,
|
|
35
|
+
* read in the browser on mount). The provider manages open/close state,
|
|
36
|
+
* auto-opens when the item count rises, locks body scroll while open, and
|
|
37
|
+
* closes on escape.
|
|
38
|
+
*
|
|
39
|
+
* THE INSTANT CART. The cart exposed via `useCartDrawer().cart` is wrapped
|
|
40
|
+
* in React 19's `useOptimistic`: every change (`addItem`, `addLine`,
|
|
41
|
+
* `updateQuantity`, `removeItem`) shows at React update speed and the
|
|
42
|
+
* platform confirms behind it; a failure reverts on its own, because the
|
|
43
|
+
* confirmed snapshot never moved. Every mutation answers with the FULL
|
|
44
|
+
* decorated cart (totals, gift cards: SERVER truth), which becomes the new
|
|
45
|
+
* confirmed snapshot, so no page refresh is needed for the drawer to
|
|
46
|
+
* settle.
|
|
47
|
+
*
|
|
48
|
+
* The changes reach the platform through ONE queue (`mutation-queue.ts`),
|
|
49
|
+
* one call at a time, each reading the cart as the answer before it left
|
|
50
|
+
* it: two answers can never cross and overwrite each other, a second add
|
|
51
|
+
* never creates a second cart, and fast quantity clicks on one line are
|
|
52
|
+
* combined into the number the shopper stopped at. A line added a moment
|
|
53
|
+
* ago carries `pending: true` (`isPendingLine`) and a placeholder id until
|
|
54
|
+
* the platform confirms it; a change made to it before then is held in the
|
|
55
|
+
* queue and sent to the real line.
|
|
56
|
+
*
|
|
57
|
+
* Ported from `@1click/ui/src/cart-drawer/context.tsx` (v2.3.1). Cartbase
|
|
58
|
+
* data seam: the Medusa server actions are replaced by
|
|
59
|
+
* `@cartbase/storefront/api/carts` calls made through a `StorefrontClient`
|
|
60
|
+
* passed to the provider. Amounts are EUR decimal major units.
|
|
61
|
+
*
|
|
62
|
+
* Every failed change flows through `onOptimisticError` with per-action
|
|
63
|
+
* context. Wire it to the store's ops logging; the console.error stays
|
|
64
|
+
* regardless.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
type CartDrawerHrefs = {
|
|
68
|
+
/** Href for the checkout button in the sticky footer */
|
|
69
|
+
checkout: string
|
|
70
|
+
/** Href for the "browse products" CTA on the empty state */
|
|
71
|
+
browse: string
|
|
72
|
+
/** Prefix for product links — full href is `${productPrefix}/${handle}` */
|
|
73
|
+
productPrefix: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const defaultHrefs: CartDrawerHrefs = {
|
|
77
|
+
checkout: "/checkout",
|
|
78
|
+
browse: "/store",
|
|
79
|
+
productPrefix: "/products",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Context payload passed to `onOptimisticError` for every failed mutation. */
|
|
83
|
+
export type OptimisticCartError = {
|
|
84
|
+
actionType: OptimisticCartAction["type"]
|
|
85
|
+
error: unknown
|
|
86
|
+
/** Structured per-action fields (quantity, line_id, variant/product ids). */
|
|
87
|
+
context: Record<string, unknown>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Display fields an "add" action carries so the drawer can render the row
|
|
92
|
+
* immediately, before the server confirms.
|
|
93
|
+
*/
|
|
94
|
+
export type OptimisticAddDisplay = {
|
|
95
|
+
product_id?: string
|
|
96
|
+
product_title?: string
|
|
97
|
+
product_handle?: string
|
|
98
|
+
thumbnail?: string | null
|
|
99
|
+
variant_title?: string | null
|
|
100
|
+
/** Per-unit price in EUR major units. total = price × qty. */
|
|
101
|
+
unit_price?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Optimistic action shapes — what callers can dispatch to update the
|
|
106
|
+
* cart UI ahead of the server confirmation.
|
|
107
|
+
*/
|
|
108
|
+
export type OptimisticCartAction =
|
|
109
|
+
| ({
|
|
110
|
+
type: "add"
|
|
111
|
+
/** Variant being added. We use this to merge with existing lines. */
|
|
112
|
+
variant_id: string
|
|
113
|
+
quantity: number
|
|
114
|
+
} & OptimisticAddDisplay)
|
|
115
|
+
| {
|
|
116
|
+
type: "remove"
|
|
117
|
+
lineId: string
|
|
118
|
+
}
|
|
119
|
+
| {
|
|
120
|
+
type: "update_quantity"
|
|
121
|
+
lineId: string
|
|
122
|
+
quantity: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What an add answers: the confirmed cart, or why it was refused. */
|
|
126
|
+
export type CartMutationResult =
|
|
127
|
+
| { ok: true; cart: Cart }
|
|
128
|
+
| {
|
|
129
|
+
ok: false
|
|
130
|
+
/** The platform's refusal (a `StoreApiError`: `code` names it, e.g. `insufficient_inventory`). */
|
|
131
|
+
error: unknown
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The id prefix of a line the platform has not confirmed yet. */
|
|
135
|
+
const PENDING_PREFIX = "optimistic-"
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A cart that can still be shopped. A completed cart is an order now: the
|
|
139
|
+
* platform still answers it, lines and all, but refuses every change to it.
|
|
140
|
+
*/
|
|
141
|
+
export function isLiveCart(cart: Cart | null | undefined): cart is Cart {
|
|
142
|
+
return Boolean(cart && !cart.completed_at)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The platform's answer that the cart a change was aimed at is over (ordered, or gone). */
|
|
146
|
+
export function isEndedCartError(err: unknown): boolean {
|
|
147
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
148
|
+
return code === "cart_completed" || code === "cart_not_found"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A line the platform has not confirmed yet: it was added a moment ago and
|
|
153
|
+
* carries a placeholder id. Its quantity and removal work already (the
|
|
154
|
+
* provider holds them until the line exists); anything that needs the real
|
|
155
|
+
* line (a server read keyed by the line id) should wait until this is false.
|
|
156
|
+
*/
|
|
157
|
+
export function isPendingLine(line: Pick<CartLineItem, "id">): boolean {
|
|
158
|
+
return (line as { pending?: unknown }).pending === true || line.id.startsWith(PENDING_PREFIX)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The variant a placeholder line id stands for; null for a real line id. */
|
|
162
|
+
function pendingVariant(lineId: string): string | null {
|
|
163
|
+
return lineId.startsWith(PENDING_PREFIX) ? lineId.slice(PENDING_PREFIX.length) : null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type CartDrawerContextValue = {
|
|
167
|
+
isOpen: boolean
|
|
168
|
+
open: () => void
|
|
169
|
+
close: () => void
|
|
170
|
+
toggle: () => void
|
|
171
|
+
/** Cart snapshot AFTER any pending optimistic updates. */
|
|
172
|
+
cart: Cart | null
|
|
173
|
+
/**
|
|
174
|
+
* Dispatch an optimistic update. MUST be called inside a
|
|
175
|
+
* `startTransition` (React requirement for useOptimistic setters
|
|
176
|
+
* outside Action props). Prefer `applyOptimistic`.
|
|
177
|
+
*/
|
|
178
|
+
dispatchOptimistic: (action: OptimisticCartAction) => void
|
|
179
|
+
/**
|
|
180
|
+
* Dispatches the optimistic action AND awaits the provided server
|
|
181
|
+
* mutation inside a single transition. For a store that owns the server
|
|
182
|
+
* call itself (a Next.js server action); the built-in methods below cover
|
|
183
|
+
* the SDK-wired path and are the fast one.
|
|
184
|
+
*/
|
|
185
|
+
applyOptimistic: (
|
|
186
|
+
action: OptimisticCartAction,
|
|
187
|
+
serverAction: () => Promise<unknown>
|
|
188
|
+
) => void
|
|
189
|
+
/**
|
|
190
|
+
* Add a variant: the line shows at once, the platform confirms behind it.
|
|
191
|
+
* Answers with the confirmed cart or the refusal (require the provider's
|
|
192
|
+
* `client` prop).
|
|
193
|
+
*/
|
|
194
|
+
addLine: (
|
|
195
|
+
variantId: string,
|
|
196
|
+
quantity?: number,
|
|
197
|
+
display?: OptimisticAddDisplay
|
|
198
|
+
) => Promise<CartMutationResult>
|
|
199
|
+
/** `addLine`, answering only whether it was confirmed. */
|
|
200
|
+
addItem: (
|
|
201
|
+
variantId: string,
|
|
202
|
+
quantity?: number,
|
|
203
|
+
display?: OptimisticAddDisplay
|
|
204
|
+
) => Promise<boolean>
|
|
205
|
+
/**
|
|
206
|
+
* Set a line's quantity (0 deletes, the Cartbase contract). Every click
|
|
207
|
+
* shows at once; fast clicks on one line send the last number.
|
|
208
|
+
*/
|
|
209
|
+
updateQuantity: (lineId: string, quantity: number) => Promise<boolean>
|
|
210
|
+
removeItem: (lineId: string) => Promise<boolean>
|
|
211
|
+
/** Re-read the decorated cart (totals/gift-card tender are live per read). */
|
|
212
|
+
refresh: () => Promise<void>
|
|
213
|
+
/**
|
|
214
|
+
* The cart is over (the order was placed): the drawer empties and the
|
|
215
|
+
* store's `onCartEnd` clears its cookie. The checkout calls it; the next
|
|
216
|
+
* add starts a new cart.
|
|
217
|
+
*/
|
|
218
|
+
forget: () => Promise<void>
|
|
219
|
+
/** True when the provider was given a client, so the methods above reach the platform. */
|
|
220
|
+
canMutate: boolean
|
|
221
|
+
labels: CartDrawerLabels
|
|
222
|
+
hrefs: CartDrawerHrefs
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const CartDrawerContext = createContext<CartDrawerContextValue>({
|
|
226
|
+
isOpen: false,
|
|
227
|
+
open: () => {},
|
|
228
|
+
close: () => {},
|
|
229
|
+
toggle: () => {},
|
|
230
|
+
cart: null,
|
|
231
|
+
dispatchOptimistic: () => {},
|
|
232
|
+
applyOptimistic: () => {},
|
|
233
|
+
addLine: async () => ({ ok: false, error: new Error("No CartDrawerProvider with a client is mounted") }),
|
|
234
|
+
addItem: async () => false,
|
|
235
|
+
updateQuantity: async () => false,
|
|
236
|
+
removeItem: async () => false,
|
|
237
|
+
refresh: async () => {},
|
|
238
|
+
forget: async () => {},
|
|
239
|
+
canMutate: false,
|
|
240
|
+
labels: defaultCartDrawerLabels,
|
|
241
|
+
hrefs: defaultHrefs,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
export function useCartDrawer(): CartDrawerContextValue {
|
|
245
|
+
return useContext(CartDrawerContext)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Reducer for the useOptimistic cart. Pure function. Returns the next
|
|
250
|
+
* cart shape after applying the action. Falls back to a synthetic empty
|
|
251
|
+
* cart when there's no current cart yet so optimistic adds work even on
|
|
252
|
+
* first-add (the confirmed cart replaces it).
|
|
253
|
+
*
|
|
254
|
+
* A change aimed at a placeholder line also finds the real line of the
|
|
255
|
+
* same variant: the confirmed cart can arrive while the change is still
|
|
256
|
+
* pending, and the line must not come back or lose its new number.
|
|
257
|
+
*
|
|
258
|
+
* Exported for unit tests (pure logic, no React).
|
|
259
|
+
*/
|
|
260
|
+
export function reduceCart(
|
|
261
|
+
current: Cart | null,
|
|
262
|
+
action: OptimisticCartAction
|
|
263
|
+
): Cart | null {
|
|
264
|
+
if (!current) {
|
|
265
|
+
if (action.type !== "add") return current
|
|
266
|
+
return {
|
|
267
|
+
id: "optimistic-cart",
|
|
268
|
+
items: [makeOptimisticLine(action)],
|
|
269
|
+
} as unknown as Cart
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
switch (action.type) {
|
|
273
|
+
case "add": {
|
|
274
|
+
const items = current.items ?? []
|
|
275
|
+
const existing = items.find((i) => i.variant_id === action.variant_id)
|
|
276
|
+
if (existing) {
|
|
277
|
+
return {
|
|
278
|
+
...current,
|
|
279
|
+
items: items.map((i) =>
|
|
280
|
+
i.variant_id === action.variant_id
|
|
281
|
+
? {
|
|
282
|
+
...i,
|
|
283
|
+
quantity: i.quantity + action.quantity,
|
|
284
|
+
total:
|
|
285
|
+
(i.unit_price ?? 0) * (i.quantity + action.quantity),
|
|
286
|
+
}
|
|
287
|
+
: i
|
|
288
|
+
),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
...current,
|
|
293
|
+
items: [...items, makeOptimisticLine(action)],
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
case "remove": {
|
|
297
|
+
const variant = pendingVariant(action.lineId)
|
|
298
|
+
return {
|
|
299
|
+
...current,
|
|
300
|
+
items: (current.items ?? []).filter(
|
|
301
|
+
(i) => i.id !== action.lineId && !(variant && i.variant_id === variant)
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
case "update_quantity": {
|
|
306
|
+
const variant = pendingVariant(action.lineId)
|
|
307
|
+
return {
|
|
308
|
+
...current,
|
|
309
|
+
items: (current.items ?? []).map((i) =>
|
|
310
|
+
i.id === action.lineId || (variant && i.variant_id === variant)
|
|
311
|
+
? {
|
|
312
|
+
...i,
|
|
313
|
+
quantity: action.quantity,
|
|
314
|
+
total: (i.unit_price ?? 0) * action.quantity,
|
|
315
|
+
}
|
|
316
|
+
: i
|
|
317
|
+
),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build a synthetic line item from an "add" action: a placeholder id and
|
|
325
|
+
* `pending: true` (see `isPendingLine`). The confirmed cart replaces it.
|
|
326
|
+
* Cast through `unknown` because `CartLineItem` has many fields we don't
|
|
327
|
+
* have at click time (created_at, tax_lines, adjustments…) — the renderer
|
|
328
|
+
* is lenient about missing optional fields.
|
|
329
|
+
*/
|
|
330
|
+
export function makeOptimisticLine(
|
|
331
|
+
action: Extract<OptimisticCartAction, { type: "add" }>
|
|
332
|
+
): CartLineItem {
|
|
333
|
+
const unitPrice = action.unit_price ?? 0
|
|
334
|
+
return {
|
|
335
|
+
id: `${PENDING_PREFIX}${action.variant_id}`,
|
|
336
|
+
pending: true,
|
|
337
|
+
variant_id: action.variant_id,
|
|
338
|
+
product_id: action.product_id,
|
|
339
|
+
product_title: action.product_title,
|
|
340
|
+
product_handle: action.product_handle,
|
|
341
|
+
thumbnail: action.thumbnail,
|
|
342
|
+
variant_title: action.variant_title,
|
|
343
|
+
quantity: action.quantity,
|
|
344
|
+
unit_price: unitPrice,
|
|
345
|
+
total: unitPrice * action.quantity,
|
|
346
|
+
original_total: unitPrice * action.quantity,
|
|
347
|
+
} as unknown as CartLineItem
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The per-action fields every failure report carries. */
|
|
351
|
+
function failureContext(action: OptimisticCartAction, err: unknown): Record<string, unknown> {
|
|
352
|
+
const err_ = err as { message?: string; name?: string; code?: string } | null
|
|
353
|
+
const ctx: Record<string, unknown> = { action_type: action.type }
|
|
354
|
+
if (action.type === "add") {
|
|
355
|
+
ctx.quantity = action.quantity
|
|
356
|
+
ctx.product_id = action.product_id
|
|
357
|
+
ctx.variant_id = action.variant_id
|
|
358
|
+
} else if (action.type === "remove") {
|
|
359
|
+
ctx.line_id = action.lineId
|
|
360
|
+
} else {
|
|
361
|
+
ctx.line_id = action.lineId
|
|
362
|
+
ctx.quantity = action.quantity
|
|
363
|
+
}
|
|
364
|
+
ctx.err_name = err_?.name
|
|
365
|
+
ctx.err_code = err_?.code
|
|
366
|
+
ctx.err_message = err_?.message ?? String(err)
|
|
367
|
+
return ctx
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function CartDrawerProvider({
|
|
371
|
+
cart,
|
|
372
|
+
client,
|
|
373
|
+
cartId,
|
|
374
|
+
onCartChange,
|
|
375
|
+
onCartEnd,
|
|
376
|
+
onOptimisticError,
|
|
377
|
+
labels: labelOverrides,
|
|
378
|
+
hrefs: hrefOverrides,
|
|
379
|
+
children,
|
|
380
|
+
}: {
|
|
381
|
+
/**
|
|
382
|
+
* Server-fetched cart snapshot (RSC/server-action pattern). When the
|
|
383
|
+
* prop changes (e.g. after a router refresh) it becomes the confirmed
|
|
384
|
+
* snapshot, unless a change of the provider's own is still on its way.
|
|
385
|
+
* Leave it out and pass `client` + `cartId` to read the cart in the
|
|
386
|
+
* browser instead, so the page never waits for it.
|
|
387
|
+
*/
|
|
388
|
+
cart?: Cart | null
|
|
389
|
+
/** StorefrontClient for the SDK-wired mutations (addLine/addItem/updateQuantity/removeItem/refresh). */
|
|
390
|
+
client?: StorefrontClient
|
|
391
|
+
/** Known cart id to retrieve when no `cart` snapshot is passed. */
|
|
392
|
+
cartId?: string | null
|
|
393
|
+
/**
|
|
394
|
+
* Fires with the decorated cart after every server-confirmed change,
|
|
395
|
+
* including first-add cart creation — persist `cart.id` (cookie/storage)
|
|
396
|
+
* here so the cart survives reloads.
|
|
397
|
+
*/
|
|
398
|
+
onCartChange?: (cart: Cart) => void
|
|
399
|
+
/**
|
|
400
|
+
* Fires when the cart is over: the order was placed, or the platform
|
|
401
|
+
* answered that the stored cart is completed or gone. Clear the stored
|
|
402
|
+
* cart id (the cookie) here; the next add creates a new cart and
|
|
403
|
+
* `onCartChange` stores its id.
|
|
404
|
+
*/
|
|
405
|
+
onCartEnd?: () => void
|
|
406
|
+
/** Ops funnel for every failed optimistic mutation (see module JSDoc). */
|
|
407
|
+
onOptimisticError?: (failure: OptimisticCartError) => void
|
|
408
|
+
/**
|
|
409
|
+
* Overrides. The default is the language mounted by
|
|
410
|
+
* `StorefrontLocaleProvider`, English when none is mounted, so a store
|
|
411
|
+
* that mounts its pack has nothing to hand over here (2026-09-14).
|
|
412
|
+
*/
|
|
413
|
+
labels?: Partial<CartDrawerLabels>
|
|
414
|
+
hrefs?: Partial<CartDrawerHrefs>
|
|
415
|
+
children: ReactNode
|
|
416
|
+
}) {
|
|
417
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
418
|
+
const prevItemCount = useRef(0)
|
|
419
|
+
|
|
420
|
+
// The queue every SDK change goes through, and the latest confirmed cart
|
|
421
|
+
// it reads when a change's turn comes (state lags a render behind).
|
|
422
|
+
const queue = useRef<CartMutationQueue | null>(null)
|
|
423
|
+
if (!queue.current) queue.current = createCartMutationQueue()
|
|
424
|
+
// A completed cart is an order: the drawer never holds one.
|
|
425
|
+
const latest = useRef<Cart | null>(isLiveCart(cart) ? cart : null)
|
|
426
|
+
|
|
427
|
+
// The store's hooks, read through refs so an inline handler never re-runs an effect.
|
|
428
|
+
const cartEnded = useRef(onCartEnd)
|
|
429
|
+
cartEnded.current = onCartEnd
|
|
430
|
+
|
|
431
|
+
// Confirmed snapshot: seeded from the prop, replaced by every SDK
|
|
432
|
+
// mutation response and by prop updates from server refreshes.
|
|
433
|
+
const [serverCart, setServerCart] = useState<Cart | null>(latest.current)
|
|
434
|
+
|
|
435
|
+
/** The cart is over: the drawer empties and the store forgets the id. */
|
|
436
|
+
const end = useCallback(() => {
|
|
437
|
+
latest.current = null
|
|
438
|
+
startTransition(() => setServerCart(null))
|
|
439
|
+
try {
|
|
440
|
+
cartEnded.current?.()
|
|
441
|
+
} catch {
|
|
442
|
+
// A store's handler must never break the drawer.
|
|
443
|
+
}
|
|
444
|
+
}, [])
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (cart === undefined) return
|
|
448
|
+
// A refresh carrying the cart as it was must not overwrite an answer
|
|
449
|
+
// still on its way; the queue's own answers are newer.
|
|
450
|
+
if (queue.current?.busy()) return
|
|
451
|
+
if (cart && !isLiveCart(cart)) {
|
|
452
|
+
end()
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
latest.current = cart
|
|
456
|
+
setServerCart(cart)
|
|
457
|
+
}, [cart, end])
|
|
458
|
+
|
|
459
|
+
// A confirmed cart becomes the snapshot inside a transition, so it lands
|
|
460
|
+
// together with the end of the change that asked for it and a pending
|
|
461
|
+
// line is never shown twice. A completed one ends the cart instead.
|
|
462
|
+
const adopt = useCallback(
|
|
463
|
+
(next: Cart) => {
|
|
464
|
+
if (!isLiveCart(next)) {
|
|
465
|
+
end()
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
latest.current = next
|
|
469
|
+
startTransition(() => setServerCart(next))
|
|
470
|
+
},
|
|
471
|
+
[end]
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
// Client+cartId mode: read the cart on mount, through the queue, so an
|
|
475
|
+
// add made before the read answers goes to this cart, not a new one.
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (cart !== undefined || !client || !cartId) return
|
|
478
|
+
let cancelled = false
|
|
479
|
+
void queue.current!.run(async () => {
|
|
480
|
+
try {
|
|
481
|
+
const res = await sdkRetrieveCart(client, cartId)
|
|
482
|
+
if (!cancelled) adopt(res.cart)
|
|
483
|
+
} catch (err) {
|
|
484
|
+
// A stored id the platform no longer knows: forget it. Any other
|
|
485
|
+
// failure (a network blip) keeps the id for the next visit.
|
|
486
|
+
if (!cancelled && isEndedCartError(err)) end()
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
return () => {
|
|
490
|
+
cancelled = true
|
|
491
|
+
}
|
|
492
|
+
}, [cart, client, cartId, adopt, end])
|
|
493
|
+
|
|
494
|
+
const [optimisticCart, dispatchOptimistic] = useOptimistic(
|
|
495
|
+
serverCart,
|
|
496
|
+
reduceCart
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
// totalItems counts PRODUCT lines only. Backend-injected fee lines
|
|
500
|
+
// (e.g. the COD fee added by the payment-session flow) are excluded
|
|
501
|
+
// — otherwise the auto-open useEffect below treats a fee insertion
|
|
502
|
+
// as "a new item was added," opens the drawer mid-checkout, and
|
|
503
|
+
// locks body scroll. See @cartbase/storefront/lib/cart-helpers for the
|
|
504
|
+
// shared predicate.
|
|
505
|
+
const totalItems = productItemCount(optimisticCart?.items)
|
|
506
|
+
|
|
507
|
+
// Auto-open when items are added (count increases AFTER initial load)
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (totalItems > prevItemCount.current && prevItemCount.current > 0) {
|
|
510
|
+
setIsOpen(true)
|
|
511
|
+
}
|
|
512
|
+
prevItemCount.current = totalItems
|
|
513
|
+
}, [totalItems])
|
|
514
|
+
|
|
515
|
+
// Lock body scroll while open
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
if (isOpen) {
|
|
518
|
+
document.body.style.overflow = "hidden"
|
|
519
|
+
} else {
|
|
520
|
+
document.body.style.overflow = ""
|
|
521
|
+
}
|
|
522
|
+
return () => {
|
|
523
|
+
document.body.style.overflow = ""
|
|
524
|
+
}
|
|
525
|
+
}, [isOpen])
|
|
526
|
+
|
|
527
|
+
// Escape closes
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
530
|
+
if (e.key === "Escape" && isOpen) setIsOpen(false)
|
|
531
|
+
}
|
|
532
|
+
document.addEventListener("keydown", handleEscape)
|
|
533
|
+
return () => document.removeEventListener("keydown", handleEscape)
|
|
534
|
+
}, [isOpen])
|
|
535
|
+
|
|
536
|
+
const open = useCallback(() => setIsOpen(true), [])
|
|
537
|
+
const close = useCallback(() => setIsOpen(false), [])
|
|
538
|
+
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
|
539
|
+
|
|
540
|
+
/** The single funnel for every failed change: console + `onOptimisticError`. */
|
|
541
|
+
const report = useCallback(
|
|
542
|
+
(action: OptimisticCartAction, err: unknown) => {
|
|
543
|
+
// eslint-disable-next-line no-console
|
|
544
|
+
console.error("[cart-optimistic]", action.type, err)
|
|
545
|
+
try {
|
|
546
|
+
onOptimisticError?.({ actionType: action.type, error: err, context: failureContext(action, err) })
|
|
547
|
+
} catch {
|
|
548
|
+
// The ops funnel itself must never break the UI.
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
[onOptimisticError]
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Show an action for as long as its work runs. useOptimistic setters
|
|
556
|
+
* MUST be called inside a transition; the transition stays pending for
|
|
557
|
+
* the whole round-trip and the action falls away when it ends, by which
|
|
558
|
+
* time the confirmed cart carries it (or, on failure, it simply reverts).
|
|
559
|
+
* `work` settles without throwing; its answer is passed through.
|
|
560
|
+
*/
|
|
561
|
+
const showWhile = useCallback(
|
|
562
|
+
<T,>(action: OptimisticCartAction, work: Promise<T>): Promise<T> =>
|
|
563
|
+
new Promise<T>((resolve) => {
|
|
564
|
+
startTransition(async () => {
|
|
565
|
+
dispatchOptimistic(action)
|
|
566
|
+
resolve(await work)
|
|
567
|
+
})
|
|
568
|
+
}),
|
|
569
|
+
[dispatchOptimistic]
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
/** The store-owned path: its own server call, reverted on throw. */
|
|
573
|
+
const applyOptimistic = useCallback(
|
|
574
|
+
(action: OptimisticCartAction, serverAction: () => Promise<unknown>) => {
|
|
575
|
+
void showWhile(
|
|
576
|
+
action,
|
|
577
|
+
serverAction().then(
|
|
578
|
+
() => true,
|
|
579
|
+
(err) => {
|
|
580
|
+
report(action, err)
|
|
581
|
+
return false
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
},
|
|
586
|
+
[showWhile, report]
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
/** Adopt a server-confirmed decorated cart and tell the store. */
|
|
590
|
+
const confirm = useCallback(
|
|
591
|
+
(next: Cart) => {
|
|
592
|
+
adopt(next)
|
|
593
|
+
// A completed cart has ended the cart; its id is never stored again.
|
|
594
|
+
if (isLiveCart(next)) onCartChange?.(next)
|
|
595
|
+
},
|
|
596
|
+
[adopt, onCartChange]
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
/** The line a change is for, as the latest confirmed cart knows it; null when it is gone. */
|
|
600
|
+
const lineNow = useCallback((lineId: string): string | null => {
|
|
601
|
+
const items = latest.current?.items ?? []
|
|
602
|
+
const variant = pendingVariant(lineId)
|
|
603
|
+
if (variant) return items.find((i) => i.variant_id === variant)?.id ?? null
|
|
604
|
+
return items.some((i) => i.id === lineId) ? lineId : null
|
|
605
|
+
}, [])
|
|
606
|
+
|
|
607
|
+
const addLine = useCallback(
|
|
608
|
+
(
|
|
609
|
+
variantId: string,
|
|
610
|
+
quantity = 1,
|
|
611
|
+
display: OptimisticAddDisplay = {}
|
|
612
|
+
): Promise<CartMutationResult> => {
|
|
613
|
+
if (!client) {
|
|
614
|
+
return Promise.resolve({ ok: false, error: new Error("CartDrawerProvider has no client") })
|
|
615
|
+
}
|
|
616
|
+
const action: OptimisticCartAction = {
|
|
617
|
+
type: "add",
|
|
618
|
+
variant_id: variantId,
|
|
619
|
+
quantity,
|
|
620
|
+
...display,
|
|
621
|
+
}
|
|
622
|
+
const work = queue.current!.run(async (): Promise<CartMutationResult> => {
|
|
623
|
+
// First add with no cart: create one with the item in a single
|
|
624
|
+
// call. region_id falls back to the store default server-side.
|
|
625
|
+
const create = () => sdkCreateCart(client, { items: [{ variant_id: variantId, quantity }] })
|
|
626
|
+
try {
|
|
627
|
+
const cartId = latest.current?.id
|
|
628
|
+
let res
|
|
629
|
+
if (!cartId) {
|
|
630
|
+
res = await create()
|
|
631
|
+
} else {
|
|
632
|
+
try {
|
|
633
|
+
res = await sdkAddLineItem(client, cartId, { variant_id: variantId, quantity })
|
|
634
|
+
} catch (error) {
|
|
635
|
+
// The stored cart is over (ordered in another tab, or gone):
|
|
636
|
+
// Medusa's starter treats it as no cart, so a new one starts
|
|
637
|
+
// with this item.
|
|
638
|
+
if (!isEndedCartError(error)) throw error
|
|
639
|
+
end()
|
|
640
|
+
res = await create()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
confirm(res.cart)
|
|
644
|
+
return { ok: true, cart: res.cart }
|
|
645
|
+
} catch (error) {
|
|
646
|
+
report(action, error)
|
|
647
|
+
return { ok: false, error }
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
return showWhile(action, work)
|
|
651
|
+
},
|
|
652
|
+
[client, confirm, report, showWhile, end]
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
const addItem = useCallback(
|
|
656
|
+
(variantId: string, quantity = 1, display: OptimisticAddDisplay = {}): Promise<boolean> =>
|
|
657
|
+
addLine(variantId, quantity, display).then((result) => result.ok),
|
|
658
|
+
[addLine]
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
const updateQuantity = useCallback(
|
|
662
|
+
(lineId: string, quantity: number): Promise<boolean> => {
|
|
663
|
+
if (!client) return Promise.resolve(false)
|
|
664
|
+
const work = queue.current!.quantity(lineId, quantity, async (latestQuantity) => {
|
|
665
|
+
const id = lineNow(lineId)
|
|
666
|
+
const cartId = latest.current?.id
|
|
667
|
+
if (!id || !cartId) return false
|
|
668
|
+
try {
|
|
669
|
+
// Cartbase contract: body is `{quantity}` only; 0 deletes the line.
|
|
670
|
+
const res = await sdkUpdateLineItem(client, cartId, id, { quantity: latestQuantity })
|
|
671
|
+
confirm(res.cart)
|
|
672
|
+
return true
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (isEndedCartError(err)) end()
|
|
675
|
+
else report({ type: "update_quantity", lineId, quantity: latestQuantity }, err)
|
|
676
|
+
return false
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
return showWhile(
|
|
680
|
+
{ type: "update_quantity", lineId, quantity },
|
|
681
|
+
work.then((sent) => sent === true)
|
|
682
|
+
)
|
|
683
|
+
},
|
|
684
|
+
[client, lineNow, confirm, report, showWhile, end]
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
const removeItem = useCallback(
|
|
688
|
+
(lineId: string): Promise<boolean> => {
|
|
689
|
+
if (!client) return Promise.resolve(false)
|
|
690
|
+
const action: OptimisticCartAction = { type: "remove", lineId }
|
|
691
|
+
// A quantity change still waiting for this line has nothing to change.
|
|
692
|
+
queue.current!.forget(lineId)
|
|
693
|
+
const work = queue.current!.run(async () => {
|
|
694
|
+
const id = lineNow(lineId)
|
|
695
|
+
const cartId = latest.current?.id
|
|
696
|
+
// Already gone (or never confirmed): nothing to delete.
|
|
697
|
+
if (!id || !cartId) return true
|
|
698
|
+
try {
|
|
699
|
+
const res = await sdkDeleteLineItem(client, cartId, id)
|
|
700
|
+
confirm(res.cart)
|
|
701
|
+
return true
|
|
702
|
+
} catch (err) {
|
|
703
|
+
if (isEndedCartError(err)) {
|
|
704
|
+
end()
|
|
705
|
+
return true
|
|
706
|
+
}
|
|
707
|
+
report(action, err)
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
})
|
|
711
|
+
return showWhile(action, work)
|
|
712
|
+
},
|
|
713
|
+
[client, lineNow, confirm, report, showWhile, end]
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
const refresh = useCallback(
|
|
717
|
+
(): Promise<void> => {
|
|
718
|
+
if (!client) return Promise.resolve()
|
|
719
|
+
return queue.current!.run(async () => {
|
|
720
|
+
const id = latest.current?.id
|
|
721
|
+
if (!id) return
|
|
722
|
+
try {
|
|
723
|
+
const res = await sdkRetrieveCart(client, id)
|
|
724
|
+
confirm(res.cart)
|
|
725
|
+
} catch (err) {
|
|
726
|
+
// A cart the platform no longer knows ends; a transient failure
|
|
727
|
+
// keeps the last snapshot.
|
|
728
|
+
if (isEndedCartError(err)) end()
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
},
|
|
732
|
+
[client, confirm, end]
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
/** The order was placed: empty the drawer and let the store forget the id. */
|
|
736
|
+
const forget = useCallback(
|
|
737
|
+
(): Promise<void> => queue.current!.run(async () => end()),
|
|
738
|
+
[end]
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
// The mounted language is the default (2026-09-14): a store that mounts
|
|
742
|
+
// StorefrontLocaleProvider has nothing to hand over here, and a `labels`
|
|
743
|
+
// prop overrides only the keys it names.
|
|
744
|
+
const inherited = useLocaleArea("cartDrawer", defaultCartDrawerLabels)
|
|
745
|
+
const labels: CartDrawerLabels = {
|
|
746
|
+
...inherited,
|
|
747
|
+
...labelOverrides,
|
|
748
|
+
}
|
|
749
|
+
const hrefs: CartDrawerHrefs = {
|
|
750
|
+
...defaultHrefs,
|
|
751
|
+
...hrefOverrides,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return (
|
|
755
|
+
<CartDrawerContext.Provider
|
|
756
|
+
value={{
|
|
757
|
+
isOpen,
|
|
758
|
+
open,
|
|
759
|
+
close,
|
|
760
|
+
toggle,
|
|
761
|
+
cart: optimisticCart,
|
|
762
|
+
dispatchOptimistic,
|
|
763
|
+
applyOptimistic,
|
|
764
|
+
addLine,
|
|
765
|
+
addItem,
|
|
766
|
+
updateQuantity,
|
|
767
|
+
removeItem,
|
|
768
|
+
refresh,
|
|
769
|
+
forget,
|
|
770
|
+
canMutate: Boolean(client),
|
|
771
|
+
labels,
|
|
772
|
+
hrefs,
|
|
773
|
+
}}
|
|
774
|
+
>
|
|
775
|
+
{children}
|
|
776
|
+
</CartDrawerContext.Provider>
|
|
777
|
+
)
|
|
778
|
+
}
|