@cartbase/storefront 0.17.0 → 0.18.0

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 (107) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -256
  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 +277 -164
  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 +71 -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 +1592 -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/country-name.ts +59 -59
  56. package/src/lib/get-product-price.ts +133 -133
  57. package/src/lib/media-image.tsx +39 -39
  58. package/src/lib/payment-constants.ts +53 -53
  59. package/src/lib/platform.ts +13 -13
  60. package/src/lib/price.tsx +39 -39
  61. package/src/lib/store-api-error.ts +36 -36
  62. package/src/lib/variant-caption.ts +32 -32
  63. package/src/locales/bg.ts +467 -467
  64. package/src/locales/context.ts +37 -37
  65. package/src/locales/en.ts +26 -26
  66. package/src/locales/es.ts +466 -466
  67. package/src/locales/index.ts +19 -19
  68. package/src/locales/provider.tsx +59 -59
  69. package/src/locales/types.ts +77 -77
  70. package/src/order/index.ts +62 -62
  71. package/src/order/labels.ts +79 -79
  72. package/src/order/order-totals.tsx +250 -250
  73. package/src/primitives/select-field.tsx +93 -93
  74. package/src/products/image-gallery.tsx +43 -43
  75. package/src/products/index.ts +68 -59
  76. package/src/products/option-select.tsx +67 -67
  77. package/src/products/product-actions-wrapper.tsx +63 -62
  78. package/src/products/product-actions.tsx +151 -150
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/product-template.tsx +163 -162
  84. package/src/products/purchase-options.tsx +130 -130
  85. package/src/products/sets.ts +110 -0
  86. package/src/products/use-product-actions.ts +199 -164
  87. package/src/products/variant-matching.ts +71 -71
  88. package/src/products/variant-url.ts +74 -74
  89. package/src/reviews-ui/index.ts +73 -73
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
  106. package/theme/theme.css +150 -150
  107. 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,33 @@ 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 line the platform has not confirmed yet: it was added a moment ago and
