@cartbase/storefront 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +3 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +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/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +367 -168
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +73 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1604 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/cookie-names.ts +14 -0
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -39
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-totals.tsx +250 -250
  74. package/src/primitives/select-field.tsx +93 -93
  75. package/src/products/image-gallery.tsx +43 -43
  76. package/src/products/index.ts +68 -59
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-actions-wrapper.tsx +63 -62
  79. package/src/products/product-actions.tsx +151 -150
  80. package/src/products/product-price.tsx +69 -69
  81. package/src/products/product-promises.tsx +61 -61
  82. package/src/products/product-specs.tsx +65 -65
  83. package/src/products/product-tabs.tsx +123 -123
  84. package/src/products/product-template.tsx +163 -162
  85. package/src/products/purchase-options.tsx +130 -130
  86. package/src/products/sets.ts +110 -0
  87. package/src/products/use-product-actions.ts +199 -164
  88. package/src/products/variant-matching.ts +71 -71
  89. package/src/products/variant-url.ts +74 -74
  90. package/src/reviews-ui/index.ts +73 -73
  91. package/src/store/category-template.tsx +136 -136
  92. package/src/store/index.ts +40 -40
  93. package/src/tracking/chatgpt-pixel.tsx +99 -99
  94. package/src/tracking/consent-init.tsx +62 -62
  95. package/src/tracking/events.ts +348 -348
  96. package/src/tracking/ga4.tsx +93 -93
  97. package/src/tracking/google-ads.ts +84 -84
  98. package/src/tracking/gtm.tsx +60 -60
  99. package/src/tracking/inline-script.ts +49 -49
  100. package/src/tracking/oaiq.ts +206 -206
  101. package/src/tracking/tiktok-pixel.tsx +91 -91
  102. package/src/tracking/track-init.tsx +56 -56
  103. package/src/tracking/track-order-purchase.tsx +122 -122
  104. package/src/tracking/ttq.ts +180 -180
  105. package/src/tracking/use-tracking-config.ts +54 -54
  106. package/theme/index.css +25 -25
  107. package/theme/theme.css +150 -150
  108. package/theme/tokens.css +106 -106
@@ -25,41 +25,43 @@ import type { StorefrontClient } from "../api/http"
25
25
  import { productItemCount } from "../lib/cart-helpers"
26
26
  import { defaultCartDrawerLabels, type CartDrawerLabels } from "./labels"
27
27
  import { useLocaleArea } from "../locales/context"
28
+ import { createCartMutationQueue, type CartMutationQueue } from "./mutation-queue"
28
29
 
29
30
  /**
30
31
  * Cart drawer context — open state + cart snapshot + labels + link hrefs.
31
32
  *
32
- * Stores wrap their root layout with `<CartDrawerProvider cart={cart}>`
33
- * (the cart is fetched server-side and passed down as a prop). The
34
- * provider manages open/close state, auto-opens when item count rises,
35
- * locks body scroll while open, and closes on escape.
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.
36
38
  *
37
- * Optimistic UI: the cart exposed via `useCartDrawer().cart` is wrapped
38
- * in React 19's `useOptimistic`. Callers (PDP add buttons, drawer
39
- * quantity steppers, drawer remove buttons) dispatch optimistic actions
40
- * BEFORE awaiting the corresponding server mutation. The drawer reflects
41
- * the change at React update speed (~16ms) instead of waiting for the
42
- * full backend round-trip (~800-1500ms). On mutation failure the
43
- * optimistic state reverts automatically because the confirmed cart
44
- * snapshot never updated past the failed mutation. See
45
- * https://react.dev/reference/react/useOptimistic.
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.
46
56
  *
47
57
  * Ported from `@1click/ui/src/cart-drawer/context.tsx` (v2.3.1). Cartbase
48
- * data seam: the Medusa server actions (`../data/cart`) are replaced by
58
+ * data seam: the Medusa server actions are replaced by
49
59
  * `@cartbase/storefront/api/carts` calls made through a `StorefrontClient`
50
- * passed to the provider. Every mutation returns the FULL decorated cart
51
- * (totals + gift_cards[]/gift_card_total/gift_card_remainder — SERVER
52
- * truth), which the provider adopts as the new confirmed snapshot, so no
53
- * RSC re-fetch is required for the drawer to settle. Amounts are EUR
54
- * decimal major units (the optimistic `unit_price × quantity` arithmetic
55
- * is unit-agnostic and unchanged).
60
+ * passed to the provider. Amounts are EUR decimal major units.
56
61
  *
57
- * The source's `logEvent` ops funnel (backend error log endpoint) has no
58
- * Cartbase SDK equivalent yet the same single-funnel discipline is kept
59
- * via the `onOptimisticError` prop: every failed optimistic mutation
60
- * (every PDP add, cross-sell add, drawer remove, drawer quantity change)
61
- * flows through it with per-action context. Wire it to the store's ops
62
- * logging; the console.error stays regardless.
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.
63
65
  */
