@cartbase/storefront 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/package.json +3 -1
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +367 -168
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/index.ts +73 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -0
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/checkout/use-checkout-orchestration.ts +1604 -1592
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/cookie-names.ts +14 -0
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
- package/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
|
@@ -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
|
|
33
|
-
*
|
|
34
|
-
* provider manages open/close state,
|
|
35
|
-
* locks body scroll while open, and
|
|
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
|
-
*
|
|
38
|
-
* in React 19's `useOptimistic
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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).
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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
|
|
264
|
-
*
|
|
265
|
-
* Cast through `unknown` because `CartLineItem` has many fields we
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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:
|
|
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
|
|
302
|
-
*
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
335
|
-
|
|
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:
|
|
338
|
-
//
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
//
|
|
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
|
-
*
|
|
403
|
-
* inside a transition
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
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
|
|
416
|
-
(
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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<
|
|
477
|
-
if (!client)
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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,
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
const res = await sdkUpdateLineItem(client,
|
|
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,
|
|
684
|
+
[client, lineNow, confirm, report, showWhile, end]
|
|
520
685
|
)
|
|
521
686
|
|
|
522
687
|
const removeItem = useCallback(
|
|
523
688
|
(lineId: string): Promise<boolean> => {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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,
|
|
713
|
+
[client, lineNow, confirm, report, showWhile, end]
|
|
532
714
|
)
|
|
533
715
|
|
|
534
|
-
const refresh = useCallback(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
}}
|