@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.
- package/LICENSE +21 -21
- package/package.json +258 -256
- 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 +277 -164
- 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 +71 -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 +1592 -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/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,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).
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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
|
|
264
|
-
*
|
|
265
|
-
* Cast through `unknown` because `CartLineItem` has many fields we
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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:
|
|
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
|
|
302
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
338
|
-
//
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
416
|
-
(
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
534
|
+
adopt(next)
|
|
466
535
|
onCartChange?.(next)
|
|
467
536
|
},
|
|
468
|
-
[onCartChange]
|
|
537
|
+
[adopt, onCartChange]
|
|
469
538
|
)
|
|
470
539
|
|
|
471
|
-
|
|
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<
|
|
477
|
-
if (!client)
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const res =
|
|
488
|
-
variant_id: variantId,
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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,
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
const res = await sdkUpdateLineItem(client,
|
|
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,
|
|
611
|
+
[client, lineNow, confirm, report, showWhile]
|
|
520
612
|
)
|
|
521
613
|
|
|
522
614
|
const removeItem = useCallback(
|
|
523
615
|
(lineId: string): Promise<boolean> => {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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,
|
|
636
|
+
[client, lineNow, confirm, report, showWhile]
|
|
532
637
|
)
|
|
533
638
|
|
|
534
|
-
const refresh = useCallback(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
}}
|