64
66
 
65
67
  type CartDrawerHrefs = {
@@ -120,6 +122,47 @@ export type OptimisticCartAction =
120
122
  quantity: number
121
123
  }
122
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
+
123
166
  type CartDrawerContextValue = {
124
167
  isOpen: boolean
125
168
  open: () => void
@@ -130,40 +173,51 @@ type CartDrawerContextValue = {
130
173
  /**
131
174
  * Dispatch an optimistic update. MUST be called inside a
132
175
  * `startTransition` (React requirement for useOptimistic setters
133
- * outside Action props). The CartDrawerProvider exposes a helper
134
- * `applyOptimistic` that wraps the dispatch in startTransition for
135
- * convenience; prefer that.
176
+ * outside Action props). Prefer `applyOptimistic`.
136
177
  */
137
178
  dispatchOptimistic: (action: OptimisticCartAction) => void
138
179
  /**
139
- * Convenience wrapper: dispatches the optimistic action AND awaits
140
- * the provided server mutation inside a single transition. Use from
141
- * PDP add buttons or anywhere the store owns the server call itself
142
- * (e.g. a Next.js server action). The built-in helpers below cover
143
- * the SDK-wired path.
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.
144
184
  */
145
185
  applyOptimistic: (
146
186
  action: OptimisticCartAction,
147
187
  serverAction: () => Promise<unknown>
148
188
  ) => void
149
189
  /**
150
- * SDK-wired mutations (require the provider's `client` prop). Each
151
- * dispatches the optimistic action, calls the matching
152
- * `@cartbase/storefront/api/carts` function, and adopts the returned
153
- * decorated cart. Resolves `true` on server confirmation, `false` on
154
- * failure (after funneling through `onOptimisticError`) or when no
155
- * client is configured.
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).
156
193
  */
194
+ addLine: (
195
+ variantId: string,
196
+ quantity?: number,
197
+ display?: OptimisticAddDisplay
198
+ ) => Promise<CartMutationResult>
199
+ /** `addLine`, answering only whether it was confirmed. */
157
200
  addItem: (
158
201
  variantId: string,
159
202
  quantity?: number,
160
203
  display?: OptimisticAddDisplay
161
204
  ) => Promise<boolean>
162
- /** POST line-items/:id with `{quantity}` — Cartbase contract: 0 deletes. */
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
+ */
163
209
  updateQuantity: (lineId: string, quantity: number) => Promise<boolean>
164
210
  removeItem: (lineId: string) => Promise<boolean>
165
211
  /** Re-read the decorated cart (totals/gift-card tender are live per read). */
166
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
167
221
  labels: CartDrawerLabels
168
222
  hrefs: CartDrawerHrefs
169
223
  }
@@ -176,10 +230,13 @@ const CartDrawerContext = createContext<CartDrawerContextValue>({
176
230
  cart: null,
177
231
  dispatchOptimistic: () => {},
178
232
  applyOptimistic: () => {},
233
+ addLine: async () => ({ ok: false, error: new Error("No CartDrawerProvider with a client is mounted") }),
179
234
  addItem: async () => false,
180
235
  updateQuantity: async () => false,
181
236
  removeItem: async () => false,
182
237
  refresh: async () => {},
238
+ forget: async () => {},
239
+ canMutate: false,
183
240
  labels: defaultCartDrawerLabels,
184
241
  hrefs: defaultHrefs,
185
242
  })
@@ -192,7 +249,11 @@ export function useCartDrawer(): CartDrawerContextValue {
192
249
  * Reducer for the useOptimistic cart. Pure function. Returns the next
193
250
  * cart shape after applying the action. Falls back to a synthetic empty
194
251
  * cart when there's no current cart yet so optimistic adds work even on
195
- * first-add (the server backfills the real cart_id + line_id below).
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.
196
257
  *
197
258
  * Exported for unit tests (pure logic, no React).
198
259
  */
@@ -201,10 +262,6 @@ export function reduceCart(
201
262
  action: OptimisticCartAction
202
263
  ): Cart | null {
203
264
  if (!current) {
204
- // No cart yet on the server — synthesize a minimal one so the drawer
205
- // can render the item immediately. The real createCart call will
206
- // create the cart and the confirmed snapshot replaces this synthetic
207
- // one.
208
265
  if (action.type !== "add") return current
209
266
  return {
210
267
  id: "optimistic-cart",
@@ -237,16 +294,20 @@ export function reduceCart(
237
294
  }
238
295
  }
239
296
  case "remove": {
297
+ const variant = pendingVariant(action.lineId)
240
298
  return {
241
299
  ...current,
242
- items: (current.items ?? []).filter((i) => i.id !== action.lineId),
300
+ items: (current.items ?? []).filter(
301
+ (i) => i.id !== action.lineId && !(variant && i.variant_id === variant)
302
+ ),
243
303
  }
244
304
  }
245
305
  case "update_quantity": {
306
+ const variant = pendingVariant(action.lineId)
246
307
  return {
247
308
  ...current,
248
309
  items: (current.items ?? []).map((i) =>
249
- i.id === action.lineId
310
+ i.id === action.lineId || (variant && i.variant_id === variant)
250
311
  ? {
251
312
  ...i,
252
313
  quantity: action.quantity,
@@ -260,18 +321,19 @@ export function reduceCart(
260
321
  }
261
322
 
262
323
  /**
263
- * Build a synthetic line item from an "add" action. The real server
264
- * response will replace this with the actual line on confirmation.
265
- * Cast through `unknown` because `CartLineItem` has many fields we
266
- * don't have at click time (created_at, tax_lines, adjustments…) — the
267
- * renderer is lenient about missing optional fields.
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.
268
329
  */
269
330
  export function makeOptimisticLine(
270
331
  action: Extract<OptimisticCartAction, { type: "add" }>
271
332
  ): CartLineItem {
272
333
  const unitPrice = action.unit_price ?? 0
273
334
  return {
274
- id: `optimistic-${action.variant_id}`,
335
+ id: `${PENDING_PREFIX}${action.variant_id}`,
336
+ pending: true,
275
337
  variant_id: action.variant_id,
276
338
  product_id: action.product_id,
277
339
  product_title: action.product_title,
@@ -285,11 +347,32 @@ export function makeOptimisticLine(
285
347
  } as unknown as CartLineItem
286
348
  }
287
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
+
288
370
  export function CartDrawerProvider({
289
371
  cart,
290
372
  client,
291
373
  cartId,
292
374
  onCartChange,
375
+ onCartEnd,
293
376
  onOptimisticError,
294
377
  labels: labelOverrides,
295
378
  hrefs: hrefOverrides,
@@ -298,11 +381,12 @@ export function CartDrawerProvider({
298
381
  /**
299
382
  * Server-fetched cart snapshot (RSC/server-action pattern). When the
300
383
  * prop changes (e.g. after a router refresh) it becomes the confirmed
301
- * snapshot. Optional when `client` + `cartId` are given the provider
302
- * then retrieves the cart itself on mount.
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.
303
387
  */
304
388
  cart?: Cart | null
305
- /** StorefrontClient for the SDK-wired mutations (addItem/updateQuantity/removeItem/refresh). */
389
+ /** StorefrontClient for the SDK-wired mutations (addLine/addItem/updateQuantity/removeItem/refresh). */
306
390
  client?: StorefrontClient
307
391
  /** Known cart id to retrieve when no `cart` snapshot is passed. */
308
392
  cartId?: string | null
@@ -312,6 +396,13 @@ export function CartDrawerProvider({
312
396
  * here so the cart survives reloads.
313
397
  */
314
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
315
406
  /** Ops funnel for every failed optimistic mutation (see module JSDoc). */
316
407
  onOptimisticError?: (failure: OptimisticCartError) => void
317
408
  /**
@@ -326,31 +417,79 @@ export function CartDrawerProvider({
326
417
  const [isOpen, setIsOpen] = useState(false)
327
418
  const prevItemCount = useRef(0)
328
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
+
329
431
  // Confirmed snapshot: seeded from the prop, replaced by every SDK
330
- // mutation response (each returns the full decorated cart) and by
331
- // prop updates from server refreshes.
332
- const [serverCart, setServerCart] = useState<Cart | null>(cart ?? null)
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
+
333
446
  useEffect(() => {
334
- if (cart !== undefined) setServerCart(cart)
335
- }, [cart])
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
+ )
336
473
 
337
- // Client+cartId mode: retrieve the decorated cart on mount when no
338
- // snapshot was provided.
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.
339
476
  useEffect(() => {
340
477
  if (cart !== undefined || !client || !cartId) return
341
478
  let cancelled = false
342
- sdkRetrieveCart(client, cartId)
343
- .then((res) => {
344
- if (!cancelled) setServerCart(res.cart)
345
- })
346
- .catch(() => {
347
- // 404 cart_not_found (expired/foreign id) stay empty; the next
348
- // addItem creates a fresh cart.
349
- })
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
+ })
350
489
  return () => {
351
490
  cancelled = true
352
491
  }
353
- }, [cart, client, cartId])
492
+ }, [cart, client, cartId, adopt, end])
354
493
 
355
494
  const [optimisticCart, dispatchOptimistic] = useOptimistic(
356
495
  serverCart,
@@ -398,149 +537,206 @@ export function CartDrawerProvider({
398
537
  const close = useCallback(() => setIsOpen(false), [])
399
538
  const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
400
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
+
401
554
  /**
402
- * Shared optimistic runner. useOptimistic setters MUST be called
403
- * inside a transition or an Action prop, otherwise React errors
404
- * during render. Wrapping both the dispatch AND the awaited server
405
- * work in a single startTransition ensures the optimistic state stays
406
- * "pending" for the entire round-trip and reverts cleanly on throw.
407
- *
408
- * On failure the optimistic state auto-reverts because the confirmed
409
- * snapshot hasn't updated. We do NOT rethrow — useOptimistic
410
- * semantics require the transition to settle cleanly. Every failure
411
- * is logged (console + `onOptimisticError`): this is the single
412
- * funnel for every PDP add, cross-sell add, drawer remove, drawer
413
- * quantity change. Without it those failures are invisible to ops.
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.
414
560
  */
415
- const runOptimistic = useCallback(
416
- (
417
- action: OptimisticCartAction,
418
- work: () => Promise<unknown>
419
- ): Promise<boolean> =>
420
- new Promise<boolean>((resolve) => {
561
+ const showWhile = useCallback(
562
+ <T,>(action: OptimisticCartAction, work: Promise<T>): Promise<T> =>
563
+ new Promise<T>((resolve) => {
421
564
  startTransition(async () => {
422
565
  dispatchOptimistic(action)
423
- try {
424
- await work()
425
- resolve(true)
426
- } catch (err) {
427
- const err_ = err as { message?: string; name?: string }
428
- // eslint-disable-next-line no-console
429
- console.error("[cart-optimistic]", action.type, err_)
430
- const ctx: Record<string, unknown> = { action_type: action.type }
431
- if (action.type === "add") {
432
- ctx.quantity = action.quantity
433
- ctx.product_id = action.product_id
434
- ctx.variant_id = action.variant_id
435
- } else if (action.type === "remove") {
436
- ctx.line_id = action.lineId
437
- } else if (action.type === "update_quantity") {
438
- ctx.line_id = action.lineId
439
- ctx.quantity = action.quantity
440
- }
441
- ctx.err_name = err_?.name
442
- ctx.err_message = err_?.message ?? String(err)
443
- try {
444
- onOptimisticError?.({ actionType: action.type, error: err, context: ctx })
445
- } catch {
446
- // The ops funnel itself must never break the UI.
447
- }
448
- resolve(false)
449
- }
566
+ resolve(await work)
450
567
  })
451
568
  }),
452
- [dispatchOptimistic, onOptimisticError]
569
+ [dispatchOptimistic]
453
570
  )
454
571
 
572
+ /** The store-owned path: its own server call, reverted on throw. */
455
573
  const applyOptimistic = useCallback(
456
574
  (action: OptimisticCartAction, serverAction: () => Promise<unknown>) => {
457
- void runOptimistic(action, serverAction)
575
+ void showWhile(
576
+ action,
577
+ serverAction().then(
578
+ () => true,
579
+ (err) => {
580
+ report(action, err)
581
+ return false
582
+ }
583
+ )
584
+ )
458
585
  },
459
- [runOptimistic]
586
+ [showWhile, report]
460
587
  )
461
588
 
462
- /** Adopt a server-confirmed decorated cart. */
589
+ /** Adopt a server-confirmed decorated cart and tell the store. */
463
590
  const confirm = useCallback(
464
591
  (next: Cart) => {
465
- setServerCart(next)
466
- onCartChange?.(next)
592
+ adopt(next)
593
+ // A completed cart has ended the cart; its id is never stored again.
594
+ if (isLiveCart(next)) onCartChange?.(next)
467
595
  },
468
- [onCartChange]
596
+ [adopt, onCartChange]
469
597
  )
470
598
 
471
- const addItem = useCallback(
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(
472
608
  (
473
609
  variantId: string,
474
610
  quantity = 1,
475
611
  display: OptimisticAddDisplay = {}
476
- ): Promise<boolean> => {
477
- if (!client) return Promise.resolve(false)
612
+ ): Promise<CartMutationResult> => {
613
+ if (!client) {
614
+ return Promise.resolve({ ok: false, error: new Error("CartDrawerProvider has no client") })
615
+ }
478
616
  const action: OptimisticCartAction = {
479
617
  type: "add",
480
618
  variant_id: variantId,
481
619
  quantity,
482
620
  ...display,
483
621
  }
484
- return runOptimistic(action, async () => {
485
- const existingId = serverCart?.id
486
- if (existingId) {
487
- const res = await sdkAddLineItem(client, existingId, {
488
- variant_id: variantId,
489
- quantity,
490
- })
491
- confirm(res.cart)
492
- } else {
493
- // First add with no cart: create one with the item in a single
494
- // call. region_id falls back to the store default server-side.
495
- const res = await sdkCreateCart(client, {
496
- items: [{ variant_id: variantId, quantity }],
497
- })
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
+ }
498
643
  confirm(res.cart)
644
+ return { ok: true, cart: res.cart }
645
+ } catch (error) {
646
+ report(action, error)
647
+ return { ok: false, error }
499
648
  }
500
649
  })
650
+ return showWhile(action, work)
501
651
  },
502
- [client, serverCart?.id, runOptimistic, confirm]
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]
503
659
  )
504
660
 
505
661
  const updateQuantity = useCallback(
506
662
  (lineId: string, quantity: number): Promise<boolean> => {
507
- const id = serverCart?.id
508
- if (!client || !id) return Promise.resolve(false)
509
- return runOptimistic(
510
- { type: "update_quantity", lineId, quantity },
511
- async () => {
512
- // Cartbase contract: body is `{quantity}` only (metadata is NOT
513
- // accepted on update, only on add); quantity 0 deletes the line.
514
- const res = await sdkUpdateLineItem(client, id, lineId, { quantity })
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 })
515
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
516
677
  }
678
+ })
679
+ return showWhile(
680
+ { type: "update_quantity", lineId, quantity },
681
+ work.then((sent) => sent === true)
517
682
  )
518
683
  },
519
- [client, serverCart?.id, runOptimistic, confirm]
684
+ [client, lineNow, confirm, report, showWhile, end]
520
685
  )
521
686
 
522
687
  const removeItem = useCallback(
523
688
  (lineId: string): Promise<boolean> => {
524
- const id = serverCart?.id
525
- if (!client || !id) return Promise.resolve(false)
526
- return runOptimistic({ type: "remove", lineId }, async () => {
527
- const res = await sdkDeleteLineItem(client, id, lineId)
528
- confirm(res.cart)
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
+ }
529
710
  })
711
+ return showWhile(action, work)
530
712
  },
531
- [client, serverCart?.id, runOptimistic, confirm]
713
+ [client, lineNow, confirm, report, showWhile, end]
532
714
  )
533
715
 
534
- const refresh = useCallback(async (): Promise<void> => {
535
- const id = serverCart?.id
536
- if (!client || !id) return
537
- try {
538
- const res = await sdkRetrieveCart(client, id)
539
- confirm(res.cart)
540
- } catch {
541
- // Keep the last snapshot on transient failure.
542
- }
543
- }, [client, serverCart?.id, confirm])
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
+ )
544
740
 
545
741
  // The mounted language is the default (2026-09-14): a store that mounts
546
742
  // StorefrontLocaleProvider has nothing to hand over here, and a `labels`
@@ -565,10 +761,13 @@ export function CartDrawerProvider({
565
761
  cart: optimisticCart,
566
762
  dispatchOptimistic,
567
763
  applyOptimistic,
764
+ addLine,
568
765
  addItem,
569
766
  updateQuantity,
570
767
  removeItem,
571
768
  refresh,
769
+ forget,
770
+ canMutate: Boolean(client),
572
771
  labels,
573
772
  hrefs,
574
773
  }}