@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.
Files changed (99) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +35 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/sticky-footer.tsx +73 -73
  32. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  33. package/src/checkout/address-error-copy.ts +117 -117
  34. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  35. package/src/checkout/compare-addresses.ts +40 -40
  36. package/src/checkout/discount-section.tsx +218 -218
  37. package/src/checkout/error-copy-codes.ts +63 -63
  38. package/src/checkout/geocode.ts +154 -154
  39. package/src/checkout/gift-card-section.tsx +224 -224
  40. package/src/checkout/index.ts +72 -72
  41. package/src/checkout/labels.ts +495 -495
  42. package/src/checkout/payment-button.tsx +372 -372
  43. package/src/checkout/payment-error-copy.ts +154 -154
  44. package/src/checkout/promotion-error-copy.ts +91 -91
  45. package/src/common/country-flag.tsx +52 -52
  46. package/src/common/country-select.tsx +11 -11
  47. package/src/common/index.ts +20 -20
  48. package/src/common/market-select.tsx +57 -57
  49. package/src/index.ts +12 -12
  50. package/src/lib/country-name.ts +59 -59
  51. package/src/lib/get-product-price.ts +133 -133
  52. package/src/lib/media-image.tsx +39 -39
  53. package/src/lib/payment-constants.ts +53 -53
  54. package/src/lib/platform.ts +13 -13
  55. package/src/lib/price.tsx +39 -39
  56. package/src/lib/store-api-error.ts +36 -36
  57. package/src/lib/variant-caption.ts +32 -32
  58. package/src/locales/context.ts +37 -37
  59. package/src/locales/en.ts +26 -26
  60. package/src/locales/index.ts +19 -19
  61. package/src/locales/provider.tsx +59 -59
  62. package/src/locales/types.ts +77 -77
  63. package/src/order/index.ts +62 -62
  64. package/src/order/labels.ts +79 -79
  65. package/src/order/order-totals.tsx +250 -250
  66. package/src/primitives/select-field.tsx +93 -93
  67. package/src/products/image-gallery.tsx +43 -43
  68. package/src/products/option-select.tsx +67 -67
  69. package/src/products/product-info.tsx +5 -5
  70. package/src/products/product-price.tsx +69 -69
  71. package/src/products/product-promises.tsx +61 -61
  72. package/src/products/product-specs.tsx +65 -65
  73. package/src/products/product-tabs.tsx +123 -123
  74. package/src/products/purchase-options.tsx +130 -130
  75. package/src/products/sets.ts +110 -110
  76. package/src/products/variant-matching.ts +71 -71
  77. package/src/products/variant-url.ts +74 -74
  78. package/src/reviews-ui/lightbox-state.ts +46 -46
  79. package/src/reviews-ui/review-header.tsx +166 -166
  80. package/src/reviews-ui/review-lightbox.tsx +271 -271
  81. package/src/reviews-ui/review-list.tsx +193 -193
  82. package/src/reviews-ui/review-widget.tsx +219 -218
  83. package/src/reviews-ui/widget-options.ts +55 -55
  84. package/src/store/category-template.tsx +136 -136
  85. package/src/store/index.ts +40 -40
  86. package/src/tracking/chatgpt-pixel.tsx +99 -99
  87. package/src/tracking/consent-init.tsx +62 -62
  88. package/src/tracking/events.ts +348 -348
  89. package/src/tracking/ga4.tsx +93 -93
  90. package/src/tracking/google-ads.ts +84 -84
  91. package/src/tracking/gtm.tsx +60 -60
  92. package/src/tracking/inline-script.ts +49 -49
  93. package/src/tracking/oaiq.ts +206 -206
  94. package/src/tracking/tiktok-pixel.tsx +91 -91
  95. package/src/tracking/track-init.tsx +56 -56
  96. package/src/tracking/track-order-purchase.tsx +122 -122
  97. package/src/tracking/ttq.ts +180 -180
  98. package/src/tracking/use-tracking-config.ts +54 -54
  99. 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
+ }