139
+ * carries a placeholder id. Its quantity and removal work already (the
140
+ * provider holds them until the line exists); anything that needs the real
141
+ * line (a server read keyed by the line id) should wait until this is false.
142
+ */
143
+ export function isPendingLine(line: Pick<CartLineItem, "id">): boolean {
144
+ return (line as { pending?: unknown }).pending === true || line.id.startsWith(PENDING_PREFIX)
145
+ }
146
+
147
+ /** The variant a placeholder line id stands for; null for a real line id. */
148
+ function pendingVariant(lineId: string): string | null {
149
+ return lineId.startsWith(PENDING_PREFIX) ? lineId.slice(PENDING_PREFIX.length) : null
150
+ }
151
+
123
152
  type CartDrawerContextValue = {
124
153
  isOpen: boolean
125
154
  open: () => void
@@ -130,40 +159,45 @@ type CartDrawerContextValue = {
130
159
  /**
131
160
  * Dispatch an optimistic update. MUST be called inside a
132
161
  * `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.
162
+ * outside Action props). Prefer `applyOptimistic`.
136
163
  */
137
164
  dispatchOptimistic: (action: OptimisticCartAction) => void
138
165
  /**
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.
166
+ * Dispatches the optimistic action AND awaits the provided server
167
+ * mutation inside a single transition. For a store that owns the server
168
+ * call itself (a Next.js server action); the built-in methods below cover
169
+ * the SDK-wired path and are the fast one.
144
170
  */
145
171
  applyOptimistic: (
146
172
  action: OptimisticCartAction,
147
173
  serverAction: () => Promise<unknown>
148
174
  ) => void
149
175
  /**
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.
176
+ * Add a variant: the line shows at once, the platform confirms behind it.
177
+ * Answers with the confirmed cart or the refusal (require the provider's
178
+ * `client` prop).
156
179
  */
180
+ addLine: (
181
+ variantId: string,
182
+ quantity?: number,
183
+ display?: OptimisticAddDisplay
184
+ ) => Promise<CartMutationResult>
185
+ /** `addLine`, answering only whether it was confirmed. */
157
186
  addItem: (
158
187
  variantId: string,
159
188
  quantity?: number,
160
189
  display?: OptimisticAddDisplay
161
190
  ) => Promise<boolean>
162
- /** POST line-items/:id with `{quantity}` — Cartbase contract: 0 deletes. */
191
+ /**
192
+ * Set a line's quantity (0 deletes, the Cartbase contract). Every click
193
+ * shows at once; fast clicks on one line send the last number.
194
+ */
163
195
  updateQuantity: (lineId: string, quantity: number) => Promise<boolean>
164
196
  removeItem: (lineId: string) => Promise<boolean>
165
197
  /** Re-read the decorated cart (totals/gift-card tender are live per read). */
166
198
  refresh: () => Promise<void>
199
+ /** True when the provider was given a client, so the methods above reach the platform. */
200
+ canMutate: boolean
167
201
  labels: CartDrawerLabels
168
202
  hrefs: CartDrawerHrefs
169
203
  }
@@ -176,10 +210,12 @@ const CartDrawerContext = createContext<CartDrawerContextValue>({
176
210
  cart: null,
177
211
  dispatchOptimistic: () => {},
178
212
  applyOptimistic: () => {},
213
+ addLine: async () => ({ ok: false, error: new Error("No CartDrawerProvider with a client is mounted") }),
179
214
  addItem: async () => false,
180
215
  updateQuantity: async () => false,
181
216
  removeItem: async () => false,
182
217
  refresh: async () => {},
218
+ canMutate: false,
183
219
  labels: defaultCartDrawerLabels,
184
220
  hrefs: defaultHrefs,
185
221
  })
@@ -192,7 +228,11 @@ export function useCartDrawer(): CartDrawerContextValue {
192
228
  * Reducer for the useOptimistic cart. Pure function. Returns the next
193
229
  * cart shape after applying the action. Falls back to a synthetic empty
194
230
  * 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).
231
+ * first-add (the confirmed cart replaces it).
232
+ *
233
+ * A change aimed at a placeholder line also finds the real line of the
234
+ * same variant: the confirmed cart can arrive while the change is still
235
+ * pending, and the line must not come back or lose its new number.
196
236
  *
197
237
  * Exported for unit tests (pure logic, no React).
198
238
  */
@@ -201,10 +241,6 @@ export function reduceCart(
201
241
  action: OptimisticCartAction
202
242
  ): Cart | null {
203
243
  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
244
  if (action.type !== "add") return current
209
245
  return {
210
246
  id: "optimistic-cart",
@@ -237,16 +273,20 @@ export function reduceCart(
237
273
  }
238
274
  }
239
275
  case "remove": {
276
+ const variant = pendingVariant(action.lineId)
240
277
  return {
241
278
  ...current,
242
- items: (current.items ?? []).filter((i) => i.id !== action.lineId),
279
+ items: (current.items ?? []).filter(
280
+ (i) => i.id !== action.lineId && !(variant && i.variant_id === variant)
281
+ ),
243
282
  }
244
283
  }
245
284
  case "update_quantity": {
285
+ const variant = pendingVariant(action.lineId)
246
286
  return {
247
287
  ...current,
248
288
  items: (current.items ?? []).map((i) =>
249
- i.id === action.lineId
289
+ i.id === action.lineId || (variant && i.variant_id === variant)
250
290
  ? {
251
291
  ...i,
252
292
  quantity: action.quantity,
@@ -260,18 +300,19 @@ export function reduceCart(
260
300
  }
261
301
 
262
302
  /**
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.
303
+ * Build a synthetic line item from an "add" action: a placeholder id and
304
+ * `pending: true` (see `isPendingLine`). The confirmed cart replaces it.
305
+ * Cast through `unknown` because `CartLineItem` has many fields we don't
306
+ * have at click time (created_at, tax_lines, adjustments…) — the renderer
307
+ * is lenient about missing optional fields.
268
308
  */
269
309
  export function makeOptimisticLine(
270
310
  action: Extract<OptimisticCartAction, { type: "add" }>
271
311
  ): CartLineItem {
272
312
  const unitPrice = action.unit_price ?? 0
273
313
  return {
274
- id: `optimistic-${action.variant_id}`,
314
+ id: `${PENDING_PREFIX}${action.variant_id}`,
315
+ pending: true,
275
316
  variant_id: action.variant_id,
276
317
  product_id: action.product_id,
277
318
  product_title: action.product_title,
@@ -285,6 +326,26 @@ export function makeOptimisticLine(
285
326
  } as unknown as CartLineItem
286
327
  }
287
328
 
329
+ /** The per-action fields every failure report carries. */
330
+ function failureContext(action: OptimisticCartAction, err: unknown): Record<string, unknown> {
331
+ const err_ = err as { message?: string; name?: string; code?: string } | null
332
+ const ctx: Record<string, unknown> = { action_type: action.type }
333
+ if (action.type === "add") {
334
+ ctx.quantity = action.quantity
335
+ ctx.product_id = action.product_id
336
+ ctx.variant_id = action.variant_id
337
+ } else if (action.type === "remove") {
338
+ ctx.line_id = action.lineId
339
+ } else {
340
+ ctx.line_id = action.lineId
341
+ ctx.quantity = action.quantity
342
+ }
343
+ ctx.err_name = err_?.name
344
+ ctx.err_code = err_?.code
345
+ ctx.err_message = err_?.message ?? String(err)
346
+ return ctx
347
+ }
348
+
288
349
  export function CartDrawerProvider({
289
350
  cart,
290
351
  client,
@@ -298,11 +359,12 @@ export function CartDrawerProvider({
298
359
  /**
299
360
  * Server-fetched cart snapshot (RSC/server-action pattern). When the
300
361
  * 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.
362
+ * snapshot, unless a change of the provider's own is still on its way.
363
+ * Leave it out and pass `client` + `cartId` to read the cart in the
364
+ * browser instead, so the page never waits for it.
303
365
  */
304
366
  cart?: Cart | null
305
- /** StorefrontClient for the SDK-wired mutations (addItem/updateQuantity/removeItem/refresh). */
367
+ /** StorefrontClient for the SDK-wired mutations (addLine/addItem/updateQuantity/removeItem/refresh). */
306
368
  client?: StorefrontClient
307
369
  /** Known cart id to retrieve when no `cart` snapshot is passed. */
308
370
  cartId?: string | null
@@ -326,31 +388,50 @@ export function CartDrawerProvider({
326
388
  const [isOpen, setIsOpen] = useState(false)
327
389
  const prevItemCount = useRef(0)
328
390
 
391
+ // The queue every SDK change goes through, and the latest confirmed cart
392
+ // it reads when a change's turn comes (state lags a render behind).
393
+ const queue = useRef<CartMutationQueue | null>(null)
394
+ if (!queue.current) queue.current = createCartMutationQueue()
395
+ const latest = useRef<Cart | null>(cart ?? null)
396
+
329
397
  // 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.
398
+ // mutation response and by prop updates from server refreshes.
332
399
  const [serverCart, setServerCart] = useState<Cart | null>(cart ?? null)
333
400
  useEffect(() => {
334
- if (cart !== undefined) setServerCart(cart)
401
+ if (cart === undefined) return
402
+ // A refresh carrying the cart as it was must not overwrite an answer
403
+ // still on its way; the queue's own answers are newer.
404
+ if (queue.current?.busy()) return
405
+ latest.current = cart
406
+ setServerCart(cart)
335
407
  }, [cart])
336
408
 
337
- // Client+cartId mode: retrieve the decorated cart on mount when no
338
- // snapshot was provided.
409
+ // A confirmed cart becomes the snapshot inside a transition, so it lands
410
+ // together with the end of the change that asked for it and a pending
411
+ // line is never shown twice.
412
+ const adopt = useCallback((next: Cart) => {
413
+ latest.current = next
414
+ startTransition(() => setServerCart(next))
415
+ }, [])
416
+
417
+ // Client+cartId mode: read the cart on mount, through the queue, so an
418
+ // add made before the read answers goes to this cart, not a new one.
339
419
  useEffect(() => {
340
420
  if (cart !== undefined || !client || !cartId) return
341
421
  let cancelled = false
342
- sdkRetrieveCart(client, cartId)
343
- .then((res) => {
344
- if (!cancelled) setServerCart(res.cart)
345
- })
346
- .catch(() => {
422
+ void queue.current!.run(async () => {
423
+ try {
424
+ const res = await sdkRetrieveCart(client, cartId)
425
+ if (!cancelled) adopt(res.cart)
426
+ } catch {
347
427
  // 404 cart_not_found (expired/foreign id) → stay empty; the next
348
- // addItem creates a fresh cart.
349
- })
428
+ // addLine creates a fresh cart.
429
+ }
430
+ })
350
431
  return () => {
351
432
  cancelled = true
352
433
  }
353
- }, [cart, client, cartId])
434
+ }, [cart, client, cartId, adopt])
354
435
 
355
436
  const [optimisticCart, dispatchOptimistic] = useOptimistic(
356
437
  serverCart,
@@ -398,149 +479,179 @@ export function CartDrawerProvider({
398
479
  const close = useCallback(() => setIsOpen(false), [])
399
480
  const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
400
481
 
482
+ /** The single funnel for every failed change: console + `onOptimisticError`. */
483
+ const report = useCallback(
484
+ (action: OptimisticCartAction, err: unknown) => {
485
+ // eslint-disable-next-line no-console
486
+ console.error("[cart-optimistic]", action.type, err)
487
+ try {
488
+ onOptimisticError?.({ actionType: action.type, error: err, context: failureContext(action, err) })
489
+ } catch {
490
+ // The ops funnel itself must never break the UI.
491
+ }
492
+ },
493
+ [onOptimisticError]
494
+ )
495
+
401
496
  /**
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.
497
+ * Show an action for as long as its work runs. useOptimistic setters
498
+ * MUST be called inside a transition; the transition stays pending for
499
+ * the whole round-trip and the action falls away when it ends, by which
500
+ * time the confirmed cart carries it (or, on failure, it simply reverts).
501
+ * `work` settles without throwing; its answer is passed through.
414
502
  */
415
- const runOptimistic = useCallback(
416
- (
417
- action: OptimisticCartAction,
418
- work: () => Promise<unknown>
419
- ): Promise<boolean> =>
420
- new Promise<boolean>((resolve) => {
503
+ const showWhile = useCallback(
504
+ <T,>(action: OptimisticCartAction, work: Promise<T>): Promise<T> =>
505
+ new Promise<T>((resolve) => {
421
506
  startTransition(async () => {
422
507
  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
- }
508
+ resolve(await work)
450
509
  })
451
510
  }),
452
- [dispatchOptimistic, onOptimisticError]
511
+ [dispatchOptimistic]
453
512
  )
454
513
 
514
+ /** The store-owned path: its own server call, reverted on throw. */
455
515
  const applyOptimistic = useCallback(
456
516
  (action: OptimisticCartAction, serverAction: () => Promise<unknown>) => {
457
- void runOptimistic(action, serverAction)
517
+ void showWhile(
518
+ action,
519
+ serverAction().then(
520
+ () => true,
521
+ (err) => {
522
+ report(action, err)
523
+ return false
524
+ }
525
+ )
526
+ )
458
527
  },
459
- [runOptimistic]
528
+ [showWhile, report]
460
529
  )
461
530
 
462
- /** Adopt a server-confirmed decorated cart. */
531
+ /** Adopt a server-confirmed decorated cart and tell the store. */
463
532
  const confirm = useCallback(
464
533
  (next: Cart) => {
465
- setServerCart(next)
534
+ adopt(next)
466
535
  onCartChange?.(next)
467
536
  },
468
- [onCartChange]
537
+ [adopt, onCartChange]
469
538
  )
470
539
 
471
- const addItem = useCallback(
540
+ /** The line a change is for, as the latest confirmed cart knows it; null when it is gone. */
541
+ const lineNow = useCallback((lineId: string): string | null => {
542
+ const items = latest.current?.items ?? []
543
+ const variant = pendingVariant(lineId)
544
+ if (variant) return items.find((i) => i.variant_id === variant)?.id ?? null
545
+ return items.some((i) => i.id === lineId) ? lineId : null
546
+ }, [])
547
+
548
+ const addLine = useCallback(
472
549
  (
473
550
  variantId: string,
474
551
  quantity = 1,
475
552
  display: OptimisticAddDisplay = {}
476
- ): Promise<boolean> => {
477
- if (!client) return Promise.resolve(false)
553
+ ): Promise<CartMutationResult> => {
554
+ if (!client) {
555
+ return Promise.resolve({ ok: false, error: new Error("CartDrawerProvider has no client") })
556
+ }
478
557
  const action: OptimisticCartAction = {
479
558
  type: "add",
480
559
  variant_id: variantId,
481
560
  quantity,
482
561
  ...display,
483
562
  }
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
- })
563
+ const work = queue.current!.run(async (): Promise<CartMutationResult> => {
564
+ try {
565
+ const cartId = latest.current?.id
566
+ const res = cartId
567
+ ? await sdkAddLineItem(client, cartId, { variant_id: variantId, quantity })
568
+ : // First add with no cart: create one with the item in a single
569
+ // call. region_id falls back to the store default server-side.
570
+ await sdkCreateCart(client, { items: [{ variant_id: variantId, quantity }] })
498
571
  confirm(res.cart)
572
+ return { ok: true, cart: res.cart }
573
+ } catch (error) {
574
+ report(action, error)
575
+ return { ok: false, error }
499
576
  }
500
577
  })
578
+ return showWhile(action, work)
501
579
  },
502
- [client, serverCart?.id, runOptimistic, confirm]
580
+ [client, confirm, report, showWhile]
581
+ )
582
+
583
+ const addItem = useCallback(
584
+ (variantId: string, quantity = 1, display: OptimisticAddDisplay = {}): Promise<boolean> =>
585
+ addLine(variantId, quantity, display).then((result) => result.ok),
586
+ [addLine]
503
587
  )
504
588
 
505
589
  const updateQuantity = useCallback(
506
590
  (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 })
591
+ if (!client) return Promise.resolve(false)
592
+ const work = queue.current!.quantity(lineId, quantity, async (latestQuantity) => {
593
+ const id = lineNow(lineId)
594
+ const cartId = latest.current?.id
595
+ if (!id || !cartId) return false
596
+ try {
597
+ // Cartbase contract: body is `{quantity}` only; 0 deletes the line.
598
+ const res = await sdkUpdateLineItem(client, cartId, id, { quantity: latestQuantity })
515
599
  confirm(res.cart)
600
+ return true
601
+ } catch (err) {
602
+ report({ type: "update_quantity", lineId, quantity: latestQuantity }, err)
603
+ return false
516
604
  }
605
+ })
606
+ return showWhile(
607
+ { type: "update_quantity", lineId, quantity },
608
+ work.then((sent) => sent === true)
517
609
  )
518
610
  },
519
- [client, serverCart?.id, runOptimistic, confirm]
611
+ [client, lineNow, confirm, report, showWhile]
520
612
  )
521
613
 
522
614
  const removeItem = useCallback(
523
615
  (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)
616
+ if (!client) return Promise.resolve(false)
617
+ const action: OptimisticCartAction = { type: "remove", lineId }
618
+ // A quantity change still waiting for this line has nothing to change.
619
+ queue.current!.forget(lineId)
620
+ const work = queue.current!.run(async () => {
621
+ const id = lineNow(lineId)
622
+ const cartId = latest.current?.id
623
+ // Already gone (or never confirmed): nothing to delete.
624
+ if (!id || !cartId) return true
625
+ try {
626
+ const res = await sdkDeleteLineItem(client, cartId, id)
627
+ confirm(res.cart)
628
+ return true
629
+ } catch (err) {
630
+ report(action, err)
631
+ return false
632
+ }
529
633
  })
634
+ return showWhile(action, work)
530
635
  },
531
- [client, serverCart?.id, runOptimistic, confirm]
636
+ [client, lineNow, confirm, report, showWhile]
532
637
  )
533
638
 
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])
639
+ const refresh = useCallback(
640
+ (): Promise<void> => {
641
+ if (!client) return Promise.resolve()
642
+ return queue.current!.run(async () => {
643
+ const id = latest.current?.id
644
+ if (!id) return
645
+ try {
646
+ const res = await sdkRetrieveCart(client, id)
647
+ confirm(res.cart)
648
+ } catch {
649
+ // Keep the last snapshot on transient failure.
650
+ }
651
+ })
652
+ },
653
+ [client, confirm]
654
+ )
544
655
 
545
656
  // The mounted language is the default (2026-09-14): a store that mounts
546
657
  // StorefrontLocaleProvider has nothing to hand over here, and a `labels`
@@ -565,10 +676,12 @@ export function CartDrawerProvider({
565
676
  cart: optimisticCart,
566
677
  dispatchOptimistic,
567
678
  applyOptimistic,
679
+ addLine,
568
680
  addItem,
569
681
  updateQuantity,
570
682
  removeItem,
571
683
  refresh,
684
+ canMutate: Boolean(client),
572
685
  labels,
573
686
  hrefs,
574
687
  }}