@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
@@ -1,1592 +1,1592 @@
1
- "use client"
2
-
3
- import {
4
- useCallback,
5
- useEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- type ChangeEvent,
10
- } from "react"
11
-
12
- import type { StorefrontClient } from "../api/http"
13
- import { countryName } from "../lib/country-name"
14
- import { listCountries, type StoreCountry } from "../api/regions"
15
- import {
16
- completeCart,
17
- updateCart,
18
- type Cart,
19
- type CartAddress,
20
- type CompletedOrder,
21
- type UpdateCartInput,
22
- } from "../api/carts"
23
- import {
24
- calculateShippingOption,
25
- prepareCheckout,
26
- refreshPaymentIfTerminal as refreshPaymentIfTerminalApi,
27
- reportCheckoutError,
28
- syncPaymentAmount as syncPaymentAmountApi,
29
- isMethodEntry,
30
- type PrepareCheckoutInput,
31
- type RefreshPaymentResult,
32
- type StorePaymentEntry,
33
- type StorePaymentMethodEntry,
34
- type StoreShippingOption,
35
- type SyncPaymentAmountResult,
36
- } from "../api/checkout"
37
- import {
38
- updateMe,
39
- type CustomerAddress,
40
- type StoreCustomer,
41
- type UpdateCustomerInput,
42
- } from "../api/customers"
43
- import { isStripeLike } from "../lib/payment-constants"
44
- import compareAddresses from "./compare-addresses"
45
- import { translateAddressError } from "./address-error-copy"
46
- import { translatePaymentError } from "./payment-error-copy"
47
- import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
48
- import type { EcontOffice } from "./econt-office-selector"
49
- import type { BoxNowLocker } from "../api/integrations"
50
-
51
- /**
52
- * useCheckoutOrchestration — single source of truth for checkout-page
53
- * orchestration. All race-condition guards, payment-session lifecycle,
54
- * shipping/address mutations, carrier metadata, completed-cart detection
55
- * and 3DS-return handling live here. Library's CheckoutClient and any
56
- * store's custom orchestration component consume this hook — they own only
57
- * the layout and any store-specific concerns (tracking, sessionStorage form
58
- * persistence, custom summary).
59
- *
60
- * Ported from `@1click/ui/src/checkout/use-checkout-orchestration.ts`
61
- * (v2.3.1, the deferred-intent architecture) with the Cartbase data seam:
62
- *
63
- * - Server actions → `@cartbase/storefront/api` calls on a caller-supplied
64
- * `StorefrontClient` (`updateCart`, `prepareCheckout`,
65
- * `syncPaymentAmount`, `refreshPaymentIfTerminal`, `completeCart`,
66
- * `calculateShippingOption`, `updateMe`).
67
- * - `placeOrder` (server action with baked-in redirect) →
68
- * `completeCart()` + template navigation (`orderConfirmedPath` with
69
- * `{id}`/`{country}` substitution) or the `onOrderPlaced` callback.
70
- * - **Zero-remainder gift path** (Cartbase gift-card tender,
71
- * docs/storefront/checkout.md): when applied gift cards cover the whole
72
- * total, `prepareCheckout` returns `client_secret: null` +
73
- * `provider_id: null` and the card path SKIPS `stripe.confirmPayment`
74
- * entirely — the cart completes on the gift session alone. The @1click
75
- * original threw on a missing client_secret; Cartbase treats
76
- * null-secret + null-provider as the documented gift path.
77
- * - The payment method fee: CART-LEVEL decoration
78
- * (`payment_method_fee_total` / `payment_method_fee_label`, folded into
79
- * `cart.total` while a live method session exists) — not a
80
- * metadata-flagged line item. ANY method may carry a fee; the optimistic
81
- * prediction reads `fee_amount` off the selected LISTING entry (the
82
- * integrations `cod` block died 2026-08-11).
83
- * - Processor ids: `pp_stripe` exactly (lib/payment-constants); merchant
84
- * methods have no provider id — sessions ride `payment_method_id`.
85
- * - `logCheckoutError`/`logEvent` (Supabase-side sinks in @1click) have no
86
- * Cartbase store endpoint — the hook takes an optional `logError` callback
87
- * so stores wire their own sink; all production log points are kept.
88
- * - Cartbase regions carry NO embedded countries array (api/regions.ts
89
- * divergence note) — the country list is a `countries` option with a
90
- * `countryCode` single-entry fallback.
91
- *
92
- * Why a hook and not a base component:
93
- * - Stores fork the layout for legitimate reasons (tracking, custom
94
- * summary). They should NOT have to fork the orchestration logic too —
95
- * that's how a fork DROPPED the session guard + syncPaymentAmount flow
96
- * during the v1.15 → v1.16 cycle, producing zombie Stripe sessions on
97
- * rapid payment-tab toggles. Centralizing the logic here makes that
98
- * class of fork-rot bug structurally impossible.
99
- */
100
-
101
- /** A payable entry the hook accepts: a processor row (`{id}`) or a
102
- * merchant-method row (`{payment_method_id, name, kind}`) — the two wire
103
- * shapes of GET /api/store/payment-providers since the pp_* kill. */
104
- export type PaymentProviderLike = StorePaymentEntry | { id: string }
105
-
106
- export type CheckoutLogError = (
107
- errorType: string,
108
- message: string,
109
- context?: Record<string, unknown>
110
- ) => void
111
-
112
- export type UseCheckoutOrchestrationOptions = {
113
- /** The SDK transport — all server calls go through it. */
114
- client: StorefrontClient
115
- cart: Cart
116
- customer: StoreCustomer | null
117
- availableShippingMethods: StoreShippingOption[] | null
118
- availablePaymentMethods: PaymentProviderLike[] | null
119
- /** Default country code when the cart has no shipping address yet. */
120
- countryCode?: string
121
- /**
122
- * Countries offered in the address form. Cartbase regions do NOT embed a
123
- * countries array (store-API divergence), so the host app supplies the
124
- * list; omitted → a single entry derived from `countryCode`.
125
- */
126
- countries?: Array<{ iso_2: string; display_name: string }>
127
- /**
128
- * Per-store rule for filtering payment methods based on the currently
129
- * selected shipping option (e.g. hide COD when BoxNow is selected).
130
- */
131
- paymentMethodFilter?: (
132
- methods: PaymentProviderLike[] | null,
133
- selectedShippingOption: StoreShippingOption | null
134
- ) => PaymentProviderLike[] | null
135
- /**
136
- * Order-confirmed redirect path template (`{id}` → order.id,
137
- * `{country}` → shipping country). Falls back to the CheckoutProvider
138
- * context value.
139
- */
140
- orderConfirmedPath?: string
141
- /**
142
- * Called after a successful complete INSTEAD of the default
143
- * `window.location.assign` navigation — stores using next/navigation
144
- * router push their own way.
145
- */
146
- onOrderPlaced?: (order: CompletedOrder) => void
147
- /**
148
- * Tracking-attribution metadata written into `cart.metadata` right
149
- * before complete (consent-gated by the CALLER — pass undefined when
150
- * consent denies). Use `getTrackingAttribution()` from
151
- * `@cartbase/storefront/tracking` + engagement time. Cart completion
152
- * copies cart.metadata to order.metadata so the backend `order.placed`
153
- * forwarder inherits fbp/fbc/ga signals (docs/storefront/
154
- * integrations.md).
155
- */
156
- resolveTrackingMetadata?: () => Record<string, unknown> | undefined
157
- /**
158
- * Operational-visibility sink. Called with (errorType, message, context).
159
- * DEFAULTS to reporting into the platform's checkout error log
160
- * (POST /api/store/checkout-errors, step `browser`) so browser-side money
161
- * failures reach the merchant without any wiring — a platform guarantee,
162
- * not a merchant chore (money-chain-hardening, 2026-08-24). Override to
163
- * add your own sink; fire-and-forget either way.
164
- */
165
- logError?: CheckoutLogError
166
- /**
167
- * Verbose [buy-click] console output for local debugging. OFF by default:
168
- * the unguarded dumps used to print the full prepare-checkout payload —
169
- * customer name, phone, email, address — into every customer's browser
170
- * console. Redacted sink events fire regardless of this flag.
171
- */
172
- debug?: boolean
173
- }
174
-
175
- /**
176
- * Address form fields the orchestration tracks. Matches the keys the
177
- * library's `CheckoutAddressForm` writes via `name=...`.
178
- *
179
- * `shipping_address.phone` is required — Bulgarian carriers (Econt,
180
- * BoxNow) need it to contact the customer; it's the courier's primary
181
- * recovery channel when the address is ambiguous. (Cartbase's
182
- * prepare-checkout schema requires it too — docs/storefront/checkout.md.)
183
- */
184
- const REQUIRED_ADDRESS_FIELDS = [
185
- "email",
186
- "shipping_address.country_code",
187
- "shipping_address.first_name",
188
- "shipping_address.last_name",
189
- "shipping_address.address_1",
190
- "shipping_address.city",
191
- "shipping_address.postal_code",
192
- "shipping_address.phone",
193
- ] as const
194
-
195
- /**
196
- * Debounce window for the auto-save effect — long enough that a user
197
- * typing through fields without blurring triggers ONE save at the end,
198
- * short enough that clicking a shipping option after the last keystroke
199
- * doesn't race the persistence (the pre-action `flushAddressSave` is
200
- * the belt-and-braces backstop for that race).
201
- */
202
- const ADDRESS_AUTO_SAVE_DEBOUNCE_MS = 600
203
-
204
- /** The offline tab's tender: a merchant METHOD entry (pp_* kill). The COD
205
- * method (kind 'cod') wins; any manual method serves as the offline tab
206
- * when no COD switch is on. The entry carries its own fee for prediction. */
207
- const findOfflineMethod = (
208
- entries: PaymentProviderLike[] | null | undefined
209
- ): StorePaymentMethodEntry | null => {
210
- const methods = (entries ?? []).filter(
211
- (m): m is Extract<PaymentProviderLike, { payment_method_id: string }> =>
212
- "payment_method_id" in m && isMethodEntry(m as StorePaymentEntry)
213
- )
214
- return methods.find((m) => m.kind === "cod") ?? methods[0] ?? null
215
- }
216
-
217
- /**
218
- * Snapshot the address-relevant subset of formData. Used to skip
219
- * redundant saves: if the snapshot matches what was last persisted,
220
- * there's nothing to do. JSON-stringify keeps comparison cheap and
221
- * correct (string keys + string values, no nested objects).
222
- */
223
- function snapshotAddressForm(
224
- formData: Record<string, string>,
225
- sameAsBilling: boolean
226
- ): string {
227
- return JSON.stringify({
228
- email: formData.email ?? "",
229
- first_name: formData["shipping_address.first_name"] ?? "",
230
- last_name: formData["shipping_address.last_name"] ?? "",
231
- address_1: formData["shipping_address.address_1"] ?? "",
232
- company: formData["shipping_address.company"] ?? "",
233
- postal_code: formData["shipping_address.postal_code"] ?? "",
234
- city: formData["shipping_address.city"] ?? "",
235
- country_code: formData["shipping_address.country_code"] ?? "",
236
- province: formData["shipping_address.province"] ?? "",
237
- phone: formData["shipping_address.phone"] ?? "",
238
- company_name: formData.company_name ?? "",
239
- company_vat: formData.company_vat ?? "",
240
- company_mol: formData.company_mol ?? "",
241
- company_address: formData.company_address ?? "",
242
- sameAsBilling,
243
- })
244
- }
245
-
246
- /** Resolve the confirmed-order path template. Pure — unit-tested. */
247
- export function resolveOrderConfirmedPath(
248
- template: string,
249
- order: { id: string },
250
- countryCode?: string | null
251
- ): string {
252
- return template
253
- .replace("{id}", order.id)
254
- .replace("{country}", (countryCode ?? "").toLowerCase())
255
- }
256
-
257
- export function useCheckoutOrchestration({
258
- client,
259
- cart,
260
- customer,
261
- availableShippingMethods,
262
- availablePaymentMethods,
263
- countryCode = "",
264
- countries,
265
- paymentMethodFilter,
266
- orderConfirmedPath: orderConfirmedPathProp,
267
- onOrderPlaced,
268
- resolveTrackingMetadata,
269
- logError: logErrorProp,
270
- debug = false,
271
- }: UseCheckoutOrchestrationOptions) {
272
- // The store's language, for the sentences a shopper reads when a save
273
- // fails. English unless a locale pack is mounted above.
274
- const checkoutLabels = useCheckoutLabels()
275
- // The default sink: the platform's own checkout error log. Browser money
276
- // failures (Stripe confirm errors, failed 3DS returns, place-order
277
- // rejections) land in checkout_error_logs step `browser` with no merchant
278
- // wiring. The two per-click debug snapshots are gated behind `debug` and
279
- // never sent by default — the sink is for FAILURES, not telemetry.
280
- const logError: CheckoutLogError = useCallback(
281
- (errorType, message, context) => {
282
- if (logErrorProp) {
283
- logErrorProp(errorType, message, context)
284
- return
285
- }
286
- void reportCheckoutError(client, {
287
- error_type: errorType,
288
- message,
289
- cart_id: cart?.id,
290
- context,
291
- })
292
- },
293
- // eslint-disable-next-line react-hooks/exhaustive-deps
294
- [logErrorProp, client, cart?.id]
295
- )
296
- // Debug-gated console output — see the `debug` option's doc.
297
- const dbg = debug
298
- ? // eslint-disable-next-line no-console
299
- (...args: unknown[]) => console.log(...args)
300
- : () => {}
301
- // Resolve the order-confirmed path. Prop wins; otherwise fall back to
302
- // the value provided by CheckoutProvider context.
303
- const contextOrderConfirmedPath = useOrderConfirmedPath()
304
- const orderConfirmedPath =
305
- orderConfirmedPathProp ?? contextOrderConfirmedPath
306
-
307
- // ── Completed-cart detection ────────────────────────────────────────
308
- // Surfaced as a flag so the page-level server component can redirect
309
- // before any client-side mutation runs. The cart cookie can outlive a
310
- // completed checkout (back button after order, second tab on the same
311
- // session) and the storefront previously rendered the full form on
312
- // top — leading to a second complete attempt that returns a raw
313
- // English error.
314
- const cartIsCompleted = useMemo(
315
- () => Boolean(cart?.completed_at),
316
- [cart]
317
- )
318
-
319
- // ── Address form ────────────────────────────────────────────────────
320
- const [addressError, setAddressError] = useState<string | null>(null)
321
- const [, setAddressSaving] = useState(false)
322
- const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
323
- // In-flight guard against concurrent saveAddress calls. Without this,
324
- // rapid blur events (email → tab → phone → tab) queue parallel
325
- // updateCart writes; the later-resolving one wins, and any field the
326
- // user edited between the two clicks gets reverted to the earlier
327
- // snapshot.
328
- const addressSavingRef = useRef(false)
329
- // Snapshot of the formData that was last successfully persisted.
330
- // Used to skip redundant saves and to detect when the user changed
331
- // form fields while a save was in flight (so we re-fire after).
332
- const lastSavedSnapshotRef = useRef<string>("")
333
- // When a save is in flight and formData changes, we set this to the
334
- // latest snapshot. The in-flight save's `finally` checks it and
335
- // re-fires saveAddress so the latest form state always wins.
336
- const pendingSnapshotRef = useRef<string | null>(null)
337
-
338
- const [formData, setFormData] = useState<Record<string, string>>(() => ({
339
- "shipping_address.first_name":
340
- cart?.shipping_address?.first_name || customer?.first_name || "",
341
- "shipping_address.last_name":
342
- cart?.shipping_address?.last_name || customer?.last_name || "",
343
- "shipping_address.address_1": cart?.shipping_address?.address_1 || "",
344
- "shipping_address.company": cart?.shipping_address?.company || "",
345
- "shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
346
- "shipping_address.city": cart?.shipping_address?.city || "",
347
- "shipping_address.country_code":
348
- cart?.shipping_address?.country_code || countryCode || "",
349
- "shipping_address.province": cart?.shipping_address?.province || "",
350
- "shipping_address.phone": cart?.shipping_address?.phone || "",
351
- email: cart?.email || customer?.email || "",
352
- company_name: (cart?.metadata?.company_name as string) || "",
353
- company_vat: (cart?.metadata?.company_vat as string) || "",
354
- company_mol: (cart?.metadata?.company_mol as string) || "",
355
- company_address: (cart?.metadata?.company_address as string) || "",
356
- }))
357
-
358
- const [sameAsBilling] = useState(
359
- cart?.shipping_address && cart?.billing_address
360
- ? compareAddresses(
361
- cart.shipping_address as unknown as Record<string, unknown>,
362
- cart.billing_address as unknown as Record<string, unknown>
363
- )
364
- : true
365
- )
366
-
367
- /**
368
- * The store's own country list, fetched when the app did not pass one.
369
- *
370
- * Until 2026-09-13 an app that passed nothing got a ONE-ENTRY list built
371
- * from `countryCode`, and the address form renders a read-only box for a
372
- * single entry, so every scaffolded store shipped a checkout offering
373
- * exactly one country that the shopper could not change. The list is the
374
- * platform's to answer (`GET /api/store/countries`: the store's Markets
375
- * when it has declared any, the whole catalogue when it has not), so the
376
- * hook asks instead of guessing.
377
- *
378
- * A failure here must never block a checkout: the `countryCode` fallback
379
- * below still stands, and the error is reported through `logError`.
380
- */
381
- const [storeCountries, setStoreCountries] = useState<StoreCountry[] | null>(null)
382
- const appSuppliedCountries = !!(countries && countries.length)
383
-
384
- useEffect(() => {
385
- if (appSuppliedCountries) return
386
- let alive = true
387
- listCountries(client)
388
- .then((res) => {
389
- if (alive) setStoreCountries(res.countries)
390
- })
391
- .catch((e: unknown) => {
392
- logError(
393
- "country_list_failed",
394
- e instanceof Error ? e.message : "could not load the country list"
395
- )
396
- })
397
- return () => {
398
- alive = false
399
- }
400
- }, [client, appSuppliedCountries, logError])
401
-
402
- // The app's own list wins, then the store's, then the single-country
403
- // fallback. This is the authority for both the select options and the
404
- // saved-address region filter.
405
- const regionCountries = useMemo(
406
- () =>
407
- countries && countries.length
408
- ? countries
409
- : storeCountries && storeCountries.length
410
- ? storeCountries
411
- : countryCode
412
- ? // A fallback country still needs a NAME: this used to be an
413
- // empty string, and the address form rendered a blank country
414
- // field for it (evoo, 2026-09-13).
415
- [{ iso_2: countryCode, display_name: countryName(countryCode) }]
416
- : [],
417
- [countries, storeCountries, countryCode]
418
- )
419
-
420
- const countriesInRegion = useMemo(
421
- () => regionCountries.map((c) => c.iso_2).filter(Boolean),
422
- [regionCountries]
423
- )
424
-
425
- const addressesInRegion = useMemo(
426
- () =>
427
- customer?.addresses?.filter(
428
- (a) =>
429
- !countriesInRegion.length ||
430
- (a.country_code && countriesInRegion.includes(a.country_code))
431
- ),
432
- [customer?.addresses, countriesInRegion]
433
- )
434
-
435
- const setFormAddress = useCallback(
436
- (
437
- address?: Partial<CartAddress> | Partial<CustomerAddress>,
438
- email?: string
439
- ) => {
440
- if (address) {
441
- setFormData((prev) => ({
442
- ...prev,
443
- "shipping_address.first_name": address.first_name || "",
444
- "shipping_address.last_name": address.last_name || "",
445
- "shipping_address.address_1": address.address_1 || "",
446
- "shipping_address.company": address.company || "",
447
- "shipping_address.postal_code": address.postal_code || "",
448
- "shipping_address.city": address.city || "",
449
- "shipping_address.country_code": address.country_code || "",
450
- "shipping_address.province": address.province || "",
451
- "shipping_address.phone": address.phone || "",
452
- }))
453
- }
454
- if (email) setFormData((prev) => ({ ...prev, email }))
455
- },
456
- []
457
- )
458
-
459
- // Re-seed formData from cart when it changes id (e.g. region switch
460
- // creates a fresh cart). Disabled within the same cart id so user's
461
- // in-progress edits aren't clobbered by a refresh after a
462
- // shipping/payment mutation.
463
- useEffect(() => {
464
- if (cart?.shipping_address)
465
- setFormAddress(cart.shipping_address, cart.email ?? undefined)
466
- if (cart && !cart.email && customer?.email)
467
- setFormAddress(undefined, customer.email)
468
- // eslint-disable-next-line react-hooks/exhaustive-deps
469
- }, [cart?.id])
470
-
471
- const allRequiredFilled = REQUIRED_ADDRESS_FIELDS.every(
472
- (f) => (formData[f] ?? "").trim().length > 0
473
- )
474
-
475
- // ── 3-second idle attention cue ────────────────────────────────────
476
- // After 3 seconds of no typing/focus activity AND not all required
477
- // fields filled, flag the empty required fields with a soft-blue
478
- // pulse so the customer knows where to look. Production UX fix
479
- // (2026-05-06) after a real customer reported being stuck on the
480
- // shipping section without realising one address field was empty.
481
- //
482
- // Color: sky-500 (in the Field primitive). Distinct from focus
483
- // (orange/primary) and error (red/destructive).
484
- // Threshold: 3s of no formData change. Resets per-field (a field
485
- // unflags itself the moment it becomes non-empty).
486
- // First load: yes — we want stuck customers to see the cue
487
- // immediately, not only after they've already tried to interact.
488
- const PULSE_IDLE_MS = 3000
489
- const [pulseFields, setPulseFields] = useState<Set<string>>(new Set())
490
- useEffect(() => {
491
- if (allRequiredFilled) {
492
- if (pulseFields.size > 0) setPulseFields(new Set())
493
- return
494
- }
495
- const timer = setTimeout(() => {
496
- const empty = REQUIRED_ADDRESS_FIELDS.filter(
497
- (f) => !((formData[f] ?? "").trim().length > 0)
498
- )
499
- setPulseFields(new Set(empty))
500
- }, PULSE_IDLE_MS)
501
- return () => clearTimeout(timer)
502
- // We intentionally depend on the FULL formData object so any
503
- // keystroke / saved-address selection / blur-driven update resets
504
- // the timer. allRequiredFilled is also tracked so the cue clears
505
- // the moment the last empty required field gets a value.
506
- // eslint-disable-next-line react-hooks/exhaustive-deps
507
- }, [formData, allRequiredFilled])
508
-
509
- // First-render seed of lastSavedSnapshotRef. When a returning user
510
- // lands on /checkout with a cart that already has email + shipping
511
- // address persisted server-side, formData initializes from the cart
512
- // and matches what's already saved. Without this seed, the auto-save
513
- // effect sees ref="" (not yet seeded) vs a populated snapshot and
514
- // schedules a redundant `updateCart` 600ms after mount — delaying the
515
- // Place Order button render by a full round-trip on every reload.
516
- //
517
- // Guard with `cart?.email && cart?.shipping_address?.first_name` so
518
- // we only seed when the cart genuinely has the data persisted; a
519
- // half-populated cart leaves ref="" so the user's first save fires
520
- // normally.
521
- const snapshotSeededRef = useRef(false)
522
- if (
523
- !snapshotSeededRef.current &&
524
- allRequiredFilled &&
525
- cart?.email &&
526
- cart?.shipping_address?.first_name
527
- ) {
528
- snapshotSeededRef.current = true
529
- lastSavedSnapshotRef.current = snapshotAddressForm(formData, sameAsBilling)
530
- }
531
-
532
- const saveAddress = useCallback(async () => {
533
- if (!allRequiredFilled) return
534
- const snapshot = snapshotAddressForm(formData, sameAsBilling)
535
- // Skip redundant saves — if the form hasn't changed since last
536
- // successful persist, don't re-hit the network. Critical for the
537
- // debounced auto-save: every formData change triggers the effect,
538
- // but only meaningful changes should reach the server.
539
- if (snapshot === lastSavedSnapshotRef.current) return
540
- // If a save is already in flight, register this snapshot as
541
- // pending. The in-flight save's `finally` will re-fire saveAddress
542
- // so the latest form state always wins. Without this, formData
543
- // edits that happen during a save get silently dropped.
544
- if (addressSavingRef.current) {
545
- pendingSnapshotRef.current = snapshot
546
- return
547
- }
548
- addressSavingRef.current = true
549
- setAddressSaving(true)
550
- setAddressError(null)
551
- try {
552
- const shippingAddress = {
553
- first_name: formData["shipping_address.first_name"],
554
- last_name: formData["shipping_address.last_name"],
555
- address_1: formData["shipping_address.address_1"],
556
- address_2: "",
557
- company: formData["shipping_address.company"] || "",
558
- postal_code: formData["shipping_address.postal_code"],
559
- city: formData["shipping_address.city"],
560
- country_code: formData["shipping_address.country_code"],
561
- province: formData["shipping_address.province"] || "",
562
- phone: formData["shipping_address.phone"] || "",
563
- }
564
- const addressData: UpdateCartInput = {
565
- shipping_address: shippingAddress,
566
- email: formData.email,
567
- }
568
- if (sameAsBilling) addressData.billing_address = shippingAddress
569
-
570
- const hasCompany = formData.company_name?.trim()
571
- if (hasCompany) {
572
- addressData.metadata = {
573
- ...(cart?.metadata ?? {}),
574
- company_name: formData.company_name,
575
- company_vat: formData.company_vat || "",
576
- company_mol: formData.company_mol || "",
577
- company_address: formData.company_address || "",
578
- }
579
- }
580
-
581
- await updateCart(client, cart.id, addressData)
582
- lastSavedSnapshotRef.current = snapshot
583
-
584
- if (customer) {
585
- // Sync the profile best-effort. Cartbase has first-class company
586
- // fields on the customer (company_name / company_eik —
587
- // api/customers.ts), so the invoice data also lands on the
588
- // profile — an upgrade over the @1click cart-metadata-only
589
- // storage (its KNOWN_ISSUES entry).
590
- const patch: UpdateCustomerInput = {
591
- first_name: formData["shipping_address.first_name"],
592
- last_name: formData["shipping_address.last_name"],
593
- phone: formData["shipping_address.phone"] || undefined,
594
- }
595
- if (hasCompany) {
596
- patch.company_name = formData.company_name
597
- if (formData.company_vat) patch.company_eik = formData.company_vat
598
- }
599
- updateMe(client, patch).catch(() => {})
600
- }
601
- } catch (e: unknown) {
602
- // ── Visibility ─────────────────────────────────────────────────
603
- // Without this log point, address-save failures are completely
604
- // silent operationally. PII discipline: log only structured/
605
- // non-PII fields (country_code, postal_code, flags). No names,
606
- // email, phone, address line, or company VAT/MOL.
607
- const errObj = e instanceof Error ? e : null
608
- logError?.(
609
- "address_save_failed",
610
- errObj?.message ?? String(e),
611
- {
612
- err_name: errObj?.name ?? null,
613
- err_code:
614
- (e as { code?: string } | null)?.code ?? null,
615
- err_status:
616
- (e as { status?: number } | null)?.status ?? null,
617
- cart_id: cart?.id,
618
- customer_id: customer?.id,
619
- all_required_filled: allRequiredFilled,
620
- same_as_billing: sameAsBilling,
621
- has_company: Boolean(formData.company_name?.trim()),
622
- country_code: formData["shipping_address.country_code"] || null,
623
- postal_code: formData["shipping_address.postal_code"] || null,
624
- }
625
- )
626
- // Into the store's own language. The raw error.message is technical
627
- // English or a validation envelope, and neither is acceptable to
628
- // show a shopper at the moment of failure. Unknown errors get the
629
- // generic sentence rather than half-translated text.
630
- setAddressError(translateAddressError(errObj ?? e, checkoutLabels.addressErrors))
631
- } finally {
632
- setAddressSaving(false)
633
- addressSavingRef.current = false
634
- // If formData changed during the save, fire again with the
635
- // latest state. Loops at most once per real user edit because
636
- // the snapshot guard skips duplicates.
637
- const pending = pendingSnapshotRef.current
638
- pendingSnapshotRef.current = null
639
- if (pending && pending !== lastSavedSnapshotRef.current) {
640
- void saveAddressRef.current?.()
641
- }
642
- }
643
- }, [
644
- client,
645
- formData,
646
- allRequiredFilled,
647
- sameAsBilling,
648
- customer,
649
- cart?.id,
650
- cart?.metadata,
651
- logError,
652
- ])
653
-
654
- // Self-reference for the post-save re-fire path. Captured via ref so
655
- // the callback can call the latest version of itself without making
656
- // useCallback's dep list circular.
657
- const saveAddressRef = useRef(saveAddress)
658
- useEffect(() => {
659
- saveAddressRef.current = saveAddress
660
- }, [saveAddress])
661
-
662
- // ── Auto-save effect ───────────────────────────────────────────────
663
- // The single source of truth for "form data → server cart" sync.
664
- // Watches formData and fires saveAddress after a debounce window.
665
- // This is what makes the persistence robust to:
666
- // - Browser autofill (1Password, Bitwarden, Chrome) which can fill
667
- // multiple fields without firing per-field blur events
668
- // - sessionStorage form restore on mount
669
- // - Programmatic / paste-driven fills with no blur
670
- // - Saved-customer-address selection (setFormAddress)
671
- //
672
- // Before this effect, persistence relied on `handleFieldBlur` —
673
- // which made the cart silently empty whenever the form got filled
674
- // by a non-blur path. That's the bug that left cart.email NULL on
675
- // anonymous carts and kept the Place Order button disabled even
676
- // when the form looked complete.
677
- //
678
- // `handleFieldBlur` is preserved as the immediate-save shortcut so
679
- // the typical typing path doesn't wait for the debounce.
680
- useEffect(() => {
681
- if (!allRequiredFilled) return
682
- const snapshot = snapshotAddressForm(formData, sameAsBilling)
683
- if (snapshot === lastSavedSnapshotRef.current) return
684
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
685
- saveTimerRef.current = setTimeout(() => {
686
- saveTimerRef.current = null
687
- void saveAddress()
688
- }, ADDRESS_AUTO_SAVE_DEBOUNCE_MS)
689
- return () => {
690
- if (saveTimerRef.current) {
691
- clearTimeout(saveTimerRef.current)
692
- saveTimerRef.current = null
693
- }
694
- }
695
- }, [formData, allRequiredFilled, sameAsBilling, saveAddress])
696
-
697
- // flushAddressSave — used by shipping/payment selection handlers to
698
- // guarantee the latest form state is persisted BEFORE a state-
699
- // advancing mutation runs. Cancels any pending debounce and awaits
700
- // the save synchronously. Without this, a fast user (clicks Econt
701
- // < 600ms after their last keystroke) advances on a stale cart.
702
- const flushAddressSave = useCallback(async () => {
703
- if (saveTimerRef.current) {
704
- clearTimeout(saveTimerRef.current)
705
- saveTimerRef.current = null
706
- }
707
- if (!allRequiredFilled) return
708
- await saveAddress()
709
- }, [allRequiredFilled, saveAddress])
710
-
711
- const handleFormChange = useCallback(
712
- (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
713
- const updated = { ...formData, [e.target.name]: e.target.value }
714
- setFormData(updated)
715
-
716
- // For selects (country), save immediately on change since there's
717
- // no blur. Reads from `updated` (not `formData`) so the post-set
718
- // value is what gets validated and persisted.
719
- if (e.target.tagName === "SELECT") {
720
- const filled = REQUIRED_ADDRESS_FIELDS.every(
721
- (f) => (updated[f] ?? "").trim().length > 0
722
- )
723
- if (filled) saveAddress()
724
- }
725
- },
726
- [formData, saveAddress]
727
- )
728
-
729
- const handleFieldBlur = useCallback(() => {
730
- if (allRequiredFilled) saveAddress()
731
- }, [allRequiredFilled, saveAddress])
732
-
733
- const addressReady =
734
- allRequiredFilled || !!(cart?.shipping_address && cart?.email)
735
-
736
- const addressInput = useMemo(
737
- () =>
738
- ({
739
- first_name: formData["shipping_address.first_name"],
740
- last_name: formData["shipping_address.last_name"],
741
- address_1: formData["shipping_address.address_1"],
742
- company: formData["shipping_address.company"],
743
- postal_code: formData["shipping_address.postal_code"],
744
- city: formData["shipping_address.city"],
745
- country_code: formData["shipping_address.country_code"],
746
- province: formData["shipping_address.province"],
747
- phone: formData["shipping_address.phone"],
748
- }) as Record<string, string>,
749
- [formData]
750
- )
751
-
752
- // ── Shipping ────────────────────────────────────────────────────────
753
- const [shippingLoading] = useState(false)
754
- const [shippingError, setShippingError] = useState<string | null>(null)
755
- const [optimisticShippingCost, setOptimisticShippingCost] = useState<
756
- number | null
757
- >(null)
758
- const [selectedShippingMethod, setSelectedShippingMethod] = useState<
759
- string | null
760
- >(cart?.shipping_methods?.at(-1)?.shipping_option_id || null)
761
-
762
- // Every listed option is offerable: the Medusa-era pickup filter read an
763
- // embedded fulfillment_set the Cartbase API never sends (it never fired),
764
- // and the concept itself died in the shipping vocabulary trial.
765
- const shippingMethods = useMemo(
766
- () => availableShippingMethods ?? [],
767
- [availableShippingMethods]
768
- )
769
-
770
- const selectedShippingOption = useMemo(
771
- () =>
772
- shippingMethods.find((sm) => sm.id === selectedShippingMethod) ?? null,
773
- [shippingMethods, selectedShippingMethod]
774
- )
775
-
776
- const effectiveAvailablePaymentMethods = useMemo(
777
- () =>
778
- paymentMethodFilter
779
- ? paymentMethodFilter(availablePaymentMethods, selectedShippingOption)
780
- : availablePaymentMethods,
781
- [paymentMethodFilter, availablePaymentMethods, selectedShippingOption]
782
- )
783
-
784
- // Calculated-rate price resolution. Cartbase serves flat prices today
785
- // (`price_type: "flat"` always — api/checkout.ts), so this effect
786
- // normally resolves immediately; the calculated branch is kept for
787
- // forward-compat with calculated-rate carriers (the /calculate route
788
- // already exists).
789
- const [calculatedPricesMap, setCalculatedPricesMap] = useState<
790
- Record<string, number>
791
- >({})
792
- const [isLoadingPrices, setIsLoadingPrices] = useState(true)
793
-
794
- useEffect(() => {
795
- if (!shippingMethods.length) {
796
- setIsLoadingPrices(false)
797
- return
798
- }
799
- setIsLoadingPrices(true)
800
- const calculated = shippingMethods.filter(
801
- (sm) => (sm.price_type as string) === "calculated"
802
- )
803
- if (!calculated.length) {
804
- setIsLoadingPrices(false)
805
- return
806
- }
807
- Promise.allSettled(
808
- calculated.map((sm) =>
809
- calculateShippingOption(client, sm.id, { cart_id: cart.id })
810
- )
811
- ).then((res) => {
812
- const map: Record<string, number> = {}
813
- res.forEach((p) => {
814
- if (p.status === "fulfilled" && p.value?.shipping_option)
815
- map[p.value.shipping_option.id ?? ""] =
816
- p.value.shipping_option.amount ?? 0
817
- })
818
- setCalculatedPricesMap(map)
819
- setIsLoadingPrices(false)
820
- })
821
- }, [availableShippingMethods, cart.id, shippingMethods, client])
822
-
823
- // ── Carrier metadata ────────────────────────────────────────────────
824
- // Carrier metadata is held in client state ONLY and written to the cart
825
- // exactly once at Buy click via prepareCheckout's `carrier_metadata`.
826
- // No eager updateCart on selection — that path was the source of the
827
- // office-vs-direct-address and BoxNow-vs-Econt mismatched-data bugs.
828
- // Server-side, prepare-checkout removes the PREVIOUS prepare's carrier
829
- // keys before merging (the `_prepared_carrier_keys` marker), so
830
- // switching carriers can never leak stale fields into the order.
831
- const [selectedEcontOffice, setSelectedEcontOffice] =
832
- useState<EcontOffice | null>(
833
- cart?.metadata?.econt_office_code
834
- ? ({
835
- code: cart.metadata.econt_office_code as string,
836
- name: cart.metadata.econt_office_name as string,
837
- } as EcontOffice)
838
- : null
839
- )
840
-
841
- const handleSelectEcontOffice = useCallback(
842
- (office: EcontOffice | null) => {
843
- setSelectedEcontOffice(office)
844
- },
845
- []
846
- )
847
-
848
- const [selectedBoxnowLocker, setSelectedBoxnowLocker] =
849
- useState<BoxNowLocker | null>(
850
- cart?.metadata?.boxnow_locker_id
851
- ? ({
852
- id: cart.metadata.boxnow_locker_id as string,
853
- title: (cart.metadata.boxnow_locker_title as string) ?? "",
854
- addressLine1:
855
- (cart.metadata.boxnow_locker_address as string) ?? "",
856
- addressLine2: "",
857
- postalCode: (cart.metadata.boxnow_locker_postal as string) ?? "",
858
- country: "",
859
- lat: null,
860
- lng: null,
861
- note: "",
862
- } as BoxNowLocker)
863
- : null
864
- )
865
-
866
- const handleSelectBoxnowLocker = useCallback(
867
- (locker: BoxNowLocker | null) => {
868
- setSelectedBoxnowLocker(locker)
869
- },
870
- []
871
- )
872
-
873
- // ── Payment ─────────────────────────────────────────────────────────
874
- const [paymentError, setPaymentError] = useState<string | null>(null)
875
-
876
- const hasCard = !!effectiveAvailablePaymentMethods?.some(
877
- (m) => "id" in m && isStripeLike(m.id)
878
- )
879
- const cardId = (
880
- effectiveAvailablePaymentMethods?.find(
881
- (m) => "id" in m && isStripeLike(m.id)
882
- ) as { id: string } | undefined
883
- )?.id
884
- // The offline tab = a merchant METHOD (the COD switch, else the first
885
- // manual method) — sessions initiate by payment_method_id, provider NULL.
886
- const offlineMethod = findOfflineMethod(effectiveAvailablePaymentMethods)
887
- const hasCod = !!offlineMethod
888
- const codMethodId = offlineMethod?.payment_method_id
889
- const offlineIsCodKind = offlineMethod?.kind === "cod"
890
-
891
- // Default tab: card when available, else COD. The eager-session model
892
- // used to seed from the cart's pending session provider; in the
893
- // deferred-intent model there is no session at mount.
894
- const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
895
- hasCard ? "card" : "cod"
896
- )
897
-
898
- // Optimistic method-fee state. Painted instantly on tab toggle so the
899
- // totals row shows the predicted fee BEFORE the server applies it (at
900
- // prepare, when the method session is minted). Any method may carry a
901
- // fee — the prediction reads the selected entry's own fee_amount.
902
- // Three values:
903
- // - null → no prediction; render whatever the cart says
904
- // - 0 → predict no fee (toggling to a fee-less tender)
905
- // - positive → predict the fee at this amount
906
- const [optimisticMethodFee, setOptimisticMethodFee] = useState<number | null>(
907
- null
908
- )
909
-
910
- // Payment tab selection is client state only pre-Buy. No payment
911
- // session is created until Buy click — this eliminates the entire
912
- // class of session-rotation / amount-drift / iframe-remount bugs
913
- // caused by the old eager-session model. AFTER a prepare (failed Buy
914
- // retry state) a pending session may exist — the best-effort sync
915
- // below rotates it to the newly picked provider per the
916
- // sync-payment-amount matrix (docs/storefront/checkout.md);
917
- // pre-Buy it no-ops with `no_payment_collection`.
918
- const handlePaymentTab = useCallback(
919
- (tab: "card" | "cod") => {
920
- setPaymentTab(tab)
921
- setPaymentError(null)
922
-
923
- // Optimistic fee prediction so the totals row updates instantly —
924
- // the selected METHOD entry carries its own fee; processors charge
925
- // none. Mirrors the server exactly (resolveMethodFee reads the same
926
- // row the listing serialized).
927
- if (offlineMethod) {
928
- const fee = Number(offlineMethod.fee_amount ?? 0)
929
- setOptimisticMethodFee(tab === "cod" && fee > 0 ? fee : 0)
930
- }
931
-
932
- const tender =
933
- tab === "card"
934
- ? cardId
935
- ? { provider_id: cardId }
936
- : null
937
- : codMethodId
938
- ? { payment_method_id: codMethodId }
939
- : null
940
- if (tender) {
941
- void syncPaymentAmountApi(client, cart.id, tender).catch(() => {})
942
- }
943
- },
944
- [cart.id, client, cardId, codMethodId, offlineMethod]
945
- )
946
-
947
- // Shipping selection is client state only. No addShippingMethod call,
948
- // no metadata-clear updateCart — all of those wrote to the cart
949
- // between toggles and produced the stale-data bug class. The shipping
950
- // method ID is sent to the backend exactly once at Buy click via
951
- // prepareCheckout.
952
- const handleSelectShipping = useCallback(
953
- (id: string) => {
954
- setShippingError(null)
955
- setSelectedShippingMethod(id)
956
-
957
- // Optimistic shipping cost — paint the totals row immediately so
958
- // the customer sees the right number before any network call.
959
- const option = shippingMethods.find((m) => m.id === id)
960
- if (option) {
961
- const price =
962
- option.price_type === "flat"
963
- ? option.amount
964
- : calculatedPricesMap[option.id]
965
- if (price !== undefined && price !== null)
966
- setOptimisticShippingCost(price)
967
- }
968
-
969
- // Switching shipping invalidates any previously-selected carrier-
970
- // specific destination (e.g. picking direct address after BoxNow
971
- // locker). All client state — no eager metadata-clear updateCart.
972
- setSelectedBoxnowLocker(null)
973
- setSelectedEcontOffice(null)
974
- },
975
- [shippingMethods, calculatedPricesMap]
976
- )
977
-
978
- // ── Delivery readiness ──────────────────────────────────────────────
979
- const selectedFulfillmentOptionId = useMemo(() => {
980
- const data = selectedShippingOption?.data as
981
- | { id?: string }
982
- | undefined
983
- | null
984
- return typeof data?.id === "string" ? data.id : null
985
- }, [selectedShippingOption])
986
- const selectedIsBoxnow = selectedFulfillmentOptionId === "boxnow-locker"
987
- const selectedIsEcont = selectedFulfillmentOptionId === "econt-office"
988
-
989
- // Defensive: trust cart.metadata for locker/office IDs in addition to
990
- // local React state. On mobile the BoxNow locker selector was seen
991
- // firing its onSelect handler in a way that updated cart.metadata
992
- // cleanly but left the local `selectedBoxnowLocker` stale (touch event
993
- // timing / hydration race). Without this fallback, the local-null kept
994
- // `deliveryReady` false and the entire payment section turned into a
995
- // ghost — even though the cart server-side knew the locker was set.
996
- const hasBoxnowLockerInCart = !!cart?.metadata?.boxnow_locker_id
997
- const hasEcontOfficeInCart = !!cart?.metadata?.econt_office_code
998
-
999
- const deliveryReady =
1000
- (!!selectedShippingMethod ||
1001
- (cart?.shipping_methods?.length ?? 0) > 0) &&
1002
- (!selectedIsBoxnow || !!selectedBoxnowLocker || hasBoxnowLockerInCart) &&
1003
- (!selectedIsEcont || !!selectedEcontOffice || hasEcontOfficeInCart)
1004
-
1005
- // Reconcile paymentTab with currently-available methods. When the
1006
- // store's paymentMethodFilter strips a method in response to a shipping
1007
- // change (e.g. BoxNow → no COD), the previously selected tab can point
1008
- // to a method that's no longer rendered — leaving the remaining tab's
1009
- // radio looking unselected and the form collapsed.
1010
- useEffect(() => {
1011
- if (!deliveryReady) return
1012
- if (paymentTab === "cod" && !hasCod && hasCard) {
1013
- handlePaymentTab("card")
1014
- } else if (paymentTab === "card" && !hasCard && hasCod) {
1015
- handlePaymentTab("cod")
1016
- }
1017
- }, [deliveryReady, paymentTab, hasCard, hasCod, handlePaymentTab])
1018
-
1019
- const handlePaymentElementChange = useCallback(
1020
- (_e: { complete: boolean; selectedMethod: string | null }) => {
1021
- setPaymentError(null)
1022
- },
1023
- []
1024
- )
1025
-
1026
- // ── Place order (complete + navigate) ───────────────────────────────
1027
- // Successor of @1click's placeOrder server action: write tracking
1028
- // attribution into cart.metadata (consent-gated by the caller), then
1029
- // POST /complete via the SDK, then navigate to the confirmed page (or
1030
- // hand the order to `onOrderPlaced`).
1031
- const placeOrder = useCallback(async (): Promise<CompletedOrder> => {
1032
- const trackingMeta = resolveTrackingMetadata?.()
1033
- if (trackingMeta && Object.keys(trackingMeta).length > 0) {
1034
- // Best-effort — a failed attribution write must never block the
1035
- // order. Cart completion copies cart.metadata → order.metadata,
1036
- // which the backend order.placed forwarder reads.
1037
- await updateCart(client, cart.id, {
1038
- metadata: { ...(cart.metadata ?? {}), ...trackingMeta },
1039
- }).catch(() => {})
1040
- }
1041
-
1042
- const { order } = await completeCart(client, cart.id)
1043
-
1044
- if (onOrderPlaced) {
1045
- onOrderPlaced(order)
1046
- } else if (typeof window !== "undefined") {
1047
- window.location.assign(
1048
- resolveOrderConfirmedPath(
1049
- orderConfirmedPath,
1050
- order,
1051
- formData["shipping_address.country_code"]
1052
- )
1053
- )
1054
- }
1055
- return order
1056
- }, [
1057
- client,
1058
- cart.id,
1059
- cart.metadata,
1060
- onOrderPlaced,
1061
- orderConfirmedPath,
1062
- resolveTrackingMetadata,
1063
- formData,
1064
- ])
1065
-
1066
- // ── Amount sync + dead-PI recovery (SDK-wrapped, exposed) ───────────
1067
- // syncPaymentAmount: call after anything that changes the total while
1068
- // a pending session exists (gift-card apply/remove, quantity change,
1069
- // shipping switch post-prepare). Happy path keeps the SAME
1070
- // client_secret so <Elements> never remounts.
1071
- const syncPaymentAmount = useCallback(
1072
- (providerId?: string): Promise<SyncPaymentAmountResult> =>
1073
- syncPaymentAmountApi(
1074
- client,
1075
- cart.id,
1076
- providerId ? { provider_id: providerId } : {}
1077
- ),
1078
- [client, cart.id]
1079
- )
1080
-
1081
- // refreshPaymentIfTerminal: REACTIVE dead-PI recovery — call from
1082
- // Stripe Elements `loaderror` or on page mount for aged carts, never
1083
- // proactively per render (the proactive variant caused a production
1084
- // reload loop; transient Stripe errors deliberately refuse to rotate).
1085
- const refreshPaymentIfTerminal = useCallback(
1086
- (): Promise<RefreshPaymentResult> =>
1087
- refreshPaymentIfTerminalApi(client, cart.id),
1088
- [client, cart.id]
1089
- )
1090
-
1091
- // ── 3DS / bank-redirect return handler ──────────────────────────────
1092
- // Stripe confirmPayment with redirect: "if_required" navigates to
1093
- // return_url ONLY when the method demands it (3DS challenge, bank-
1094
- // redirect APMs). On return the browser carries
1095
- // ?payment_intent=...&redirect_status=succeeded|...
1096
- //
1097
- // In the deferred-intent flow, the PaymentIntent was created at Buy
1098
- // click via prepareCheckout; the cart already has a pending session
1099
- // pointing at that PI. After 3DS succeeds the PI is in
1100
- // requires_capture / succeeded — complete can authorize. We just
1101
- // call placeOrder (= POST /complete).
1102
- //
1103
- // Strips query params before async work so a refresh / re-render
1104
- // doesn't re-trigger this effect.
1105
- const threeDSHandledRef = useRef(false)
1106
- useEffect(() => {
1107
- if (typeof window === "undefined") return
1108
- if (threeDSHandledRef.current) return
1109
-
1110
- const url = new URL(window.location.href)
1111
- const redirectStatus = url.searchParams.get("redirect_status")
1112
- const paymentIntentId = url.searchParams.get("payment_intent")
1113
- if (!redirectStatus || !paymentIntentId) return
1114
-
1115
- threeDSHandledRef.current = true
1116
-
1117
- ;[
1118
- "redirect_status",
1119
- "payment_intent",
1120
- "payment_intent_client_secret",
1121
- ].forEach((k) => url.searchParams.delete(k))
1122
- window.history.replaceState({}, "", url.toString())
1123
-
1124
- if (redirectStatus === "succeeded") {
1125
- placeOrder().catch((err: unknown) => {
1126
- const translated = translatePaymentError(err, "card", checkoutLabels.paymentErrors)
1127
- setPaymentError(translated)
1128
- logError?.(
1129
- "place_order_error",
1130
- err instanceof Error ? err.message : String(err),
1131
- {
1132
- via: "3ds_return",
1133
- cartId: cart.id,
1134
- paymentIntentId,
1135
- }
1136
- )
1137
- })
1138
- } else {
1139
- // The store's own words. This was a hardcoded BULGARIAN sentence until
1140
- // 2026-09-13: the 2026-09-13 morning sweep fixed the three error-copy
1141
- // modules and missed this one, because it sits in the 3DS-return
1142
- // effect rather than in a copy module. Same meaning, from the pack.
1143
- setPaymentError(checkoutLabels.paymentErrors.paymentNotAuthorized)
1144
- logError?.("place_order_error", "redirect_not_succeeded", {
1145
- via: "3ds_return",
1146
- cartId: cart.id,
1147
- paymentIntentId,
1148
- redirectStatus,
1149
- })
1150
- }
1151
- // eslint-disable-next-line react-hooks/exhaustive-deps
1152
- }, [])
1153
-
1154
- const summaryCart = cart
1155
-
1156
- // ── Optimistic total (display, EUR major units) ─────────────────────
1157
- // Single source of truth for "what total to show on every checkout
1158
- // surface that previously read cart.total" — order summary, Place
1159
- // Order button.
1160
- //
1161
- // Why this exists. The deferred-checkout architecture made cart.total
1162
- // stale until Buy click: shipping & COD fee are now client state, not
1163
- // written to the cart pre-Buy. Surfaces reading cart.total raw drift
1164
- // visibly. This memo applies the compensating math once, hoisted so
1165
- // every consumer shares one formula.
1166
- //
1167
- // Formula:
1168
- // start = cart.total (covers subtotal + tax + any already-written
1169
- // shipping/fee)
1170
- // if optimisticShippingCost set: replace cart.shipping_total with it
1171
- // if optimisticMethodFee set: replace cart.payment_method_fee_total
1172
- // with it (the fee is CART-LEVEL decoration folded into total,
1173
- // not a fee line item)
1174
- // When nothing is optimistic, this collapses to cart.total.
1175
- const optimisticTotal = useMemo(() => {
1176
- const realMethodFeeAmount = cart?.payment_method_fee_total ?? 0
1177
- let total = cart?.total ?? 0
1178
- if (optimisticShippingCost !== null) {
1179
- total = total - (cart?.shipping_total ?? 0) + optimisticShippingCost
1180
- }
1181
- if (optimisticMethodFee !== null && optimisticMethodFee !== undefined) {
1182
- total = total - realMethodFeeAmount + optimisticMethodFee
1183
- }
1184
- return total
1185
- }, [
1186
- cart?.total,
1187
- cart?.shipping_total,
1188
- cart?.payment_method_fee_total,
1189
- optimisticShippingCost,
1190
- optimisticMethodFee,
1191
- ])
1192
-
1193
- // ── Optimistic total in cents (for Stripe Elements deferred-intent) ─
1194
- // Stripe's deferred-intent <Elements> needs `amount` + `currency` at
1195
- // mount time (no PaymentIntent on the backend yet). Same value as
1196
- // `optimisticTotal` above, just in the smallest currency unit and
1197
- // floor-clamped to Stripe's minimum charge. Round to protect against
1198
- // float drift in optimistic deltas. (Store-API money is EUR major
1199
- // units — minor-unit conversion happens ONLY at this Stripe display
1200
- // boundary; the charged amount is always the server's.)
1201
- const optimisticTotalCents = useMemo(
1202
- () => Math.max(50, Math.round(optimisticTotal * 100)),
1203
- [optimisticTotal]
1204
- )
1205
-
1206
- // ── Buy-click payload builder ───────────────────────────────────────
1207
- // Constructs the prepareCheckout request body from current client
1208
- // state. Called by PaymentButton on click.
1209
- const buildPrepareCheckoutPayload =
1210
- useCallback((): PrepareCheckoutInput => {
1211
- const carrierMetadata: Record<string, unknown> = {}
1212
- if (selectedEcontOffice) {
1213
- const addr = [
1214
- selectedEcontOffice.address?.street,
1215
- selectedEcontOffice.address?.num,
1216
- ]
1217
- .filter(Boolean)
1218
- .join(" ")
1219
- carrierMetadata.econt_office_code = selectedEcontOffice.code
1220
- carrierMetadata.econt_office_name = selectedEcontOffice.name
1221
- carrierMetadata.econt_office_city =
1222
- selectedEcontOffice.address?.city?.name || ""
1223
- carrierMetadata.econt_office_address = addr
1224
- carrierMetadata.econt_office_phone =
1225
- selectedEcontOffice.phones?.[0] || ""
1226
- }
1227
- if (selectedBoxnowLocker) {
1228
- carrierMetadata.boxnow_locker_id = selectedBoxnowLocker.id
1229
- carrierMetadata.boxnow_locker_title = selectedBoxnowLocker.title
1230
- carrierMetadata.boxnow_locker_address =
1231
- selectedBoxnowLocker.addressLine1 ?? ""
1232
- carrierMetadata.boxnow_locker_postal =
1233
- selectedBoxnowLocker.postalCode ?? ""
1234
- }
1235
-
1236
- const shippingMethodId = selectedShippingMethod
1237
- if (!shippingMethodId) {
1238
- throw new Error("No shipping method selected")
1239
- }
1240
- // The tender: card tab → the processor; offline tab → the merchant
1241
- // method (provider-less session, pp_* kill).
1242
- const tender =
1243
- paymentTab === "card"
1244
- ? cardId
1245
- ? { payment_provider: cardId }
1246
- : null
1247
- : codMethodId
1248
- ? { payment_method_id: codMethodId }
1249
- : null
1250
- if (!tender) {
1251
- throw new Error("No payment method available")
1252
- }
1253
-
1254
- return {
1255
- shipping_address: {
1256
- first_name: formData["shipping_address.first_name"] ?? "",
1257
- last_name: formData["shipping_address.last_name"] ?? "",
1258
- address_1: formData["shipping_address.address_1"] ?? "",
1259
- address_2: "",
1260
- city: formData["shipping_address.city"] ?? "",
1261
- postal_code: formData["shipping_address.postal_code"] ?? "",
1262
- country_code: formData["shipping_address.country_code"] ?? "",
1263
- phone: formData["shipping_address.phone"] ?? "",
1264
- },
1265
- shipping_method_id: shippingMethodId,
1266
- carrier_metadata: carrierMetadata,
1267
- ...tender,
1268
- }
1269
- }, [
1270
- formData,
1271
- selectedShippingMethod,
1272
- selectedEcontOffice,
1273
- selectedBoxnowLocker,
1274
- paymentTab,
1275
- cardId,
1276
- codMethodId,
1277
- ])
1278
-
1279
- // ── Buy click ───────────────────────────────────────────────────────
1280
- // Single source of truth for the Buy-click flow. Called by PaymentButton.
1281
- // Steps:
1282
- // 1. Flush any pending address auto-save (so the server has the
1283
- // latest email/name/phone for tracking + abandoned-cart).
1284
- // 2. (Card path) elements.submit() — validates the form inside the
1285
- // Stripe iframe before any server call.
1286
- // 3. POST /api/store/carts/:id/prepare-checkout — ONE atomic,
1287
- // compensated write of address + shipping + carrier metadata +
1288
- // payment collection + session at the FINAL amount.
1289
- // 4. (Card path) stripe.confirmPayment(elements, clientSecret) —
1290
- // attaches the payment method and confirms. On 3DS this redirects
1291
- // and we resume in the threeDSHandledRef effect. SKIPPED entirely
1292
- // on the zero-remainder gift path (client_secret === null &&
1293
- // provider_id === null — gift cards cover the whole total).
1294
- // 5. placeOrder() (POST /complete) — authorize passes because the PI
1295
- // is now in requires_capture / succeeded (or the gift session
1296
- // covers everything).
1297
- type BuyClickStripe = {
1298
- submit: () => Promise<{ error?: { message?: string } | null }>
1299
- stripe: {
1300
- confirmPayment: (args: {
1301
- elements: unknown
1302
- clientSecret: string
1303
- confirmParams: { return_url: string }
1304
- redirect: "if_required"
1305
- }) => Promise<{ error?: { message?: string } | null }>
1306
- }
1307
- elements: unknown
1308
- }
1309
-
1310
- const performBuyClick = useCallback(
1311
- async (stripeBundle?: BuyClickStripe): Promise<void> => {
1312
- setPaymentError(null)
1313
-
1314
- // STATE-AT-CLICK SNAPSHOT — logs the EXACT state the storefront sees
1315
- // when the customer clicks Buy. Diagnostic-only; no PII beyond
1316
- // what's already in the order.
1317
- const stateSnapshot = {
1318
- cart_id: cart.id,
1319
- paymentTab,
1320
- cardId,
1321
- codMethodId,
1322
- selectedShippingMethod,
1323
- selectedEcontOffice: selectedEcontOffice
1324
- ? { code: selectedEcontOffice.code, name: selectedEcontOffice.name }
1325
- : null,
1326
- selectedBoxnowLocker: selectedBoxnowLocker
1327
- ? {
1328
- id: selectedBoxnowLocker.id,
1329
- title: selectedBoxnowLocker.title,
1330
- }
1331
- : null,
1332
- hasStripeBundle: !!stripeBundle,
1333
- }
1334
- dbg("[buy-click] STATE", stateSnapshot)
1335
- // Per-click telemetry goes only to a merchant-wired sink, never to the
1336
- // default platform error log — the sink is for failures.
1337
- logErrorProp?.("other", "buy_click_state", stateSnapshot)
1338
-
1339
- await flushAddressSave()
1340
-
1341
- if (paymentTab === "card") {
1342
- if (!stripeBundle) {
1343
- // eslint-disable-next-line no-console
1344
- console.error("[buy-click] card path but no stripe bundle")
1345
- throw new Error("Stripe not ready")
1346
- }
1347
- dbg("[buy-click] elements.submit() …")
1348
- const { error: submitError } = await stripeBundle.submit()
1349
- if (submitError) {
1350
- // eslint-disable-next-line no-console
1351
- console.error("[buy-click] elements.submit() error", submitError)
1352
- throw submitError
1353
- }
1354
- }
1355
-
1356
- const payload = buildPrepareCheckoutPayload()
1357
- // debug-only: the full payload carries the customer's name, phone,
1358
- // email and address — it must never print in a customer's console.
1359
- dbg("[buy-click] PAYLOAD →", payload)
1360
- logErrorProp?.("other", "buy_click_payload", {
1361
- provider_id: payload.payment_provider ?? null,
1362
- payment_method_id: payload.payment_method_id ?? null,
1363
- shipping_method_id: payload.shipping_method_id,
1364
- carrier_metadata_keys: Object.keys(payload.carrier_metadata ?? {}),
1365
- country_code: payload.shipping_address.country_code,
1366
- })
1367
-
1368
- const prep = await prepareCheckout(client, cart.id, payload).catch(
1369
- (e: unknown) => {
1370
- // eslint-disable-next-line no-console
1371
- console.error("[buy-click] prepareCheckout threw", e)
1372
- throw e
1373
- }
1374
- )
1375
- dbg("[buy-click] prepareCheckout response", {
1376
- has_client_secret: !!prep.client_secret,
1377
- provider_id: prep.provider_id,
1378
- })
1379
-
1380
- // ── Zero-remainder gift path (Cartbase gift-card tender) ──────────
1381
- // When applied gift cards cover the whole total, prepare skips the
1382
- // provider session: client_secret AND provider_id come back null
1383
- // and the cart completes on the internal pp_giftcard session alone
1384
- // — no Stripe involved (docs/storefront/checkout.md +
1385
- // gift-cards.md). Detect it BEFORE the card branch so a card-tab
1386
- // Buy click on a fully-covered cart doesn't demand a secret.
1387
- const zeroRemainderGiftPath =
1388
- prep.client_secret === null && prep.provider_id === null
1389
-
1390
- if (paymentTab === "card" && !zeroRemainderGiftPath) {
1391
- if (!stripeBundle || !prep.client_secret) {
1392
- // eslint-disable-next-line no-console
1393
- console.error(
1394
- "[buy-click] card path missing client_secret",
1395
- { has_bundle: !!stripeBundle, has_secret: !!prep.client_secret }
1396
- )
1397
- throw new Error("Stripe client_secret missing after prepare")
1398
- }
1399
- const returnUrl =
1400
- typeof window !== "undefined" ? window.location.href : ""
1401
-
1402
- // billing_details — passed for AVS / Radar / 3DS risk scoring /
1403
- // dispute defense. PaymentElement is NO LONGER mounted with
1404
- // fields.billingDetails.address = "never" (see payment-method-
1405
- // list.tsx) — that flag put Stripe into strict-completeness mode
1406
- // and threw IntegrationError on the first missing sub-field
1407
- // (country → state → next), which is what blocked card payments
1408
- // on 2026-05-06. Without it, Stripe accepts whatever billing
1409
- // details we provide and falls back to its iframe-collected data
1410
- // for anything missing. We still pass full billing_details here
1411
- // because the data improves auth rates regardless.
1412
- //
1413
- // `null` (not `undefined`) for empty fields — explicit null is
1414
- // robust against any future Stripe SDK reintroducing presence
1415
- // checks; undefined would read as "missing".
1416
- const firstName = formData["shipping_address.first_name"] ?? ""
1417
- const lastName = formData["shipping_address.last_name"] ?? ""
1418
- const fullName = `${firstName} ${lastName}`.trim()
1419
- const billingDetails = {
1420
- name: fullName || null,
1421
- email: formData.email || null,
1422
- phone: formData["shipping_address.phone"] || null,
1423
- address: {
1424
- line1: formData["shipping_address.address_1"] || null,
1425
- line2: null,
1426
- city: formData["shipping_address.city"] || null,
1427
- state: formData["shipping_address.province"] || null,
1428
- postal_code: formData["shipping_address.postal_code"] || null,
1429
- // ISO 3166-1 alpha-2, uppercase per Stripe convention.
1430
- country:
1431
- formData["shipping_address.country_code"]?.toUpperCase() || null,
1432
- },
1433
- }
1434
-
1435
- dbg("[buy-click] stripe.confirmPayment() …", {
1436
- has_country: !!billingDetails.address.country,
1437
- })
1438
- const { error } = await (stripeBundle.stripe as unknown as {
1439
- confirmPayment: (args: unknown) => Promise<{ error?: unknown }>
1440
- }).confirmPayment({
1441
- elements: stripeBundle.elements,
1442
- clientSecret: prep.client_secret,
1443
- confirmParams: {
1444
- return_url: returnUrl,
1445
- payment_method_data: {
1446
- billing_details: billingDetails,
1447
- },
1448
- },
1449
- redirect: "if_required",
1450
- })
1451
- if (error) {
1452
- const stripeErr = error as {
1453
- type?: string
1454
- code?: string
1455
- decline_code?: string
1456
- message?: string
1457
- payment_intent?: { id?: string; status?: string }
1458
- }
1459
- // eslint-disable-next-line no-console
1460
- console.error("[buy-click] stripe.confirmPayment error", stripeErr)
1461
- // Log so the actual Stripe error code + message is readable
1462
- // without needing the customer's browser console.
1463
- logError?.(
1464
- "stripe_confirm_error",
1465
- stripeErr.message ?? "unknown",
1466
- {
1467
- type: stripeErr.type,
1468
- code: stripeErr.code,
1469
- decline_code: stripeErr.decline_code,
1470
- pi_id: stripeErr.payment_intent?.id,
1471
- pi_status: stripeErr.payment_intent?.status,
1472
- cart_id: cart.id,
1473
- client_secret_prefix: prep.client_secret.slice(0, 8),
1474
- }
1475
- )
1476
- throw error
1477
- }
1478
- // confirmPayment succeeded synchronously (no redirect needed,
1479
- // e.g. non-3DS card flow). The PaymentIntent is now in
1480
- // requires_capture or succeeded — complete will pass authorize.
1481
- dbg("[buy-click] stripe.confirmPayment SUCCESS (no redirect)")
1482
- logErrorProp?.("stripe_confirm_succeeded", "ok", {
1483
- cart_id: cart.id,
1484
- client_secret_prefix: prep.client_secret.slice(0, 8),
1485
- })
1486
- } else if (zeroRemainderGiftPath) {
1487
- dbg(
1488
- "[buy-click] zero-remainder gift path — skipping Stripe, completing on the gift session"
1489
- )
1490
- logErrorProp?.("other", "zero_remainder_gift_path", { cart_id: cart.id })
1491
- }
1492
-
1493
- dbg("[buy-click] placeOrder() …")
1494
- logErrorProp?.("order_placed", "called", {
1495
- cart_id: cart.id,
1496
- path: paymentTab,
1497
- })
1498
- await placeOrder().catch((e: unknown) => {
1499
- // eslint-disable-next-line no-console
1500
- console.error("[buy-click] placeOrder threw", e)
1501
- throw e
1502
- })
1503
- },
1504
- [
1505
- client,
1506
- cart.id,
1507
- paymentTab,
1508
- cardId,
1509
- codMethodId,
1510
- selectedShippingMethod,
1511
- selectedEcontOffice,
1512
- selectedBoxnowLocker,
1513
- flushAddressSave,
1514
- buildPrepareCheckoutPayload,
1515
- placeOrder,
1516
- formData,
1517
- logError,
1518
- ]
1519
- )
1520
-
1521
- return {
1522
- // Completed-cart guard
1523
- cartIsCompleted,
1524
-
1525
- // Address form
1526
- formData,
1527
- setFormData,
1528
- addressError,
1529
- addressReady,
1530
- allRequiredFilled,
1531
- setFormAddress,
1532
- handleFormChange,
1533
- handleFieldBlur,
1534
- addressInput,
1535
- regionCountries,
1536
- addressesInRegion,
1537
- saveAddress,
1538
- flushAddressSave,
1539
- pulseFields,
1540
-
1541
- // Shipping
1542
- shippingMethods,
1543
- calculatedPricesMap,
1544
- isLoadingPrices,
1545
- selectedShippingMethod,
1546
- selectedShippingOption,
1547
- selectedFulfillmentOptionId,
1548
- selectedIsBoxnow,
1549
- selectedIsEcont,
1550
- shippingLoading,
1551
- shippingError,
1552
- optimisticShippingCost,
1553
- setOptimisticShippingCost,
1554
- handleSelectShipping,
1555
-
1556
- // Carriers
1557
- selectedEcontOffice,
1558
- handleSelectEcontOffice,
1559
- selectedBoxnowLocker,
1560
- handleSelectBoxnowLocker,
1561
-
1562
- // Payment
1563
- paymentTab,
1564
- hasCard,
1565
- hasCod,
1566
- cardId,
1567
- /** The offline tab's method (the COD switch or a manual method). */
1568
- codMethodId,
1569
- offlineMethod,
1570
- paymentError,
1571
- setPaymentError,
1572
- deliveryReady,
1573
- optimisticMethodFee,
1574
- setOptimisticMethodFee,
1575
- handlePaymentTab,
1576
- handlePaymentElementChange,
1577
-
1578
- // Amount sync + recovery (SDK-wrapped)
1579
- syncPaymentAmount,
1580
- refreshPaymentIfTerminal,
1581
-
1582
- // Buy click (deferred-intent flow)
1583
- optimisticTotal,
1584
- optimisticTotalCents,
1585
- buildPrepareCheckoutPayload,
1586
- performBuyClick,
1587
- placeOrder,
1588
-
1589
- // Misc
1590
- summaryCart,
1591
- }
1592
- }
1
+ "use client"
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ChangeEvent,
10
+ } from "react"
11
+
12
+ import type { StorefrontClient } from "../api/http"
13
+ import { countryName } from "../lib/country-name"
14
+ import { listCountries, type StoreCountry } from "../api/regions"
15
+ import {
16
+ completeCart,
17
+ updateCart,
18
+ type Cart,
19
+ type CartAddress,
20
+ type CompletedOrder,
21
+ type UpdateCartInput,
22
+ } from "../api/carts"
23
+ import {
24
+ calculateShippingOption,
25
+ prepareCheckout,
26
+ refreshPaymentIfTerminal as refreshPaymentIfTerminalApi,
27
+ reportCheckoutError,
28
+ syncPaymentAmount as syncPaymentAmountApi,
29
+ isMethodEntry,
30
+ type PrepareCheckoutInput,
31
+ type RefreshPaymentResult,
32
+ type StorePaymentEntry,
33
+ type StorePaymentMethodEntry,
34
+ type StoreShippingOption,
35
+ type SyncPaymentAmountResult,
36
+ } from "../api/checkout"
37
+ import {
38
+ updateMe,
39
+ type CustomerAddress,
40
+ type StoreCustomer,
41
+ type UpdateCustomerInput,
42
+ } from "../api/customers"
43
+ import { isStripeLike } from "../lib/payment-constants"
44
+ import compareAddresses from "./compare-addresses"
45
+ import { translateAddressError } from "./address-error-copy"
46
+ import { translatePaymentError } from "./payment-error-copy"
47
+ import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
48
+ import type { EcontOffice } from "./econt-office-selector"
49
+ import type { BoxNowLocker } from "../api/integrations"
50
+
51
+ /**
52
+ * useCheckoutOrchestration — single source of truth for checkout-page
53
+ * orchestration. All race-condition guards, payment-session lifecycle,
54
+ * shipping/address mutations, carrier metadata, completed-cart detection
55
+ * and 3DS-return handling live here. Library's CheckoutClient and any
56
+ * store's custom orchestration component consume this hook — they own only
57
+ * the layout and any store-specific concerns (tracking, sessionStorage form
58
+ * persistence, custom summary).
59
+ *
60
+ * Ported from `@1click/ui/src/checkout/use-checkout-orchestration.ts`
61
+ * (v2.3.1, the deferred-intent architecture) with the Cartbase data seam:
62
+ *
63
+ * - Server actions → `@cartbase/storefront/api` calls on a caller-supplied
64
+ * `StorefrontClient` (`updateCart`, `prepareCheckout`,
65
+ * `syncPaymentAmount`, `refreshPaymentIfTerminal`, `completeCart`,
66
+ * `calculateShippingOption`, `updateMe`).
67
+ * - `placeOrder` (server action with baked-in redirect) →
68
+ * `completeCart()` + template navigation (`orderConfirmedPath` with
69
+ * `{id}`/`{country}` substitution) or the `onOrderPlaced` callback.
70
+ * - **Zero-remainder gift path** (Cartbase gift-card tender,
71
+ * docs/storefront/checkout.md): when applied gift cards cover the whole
72
+ * total, `prepareCheckout` returns `client_secret: null` +
73
+ * `provider_id: null` and the card path SKIPS `stripe.confirmPayment`
74
+ * entirely — the cart completes on the gift session alone. The @1click
75
+ * original threw on a missing client_secret; Cartbase treats
76
+ * null-secret + null-provider as the documented gift path.
77
+ * - The payment method fee: CART-LEVEL decoration
78
+ * (`payment_method_fee_total` / `payment_method_fee_label`, folded into
79
+ * `cart.total` while a live method session exists) — not a
80
+ * metadata-flagged line item. ANY method may carry a fee; the optimistic
81
+ * prediction reads `fee_amount` off the selected LISTING entry (the
82
+ * integrations `cod` block died 2026-08-11).
83
+ * - Processor ids: `pp_stripe` exactly (lib/payment-constants); merchant
84
+ * methods have no provider id — sessions ride `payment_method_id`.
85
+ * - `logCheckoutError`/`logEvent` (Supabase-side sinks in @1click) have no
86
+ * Cartbase store endpoint — the hook takes an optional `logError` callback
87
+ * so stores wire their own sink; all production log points are kept.
88
+ * - Cartbase regions carry NO embedded countries array (api/regions.ts
89
+ * divergence note) — the country list is a `countries` option with a
90
+ * `countryCode` single-entry fallback.
91
+ *
92
+ * Why a hook and not a base component:
93
+ * - Stores fork the layout for legitimate reasons (tracking, custom
94
+ * summary). They should NOT have to fork the orchestration logic too —
95
+ * that's how a fork DROPPED the session guard + syncPaymentAmount flow
96
+ * during the v1.15 → v1.16 cycle, producing zombie Stripe sessions on
97
+ * rapid payment-tab toggles. Centralizing the logic here makes that
98
+ * class of fork-rot bug structurally impossible.
99
+ */
100
+
101
+ /** A payable entry the hook accepts: a processor row (`{id}`) or a
102
+ * merchant-method row (`{payment_method_id, name, kind}`) — the two wire
103
+ * shapes of GET /api/store/payment-providers since the pp_* kill. */
104
+ export type PaymentProviderLike = StorePaymentEntry | { id: string }
105
+
106
+ export type CheckoutLogError = (
107
+ errorType: string,
108
+ message: string,
109
+ context?: Record<string, unknown>
110
+ ) => void
111
+
112
+ export type UseCheckoutOrchestrationOptions = {
113
+ /** The SDK transport — all server calls go through it. */
114
+ client: StorefrontClient
115
+ cart: Cart
116
+ customer: StoreCustomer | null
117
+ availableShippingMethods: StoreShippingOption[] | null
118
+ availablePaymentMethods: PaymentProviderLike[] | null
119
+ /** Default country code when the cart has no shipping address yet. */
120
+ countryCode?: string
121
+ /**
122
+ * Countries offered in the address form. Cartbase regions do NOT embed a
123
+ * countries array (store-API divergence), so the host app supplies the
124
+ * list; omitted → a single entry derived from `countryCode`.
125
+ */
126
+ countries?: Array<{ iso_2: string; display_name: string }>
127
+ /**
128
+ * Per-store rule for filtering payment methods based on the currently
129
+ * selected shipping option (e.g. hide COD when BoxNow is selected).
130
+ */
131
+ paymentMethodFilter?: (
132
+ methods: PaymentProviderLike[] | null,
133
+ selectedShippingOption: StoreShippingOption | null
134
+ ) => PaymentProviderLike[] | null
135
+ /**
136
+ * Order-confirmed redirect path template (`{id}` → order.id,
137
+ * `{country}` → shipping country). Falls back to the CheckoutProvider
138
+ * context value.
139
+ */
140
+ orderConfirmedPath?: string
141
+ /**
142
+ * Called after a successful complete INSTEAD of the default
143
+ * `window.location.assign` navigation — stores using next/navigation
144
+ * router push their own way.
145
+ */
146
+ onOrderPlaced?: (order: CompletedOrder) => void
147
+ /**
148
+ * Tracking-attribution metadata written into `cart.metadata` right
149
+ * before complete (consent-gated by the CALLER — pass undefined when
150
+ * consent denies). Use `getTrackingAttribution()` from
151
+ * `@cartbase/storefront/tracking` + engagement time. Cart completion
152
+ * copies cart.metadata to order.metadata so the backend `order.placed`
153
+ * forwarder inherits fbp/fbc/ga signals (docs/storefront/
154
+ * integrations.md).
155
+ */
156
+ resolveTrackingMetadata?: () => Record<string, unknown> | undefined
157
+ /**
158
+ * Operational-visibility sink. Called with (errorType, message, context).
159
+ * DEFAULTS to reporting into the platform's checkout error log
160
+ * (POST /api/store/checkout-errors, step `browser`) so browser-side money
161
+ * failures reach the merchant without any wiring — a platform guarantee,
162
+ * not a merchant chore (money-chain-hardening, 2026-08-24). Override to
163
+ * add your own sink; fire-and-forget either way.
164
+ */
165
+ logError?: CheckoutLogError
166
+ /**
167
+ * Verbose [buy-click] console output for local debugging. OFF by default:
168
+ * the unguarded dumps used to print the full prepare-checkout payload —
169
+ * customer name, phone, email, address — into every customer's browser
170
+ * console. Redacted sink events fire regardless of this flag.
171
+ */
172
+ debug?: boolean
173
+ }
174
+
175
+ /**
176
+ * Address form fields the orchestration tracks. Matches the keys the
177
+ * library's `CheckoutAddressForm` writes via `name=...`.
178
+ *
179
+ * `shipping_address.phone` is required — Bulgarian carriers (Econt,
180
+ * BoxNow) need it to contact the customer; it's the courier's primary
181
+ * recovery channel when the address is ambiguous. (Cartbase's
182
+ * prepare-checkout schema requires it too — docs/storefront/checkout.md.)
183
+ */
184
+ const REQUIRED_ADDRESS_FIELDS = [
185
+ "email",
186
+ "shipping_address.country_code",
187
+ "shipping_address.first_name",
188
+ "shipping_address.last_name",
189
+ "shipping_address.address_1",
190
+ "shipping_address.city",
191
+ "shipping_address.postal_code",
192
+ "shipping_address.phone",
193
+ ] as const
194
+
195
+ /**
196
+ * Debounce window for the auto-save effect — long enough that a user
197
+ * typing through fields without blurring triggers ONE save at the end,
198
+ * short enough that clicking a shipping option after the last keystroke
199
+ * doesn't race the persistence (the pre-action `flushAddressSave` is
200
+ * the belt-and-braces backstop for that race).
201
+ */
202
+ const ADDRESS_AUTO_SAVE_DEBOUNCE_MS = 600
203
+
204
+ /** The offline tab's tender: a merchant METHOD entry (pp_* kill). The COD
205
+ * method (kind 'cod') wins; any manual method serves as the offline tab
206
+ * when no COD switch is on. The entry carries its own fee for prediction. */
207
+ const findOfflineMethod = (
208
+ entries: PaymentProviderLike[] | null | undefined
209
+ ): StorePaymentMethodEntry | null => {
210
+ const methods = (entries ?? []).filter(
211
+ (m): m is Extract<PaymentProviderLike, { payment_method_id: string }> =>
212
+ "payment_method_id" in m && isMethodEntry(m as StorePaymentEntry)
213
+ )
214
+ return methods.find((m) => m.kind === "cod") ?? methods[0] ?? null
215
+ }
216
+
217
+ /**
218
+ * Snapshot the address-relevant subset of formData. Used to skip
219
+ * redundant saves: if the snapshot matches what was last persisted,
220
+ * there's nothing to do. JSON-stringify keeps comparison cheap and
221
+ * correct (string keys + string values, no nested objects).
222
+ */
223
+ function snapshotAddressForm(
224
+ formData: Record<string, string>,
225
+ sameAsBilling: boolean
226
+ ): string {
227
+ return JSON.stringify({
228
+ email: formData.email ?? "",
229
+ first_name: formData["shipping_address.first_name"] ?? "",
230
+ last_name: formData["shipping_address.last_name"] ?? "",
231
+ address_1: formData["shipping_address.address_1"] ?? "",
232
+ company: formData["shipping_address.company"] ?? "",
233
+ postal_code: formData["shipping_address.postal_code"] ?? "",
234
+ city: formData["shipping_address.city"] ?? "",
235
+ country_code: formData["shipping_address.country_code"] ?? "",
236
+ province: formData["shipping_address.province"] ?? "",
237
+ phone: formData["shipping_address.phone"] ?? "",
238
+ company_name: formData.company_name ?? "",
239
+ company_vat: formData.company_vat ?? "",
240
+ company_mol: formData.company_mol ?? "",
241
+ company_address: formData.company_address ?? "",
242
+ sameAsBilling,
243
+ })
244
+ }
245
+
246
+ /** Resolve the confirmed-order path template. Pure — unit-tested. */
247
+ export function resolveOrderConfirmedPath(
248
+ template: string,
249
+ order: { id: string },
250
+ countryCode?: string | null
251
+ ): string {
252
+ return template
253
+ .replace("{id}", order.id)
254
+ .replace("{country}", (countryCode ?? "").toLowerCase())
255
+ }
256
+
257
+ export function useCheckoutOrchestration({
258
+ client,
259
+ cart,
260
+ customer,
261
+ availableShippingMethods,
262
+ availablePaymentMethods,
263
+ countryCode = "",
264
+ countries,
265
+ paymentMethodFilter,
266
+ orderConfirmedPath: orderConfirmedPathProp,
267
+ onOrderPlaced,
268
+ resolveTrackingMetadata,
269
+ logError: logErrorProp,
270
+ debug = false,
271
+ }: UseCheckoutOrchestrationOptions) {
272
+ // The store's language, for the sentences a shopper reads when a save
273
+ // fails. English unless a locale pack is mounted above.
274
+ const checkoutLabels = useCheckoutLabels()
275
+ // The default sink: the platform's own checkout error log. Browser money
276
+ // failures (Stripe confirm errors, failed 3DS returns, place-order
277
+ // rejections) land in checkout_error_logs step `browser` with no merchant
278
+ // wiring. The two per-click debug snapshots are gated behind `debug` and
279
+ // never sent by default — the sink is for FAILURES, not telemetry.
280
+ const logError: CheckoutLogError = useCallback(
281
+ (errorType, message, context) => {
282
+ if (logErrorProp) {
283
+ logErrorProp(errorType, message, context)
284
+ return
285
+ }
286
+ void reportCheckoutError(client, {
287
+ error_type: errorType,
288
+ message,
289
+ cart_id: cart?.id,
290
+ context,
291
+ })
292
+ },
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ [logErrorProp, client, cart?.id]
295
+ )
296
+ // Debug-gated console output — see the `debug` option's doc.
297
+ const dbg = debug
298
+ ? // eslint-disable-next-line no-console
299
+ (...args: unknown[]) => console.log(...args)
300
+ : () => {}
301
+ // Resolve the order-confirmed path. Prop wins; otherwise fall back to
302
+ // the value provided by CheckoutProvider context.
303
+ const contextOrderConfirmedPath = useOrderConfirmedPath()
304
+ const orderConfirmedPath =
305
+ orderConfirmedPathProp ?? contextOrderConfirmedPath
306
+
307
+ // ── Completed-cart detection ────────────────────────────────────────
308
+ // Surfaced as a flag so the page-level server component can redirect
309
+ // before any client-side mutation runs. The cart cookie can outlive a
310
+ // completed checkout (back button after order, second tab on the same
311
+ // session) and the storefront previously rendered the full form on
312
+ // top — leading to a second complete attempt that returns a raw
313
+ // English error.
314
+ const cartIsCompleted = useMemo(
315
+ () => Boolean(cart?.completed_at),
316
+ [cart]
317
+ )
318
+
319
+ // ── Address form ────────────────────────────────────────────────────
320
+ const [addressError, setAddressError] = useState<string | null>(null)
321
+ const [, setAddressSaving] = useState(false)
322
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
323
+ // In-flight guard against concurrent saveAddress calls. Without this,
324
+ // rapid blur events (email → tab → phone → tab) queue parallel
325
+ // updateCart writes; the later-resolving one wins, and any field the
326
+ // user edited between the two clicks gets reverted to the earlier
327
+ // snapshot.
328
+ const addressSavingRef = useRef(false)
329
+ // Snapshot of the formData that was last successfully persisted.
330
+ // Used to skip redundant saves and to detect when the user changed
331
+ // form fields while a save was in flight (so we re-fire after).
332
+ const lastSavedSnapshotRef = useRef<string>("")
333
+ // When a save is in flight and formData changes, we set this to the
334
+ // latest snapshot. The in-flight save's `finally` checks it and
335
+ // re-fires saveAddress so the latest form state always wins.
336
+ const pendingSnapshotRef = useRef<string | null>(null)
337
+
338
+ const [formData, setFormData] = useState<Record<string, string>>(() => ({
339
+ "shipping_address.first_name":
340
+ cart?.shipping_address?.first_name || customer?.first_name || "",
341
+ "shipping_address.last_name":
342
+ cart?.shipping_address?.last_name || customer?.last_name || "",
343
+ "shipping_address.address_1": cart?.shipping_address?.address_1 || "",
344
+ "shipping_address.company": cart?.shipping_address?.company || "",
345
+ "shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
346
+ "shipping_address.city": cart?.shipping_address?.city || "",
347
+ "shipping_address.country_code":
348
+ cart?.shipping_address?.country_code || countryCode || "",
349
+ "shipping_address.province": cart?.shipping_address?.province || "",
350
+ "shipping_address.phone": cart?.shipping_address?.phone || "",
351
+ email: cart?.email || customer?.email || "",
352
+ company_name: (cart?.metadata?.company_name as string) || "",
353
+ company_vat: (cart?.metadata?.company_vat as string) || "",
354
+ company_mol: (cart?.metadata?.company_mol as string) || "",
355
+ company_address: (cart?.metadata?.company_address as string) || "",
356
+ }))
357
+
358
+ const [sameAsBilling] = useState(
359
+ cart?.shipping_address && cart?.billing_address
360
+ ? compareAddresses(
361
+ cart.shipping_address as unknown as Record<string, unknown>,
362
+ cart.billing_address as unknown as Record<string, unknown>
363
+ )
364
+ : true
365
+ )
366
+
367
+ /**
368
+ * The store's own country list, fetched when the app did not pass one.
369
+ *
370
+ * Until 2026-09-13 an app that passed nothing got a ONE-ENTRY list built
371
+ * from `countryCode`, and the address form renders a read-only box for a
372
+ * single entry, so every scaffolded store shipped a checkout offering
373
+ * exactly one country that the shopper could not change. The list is the
374
+ * platform's to answer (`GET /api/store/countries`: the store's Markets
375
+ * when it has declared any, the whole catalogue when it has not), so the
376
+ * hook asks instead of guessing.
377
+ *
378
+ * A failure here must never block a checkout: the `countryCode` fallback
379
+ * below still stands, and the error is reported through `logError`.
380
+ */
381
+ const [storeCountries, setStoreCountries] = useState<StoreCountry[] | null>(null)
382
+ const appSuppliedCountries = !!(countries && countries.length)
383
+
384
+ useEffect(() => {
385
+ if (appSuppliedCountries) return
386
+ let alive = true
387
+ listCountries(client)
388
+ .then((res) => {
389
+ if (alive) setStoreCountries(res.countries)
390
+ })
391
+ .catch((e: unknown) => {
392
+ logError(
393
+ "country_list_failed",
394
+ e instanceof Error ? e.message : "could not load the country list"
395
+ )
396
+ })
397
+ return () => {
398
+ alive = false
399
+ }
400
+ }, [client, appSuppliedCountries, logError])
401
+
402
+ // The app's own list wins, then the store's, then the single-country
403
+ // fallback. This is the authority for both the select options and the
404
+ // saved-address region filter.
405
+ const regionCountries = useMemo(
406
+ () =>
407
+ countries && countries.length
408
+ ? countries
409
+ : storeCountries && storeCountries.length
410
+ ? storeCountries
411
+ : countryCode
412
+ ? // A fallback country still needs a NAME: this used to be an
413
+ // empty string, and the address form rendered a blank country
414
+ // field for it (evoo, 2026-09-13).
415
+ [{ iso_2: countryCode, display_name: countryName(countryCode) }]
416
+ : [],
417
+ [countries, storeCountries, countryCode]
418
+ )
419
+
420
+ const countriesInRegion = useMemo(
421
+ () => regionCountries.map((c) => c.iso_2).filter(Boolean),
422
+ [regionCountries]
423
+ )
424
+
425
+ const addressesInRegion = useMemo(
426
+ () =>
427
+ customer?.addresses?.filter(
428
+ (a) =>
429
+ !countriesInRegion.length ||
430
+ (a.country_code && countriesInRegion.includes(a.country_code))
431
+ ),
432
+ [customer?.addresses, countriesInRegion]
433
+ )
434
+
435
+ const setFormAddress = useCallback(
436
+ (
437
+ address?: Partial<CartAddress> | Partial<CustomerAddress>,
438
+ email?: string
439
+ ) => {
440
+ if (address) {
441
+ setFormData((prev) => ({
442
+ ...prev,
443
+ "shipping_address.first_name": address.first_name || "",
444
+ "shipping_address.last_name": address.last_name || "",
445
+ "shipping_address.address_1": address.address_1 || "",
446
+ "shipping_address.company": address.company || "",
447
+ "shipping_address.postal_code": address.postal_code || "",
448
+ "shipping_address.city": address.city || "",
449
+ "shipping_address.country_code": address.country_code || "",
450
+ "shipping_address.province": address.province || "",
451
+ "shipping_address.phone": address.phone || "",
452
+ }))
453
+ }
454
+ if (email) setFormData((prev) => ({ ...prev, email }))
455
+ },
456
+ []
457
+ )
458
+
459
+ // Re-seed formData from cart when it changes id (e.g. region switch
460
+ // creates a fresh cart). Disabled within the same cart id so user's
461
+ // in-progress edits aren't clobbered by a refresh after a
462
+ // shipping/payment mutation.
463
+ useEffect(() => {
464
+ if (cart?.shipping_address)
465
+ setFormAddress(cart.shipping_address, cart.email ?? undefined)
466
+ if (cart && !cart.email && customer?.email)
467
+ setFormAddress(undefined, customer.email)
468
+ // eslint-disable-next-line react-hooks/exhaustive-deps
469
+ }, [cart?.id])
470
+
471
+ const allRequiredFilled = REQUIRED_ADDRESS_FIELDS.every(
472
+ (f) => (formData[f] ?? "").trim().length > 0
473
+ )
474
+
475
+ // ── 3-second idle attention cue ────────────────────────────────────
476
+ // After 3 seconds of no typing/focus activity AND not all required
477
+ // fields filled, flag the empty required fields with a soft-blue
478
+ // pulse so the customer knows where to look. Production UX fix
479
+ // (2026-05-06) after a real customer reported being stuck on the
480
+ // shipping section without realising one address field was empty.
481
+ //
482
+ // Color: sky-500 (in the Field primitive). Distinct from focus
483
+ // (orange/primary) and error (red/destructive).
484
+ // Threshold: 3s of no formData change. Resets per-field (a field
485
+ // unflags itself the moment it becomes non-empty).
486
+ // First load: yes — we want stuck customers to see the cue
487
+ // immediately, not only after they've already tried to interact.
488
+ const PULSE_IDLE_MS = 3000
489
+ const [pulseFields, setPulseFields] = useState<Set<string>>(new Set())
490
+ useEffect(() => {
491
+ if (allRequiredFilled) {
492
+ if (pulseFields.size > 0) setPulseFields(new Set())
493
+ return
494
+ }
495
+ const timer = setTimeout(() => {
496
+ const empty = REQUIRED_ADDRESS_FIELDS.filter(
497
+ (f) => !((formData[f] ?? "").trim().length > 0)
498
+ )
499
+ setPulseFields(new Set(empty))
500
+ }, PULSE_IDLE_MS)
501
+ return () => clearTimeout(timer)
502
+ // We intentionally depend on the FULL formData object so any
503
+ // keystroke / saved-address selection / blur-driven update resets
504
+ // the timer. allRequiredFilled is also tracked so the cue clears
505
+ // the moment the last empty required field gets a value.
506
+ // eslint-disable-next-line react-hooks/exhaustive-deps
507
+ }, [formData, allRequiredFilled])
508
+
509
+ // First-render seed of lastSavedSnapshotRef. When a returning user
510
+ // lands on /checkout with a cart that already has email + shipping
511
+ // address persisted server-side, formData initializes from the cart
512
+ // and matches what's already saved. Without this seed, the auto-save
513
+ // effect sees ref="" (not yet seeded) vs a populated snapshot and
514
+ // schedules a redundant `updateCart` 600ms after mount — delaying the
515
+ // Place Order button render by a full round-trip on every reload.
516
+ //
517
+ // Guard with `cart?.email && cart?.shipping_address?.first_name` so
518
+ // we only seed when the cart genuinely has the data persisted; a
519
+ // half-populated cart leaves ref="" so the user's first save fires
520
+ // normally.
521
+ const snapshotSeededRef = useRef(false)
522
+ if (
523
+ !snapshotSeededRef.current &&
524
+ allRequiredFilled &&
525
+ cart?.email &&
526
+ cart?.shipping_address?.first_name
527
+ ) {
528
+ snapshotSeededRef.current = true
529
+ lastSavedSnapshotRef.current = snapshotAddressForm(formData, sameAsBilling)
530
+ }
531
+
532
+ const saveAddress = useCallback(async () => {
533
+ if (!allRequiredFilled) return
534
+ const snapshot = snapshotAddressForm(formData, sameAsBilling)
535
+ // Skip redundant saves — if the form hasn't changed since last
536
+ // successful persist, don't re-hit the network. Critical for the
537
+ // debounced auto-save: every formData change triggers the effect,
538
+ // but only meaningful changes should reach the server.
539
+ if (snapshot === lastSavedSnapshotRef.current) return
540
+ // If a save is already in flight, register this snapshot as
541
+ // pending. The in-flight save's `finally` will re-fire saveAddress
542
+ // so the latest form state always wins. Without this, formData
543
+ // edits that happen during a save get silently dropped.
544
+ if (addressSavingRef.current) {
545
+ pendingSnapshotRef.current = snapshot
546
+ return
547
+ }
548
+ addressSavingRef.current = true
549
+ setAddressSaving(true)
550
+ setAddressError(null)
551
+ try {
552
+ const shippingAddress = {
553
+ first_name: formData["shipping_address.first_name"],
554
+ last_name: formData["shipping_address.last_name"],
555
+ address_1: formData["shipping_address.address_1"],
556
+ address_2: "",
557
+ company: formData["shipping_address.company"] || "",
558
+ postal_code: formData["shipping_address.postal_code"],
559
+ city: formData["shipping_address.city"],
560
+ country_code: formData["shipping_address.country_code"],
561
+ province: formData["shipping_address.province"] || "",
562
+ phone: formData["shipping_address.phone"] || "",
563
+ }
564
+ const addressData: UpdateCartInput = {
565
+ shipping_address: shippingAddress,
566
+ email: formData.email,
567
+ }
568
+ if (sameAsBilling) addressData.billing_address = shippingAddress
569
+
570
+ const hasCompany = formData.company_name?.trim()
571
+ if (hasCompany) {
572
+ addressData.metadata = {
573
+ ...(cart?.metadata ?? {}),
574
+ company_name: formData.company_name,
575
+ company_vat: formData.company_vat || "",
576
+ company_mol: formData.company_mol || "",
577
+ company_address: formData.company_address || "",
578
+ }
579
+ }
580
+
581
+ await updateCart(client, cart.id, addressData)
582
+ lastSavedSnapshotRef.current = snapshot
583
+
584
+ if (customer) {
585
+ // Sync the profile best-effort. Cartbase has first-class company
586
+ // fields on the customer (company_name / company_eik —
587
+ // api/customers.ts), so the invoice data also lands on the
588
+ // profile — an upgrade over the @1click cart-metadata-only
589
+ // storage (its KNOWN_ISSUES entry).
590
+ const patch: UpdateCustomerInput = {
591
+ first_name: formData["shipping_address.first_name"],
592
+ last_name: formData["shipping_address.last_name"],
593
+ phone: formData["shipping_address.phone"] || undefined,
594
+ }
595
+ if (hasCompany) {
596
+ patch.company_name = formData.company_name
597
+ if (formData.company_vat) patch.company_eik = formData.company_vat
598
+ }
599
+ updateMe(client, patch).catch(() => {})
600
+ }
601
+ } catch (e: unknown) {
602
+ // ── Visibility ─────────────────────────────────────────────────
603
+ // Without this log point, address-save failures are completely
604
+ // silent operationally. PII discipline: log only structured/
605
+ // non-PII fields (country_code, postal_code, flags). No names,
606
+ // email, phone, address line, or company VAT/MOL.
607
+ const errObj = e instanceof Error ? e : null
608
+ logError?.(
609
+ "address_save_failed",
610
+ errObj?.message ?? String(e),
611
+ {
612
+ err_name: errObj?.name ?? null,
613
+ err_code:
614
+ (e as { code?: string } | null)?.code ?? null,
615
+ err_status:
616
+ (e as { status?: number } | null)?.status ?? null,
617
+ cart_id: cart?.id,
618
+ customer_id: customer?.id,
619
+ all_required_filled: allRequiredFilled,
620
+ same_as_billing: sameAsBilling,
621
+ has_company: Boolean(formData.company_name?.trim()),
622
+ country_code: formData["shipping_address.country_code"] || null,
623
+ postal_code: formData["shipping_address.postal_code"] || null,
624
+ }
625
+ )
626
+ // Into the store's own language. The raw error.message is technical
627
+ // English or a validation envelope, and neither is acceptable to
628
+ // show a shopper at the moment of failure. Unknown errors get the
629
+ // generic sentence rather than half-translated text.
630
+ setAddressError(translateAddressError(errObj ?? e, checkoutLabels.addressErrors))
631
+ } finally {
632
+ setAddressSaving(false)
633
+ addressSavingRef.current = false
634
+ // If formData changed during the save, fire again with the
635
+ // latest state. Loops at most once per real user edit because
636
+ // the snapshot guard skips duplicates.
637
+ const pending = pendingSnapshotRef.current
638
+ pendingSnapshotRef.current = null
639
+ if (pending && pending !== lastSavedSnapshotRef.current) {
640
+ void saveAddressRef.current?.()
641
+ }
642
+ }
643
+ }, [
644
+ client,
645
+ formData,
646
+ allRequiredFilled,
647
+ sameAsBilling,
648
+ customer,
649
+ cart?.id,
650
+ cart?.metadata,
651
+ logError,
652
+ ])
653
+
654
+ // Self-reference for the post-save re-fire path. Captured via ref so
655
+ // the callback can call the latest version of itself without making
656
+ // useCallback's dep list circular.
657
+ const saveAddressRef = useRef(saveAddress)
658
+ useEffect(() => {
659
+ saveAddressRef.current = saveAddress
660
+ }, [saveAddress])
661
+
662
+ // ── Auto-save effect ───────────────────────────────────────────────
663
+ // The single source of truth for "form data → server cart" sync.
664
+ // Watches formData and fires saveAddress after a debounce window.
665
+ // This is what makes the persistence robust to:
666
+ // - Browser autofill (1Password, Bitwarden, Chrome) which can fill
667
+ // multiple fields without firing per-field blur events
668
+ // - sessionStorage form restore on mount
669
+ // - Programmatic / paste-driven fills with no blur
670
+ // - Saved-customer-address selection (setFormAddress)
671
+ //
672
+ // Before this effect, persistence relied on `handleFieldBlur` —
673
+ // which made the cart silently empty whenever the form got filled
674
+ // by a non-blur path. That's the bug that left cart.email NULL on
675
+ // anonymous carts and kept the Place Order button disabled even
676
+ // when the form looked complete.
677
+ //
678
+ // `handleFieldBlur` is preserved as the immediate-save shortcut so
679
+ // the typical typing path doesn't wait for the debounce.
680
+ useEffect(() => {
681
+ if (!allRequiredFilled) return
682
+ const snapshot = snapshotAddressForm(formData, sameAsBilling)
683
+ if (snapshot === lastSavedSnapshotRef.current) return
684
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
685
+ saveTimerRef.current = setTimeout(() => {
686
+ saveTimerRef.current = null
687
+ void saveAddress()
688
+ }, ADDRESS_AUTO_SAVE_DEBOUNCE_MS)
689
+ return () => {
690
+ if (saveTimerRef.current) {
691
+ clearTimeout(saveTimerRef.current)
692
+ saveTimerRef.current = null
693
+ }
694
+ }
695
+ }, [formData, allRequiredFilled, sameAsBilling, saveAddress])
696
+
697
+ // flushAddressSave — used by shipping/payment selection handlers to
698
+ // guarantee the latest form state is persisted BEFORE a state-
699
+ // advancing mutation runs. Cancels any pending debounce and awaits
700
+ // the save synchronously. Without this, a fast user (clicks Econt
701
+ // < 600ms after their last keystroke) advances on a stale cart.
702
+ const flushAddressSave = useCallback(async () => {
703
+ if (saveTimerRef.current) {
704
+ clearTimeout(saveTimerRef.current)
705
+ saveTimerRef.current = null
706
+ }
707
+ if (!allRequiredFilled) return
708
+ await saveAddress()
709
+ }, [allRequiredFilled, saveAddress])
710
+
711
+ const handleFormChange = useCallback(
712
+ (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
713
+ const updated = { ...formData, [e.target.name]: e.target.value }
714
+ setFormData(updated)
715
+
716
+ // For selects (country), save immediately on change since there's
717
+ // no blur. Reads from `updated` (not `formData`) so the post-set
718
+ // value is what gets validated and persisted.
719
+ if (e.target.tagName === "SELECT") {
720
+ const filled = REQUIRED_ADDRESS_FIELDS.every(
721
+ (f) => (updated[f] ?? "").trim().length > 0
722
+ )
723
+ if (filled) saveAddress()
724
+ }
725
+ },
726
+ [formData, saveAddress]
727
+ )
728
+
729
+ const handleFieldBlur = useCallback(() => {
730
+ if (allRequiredFilled) saveAddress()
731
+ }, [allRequiredFilled, saveAddress])
732
+
733
+ const addressReady =
734
+ allRequiredFilled || !!(cart?.shipping_address && cart?.email)
735
+
736
+ const addressInput = useMemo(
737
+ () =>
738
+ ({
739
+ first_name: formData["shipping_address.first_name"],
740
+ last_name: formData["shipping_address.last_name"],
741
+ address_1: formData["shipping_address.address_1"],
742
+ company: formData["shipping_address.company"],
743
+ postal_code: formData["shipping_address.postal_code"],
744
+ city: formData["shipping_address.city"],
745
+ country_code: formData["shipping_address.country_code"],
746
+ province: formData["shipping_address.province"],
747
+ phone: formData["shipping_address.phone"],
748
+ }) as Record<string, string>,
749
+ [formData]
750
+ )
751
+
752
+ // ── Shipping ────────────────────────────────────────────────────────
753
+ const [shippingLoading] = useState(false)
754
+ const [shippingError, setShippingError] = useState<string | null>(null)
755
+ const [optimisticShippingCost, setOptimisticShippingCost] = useState<
756
+ number | null
757
+ >(null)
758
+ const [selectedShippingMethod, setSelectedShippingMethod] = useState<
759
+ string | null
760
+ >(cart?.shipping_methods?.at(-1)?.shipping_option_id || null)
761
+
762
+ // Every listed option is offerable: the Medusa-era pickup filter read an
763
+ // embedded fulfillment_set the Cartbase API never sends (it never fired),
764
+ // and the concept itself died in the shipping vocabulary trial.
765
+ const shippingMethods = useMemo(
766
+ () => availableShippingMethods ?? [],
767
+ [availableShippingMethods]
768
+ )
769
+
770
+ const selectedShippingOption = useMemo(
771
+ () =>
772
+ shippingMethods.find((sm) => sm.id === selectedShippingMethod) ?? null,
773
+ [shippingMethods, selectedShippingMethod]
774
+ )
775
+
776
+ const effectiveAvailablePaymentMethods = useMemo(
777
+ () =>
778
+ paymentMethodFilter
779
+ ? paymentMethodFilter(availablePaymentMethods, selectedShippingOption)
780
+ : availablePaymentMethods,
781
+ [paymentMethodFilter, availablePaymentMethods, selectedShippingOption]
782
+ )
783
+
784
+ // Calculated-rate price resolution. Cartbase serves flat prices today
785
+ // (`price_type: "flat"` always — api/checkout.ts), so this effect
786
+ // normally resolves immediately; the calculated branch is kept for
787
+ // forward-compat with calculated-rate carriers (the /calculate route
788
+ // already exists).
789
+ const [calculatedPricesMap, setCalculatedPricesMap] = useState<
790
+ Record<string, number>
791
+ >({})
792
+ const [isLoadingPrices, setIsLoadingPrices] = useState(true)
793
+
794
+ useEffect(() => {
795
+ if (!shippingMethods.length) {
796
+ setIsLoadingPrices(false)
797
+ return
798
+ }
799
+ setIsLoadingPrices(true)
800
+ const calculated = shippingMethods.filter(
801
+ (sm) => (sm.price_type as string) === "calculated"
802
+ )
803
+ if (!calculated.length) {
804
+ setIsLoadingPrices(false)
805
+ return
806
+ }
807
+ Promise.allSettled(
808
+ calculated.map((sm) =>
809
+ calculateShippingOption(client, sm.id, { cart_id: cart.id })
810
+ )
811
+ ).then((res) => {
812
+ const map: Record<string, number> = {}
813
+ res.forEach((p) => {
814
+ if (p.status === "fulfilled" && p.value?.shipping_option)
815
+ map[p.value.shipping_option.id ?? ""] =
816
+ p.value.shipping_option.amount ?? 0
817
+ })
818
+ setCalculatedPricesMap(map)
819
+ setIsLoadingPrices(false)
820
+ })
821
+ }, [availableShippingMethods, cart.id, shippingMethods, client])
822
+
823
+ // ── Carrier metadata ────────────────────────────────────────────────
824
+ // Carrier metadata is held in client state ONLY and written to the cart
825
+ // exactly once at Buy click via prepareCheckout's `carrier_metadata`.
826
+ // No eager updateCart on selection — that path was the source of the
827
+ // office-vs-direct-address and BoxNow-vs-Econt mismatched-data bugs.
828
+ // Server-side, prepare-checkout removes the PREVIOUS prepare's carrier
829
+ // keys before merging (the `_prepared_carrier_keys` marker), so
830
+ // switching carriers can never leak stale fields into the order.
831
+ const [selectedEcontOffice, setSelectedEcontOffice] =
832
+ useState<EcontOffice | null>(
833
+ cart?.metadata?.econt_office_code
834
+ ? ({
835
+ code: cart.metadata.econt_office_code as string,
836
+ name: cart.metadata.econt_office_name as string,
837
+ } as EcontOffice)
838
+ : null
839
+ )
840
+
841
+ const handleSelectEcontOffice = useCallback(
842
+ (office: EcontOffice | null) => {
843
+ setSelectedEcontOffice(office)
844
+ },
845
+ []
846
+ )
847
+
848
+ const [selectedBoxnowLocker, setSelectedBoxnowLocker] =
849
+ useState<BoxNowLocker | null>(
850
+ cart?.metadata?.boxnow_locker_id
851
+ ? ({
852
+ id: cart.metadata.boxnow_locker_id as string,
853
+ title: (cart.metadata.boxnow_locker_title as string) ?? "",
854
+ addressLine1:
855
+ (cart.metadata.boxnow_locker_address as string) ?? "",
856
+ addressLine2: "",
857
+ postalCode: (cart.metadata.boxnow_locker_postal as string) ?? "",
858
+ country: "",
859
+ lat: null,
860
+ lng: null,
861
+ note: "",
862
+ } as BoxNowLocker)
863
+ : null
864
+ )
865
+
866
+ const handleSelectBoxnowLocker = useCallback(
867
+ (locker: BoxNowLocker | null) => {
868
+ setSelectedBoxnowLocker(locker)
869
+ },
870
+ []
871
+ )
872
+
873
+ // ── Payment ─────────────────────────────────────────────────────────
874
+ const [paymentError, setPaymentError] = useState<string | null>(null)
875
+
876
+ const hasCard = !!effectiveAvailablePaymentMethods?.some(
877
+ (m) => "id" in m && isStripeLike(m.id)
878
+ )
879
+ const cardId = (
880
+ effectiveAvailablePaymentMethods?.find(
881
+ (m) => "id" in m && isStripeLike(m.id)
882
+ ) as { id: string } | undefined
883
+ )?.id
884
+ // The offline tab = a merchant METHOD (the COD switch, else the first
885
+ // manual method) — sessions initiate by payment_method_id, provider NULL.
886
+ const offlineMethod = findOfflineMethod(effectiveAvailablePaymentMethods)
887
+ const hasCod = !!offlineMethod
888
+ const codMethodId = offlineMethod?.payment_method_id
889
+ const offlineIsCodKind = offlineMethod?.kind === "cod"
890
+
891
+ // Default tab: card when available, else COD. The eager-session model
892
+ // used to seed from the cart's pending session provider; in the
893
+ // deferred-intent model there is no session at mount.
894
+ const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
895
+ hasCard ? "card" : "cod"
896
+ )
897
+
898
+ // Optimistic method-fee state. Painted instantly on tab toggle so the
899
+ // totals row shows the predicted fee BEFORE the server applies it (at
900
+ // prepare, when the method session is minted). Any method may carry a
901
+ // fee — the prediction reads the selected entry's own fee_amount.
902
+ // Three values:
903
+ // - null → no prediction; render whatever the cart says
904
+ // - 0 → predict no fee (toggling to a fee-less tender)
905
+ // - positive → predict the fee at this amount
906
+ const [optimisticMethodFee, setOptimisticMethodFee] = useState<number | null>(
907
+ null
908
+ )
909
+
910
+ // Payment tab selection is client state only pre-Buy. No payment
911
+ // session is created until Buy click — this eliminates the entire
912
+ // class of session-rotation / amount-drift / iframe-remount bugs
913
+ // caused by the old eager-session model. AFTER a prepare (failed Buy
914
+ // retry state) a pending session may exist — the best-effort sync
915
+ // below rotates it to the newly picked provider per the
916
+ // sync-payment-amount matrix (docs/storefront/checkout.md);
917
+ // pre-Buy it no-ops with `no_payment_collection`.
918
+ const handlePaymentTab = useCallback(
919
+ (tab: "card" | "cod") => {
920
+ setPaymentTab(tab)
921
+ setPaymentError(null)
922
+
923
+ // Optimistic fee prediction so the totals row updates instantly —
924
+ // the selected METHOD entry carries its own fee; processors charge
925
+ // none. Mirrors the server exactly (resolveMethodFee reads the same
926
+ // row the listing serialized).
927
+ if (offlineMethod) {
928
+ const fee = Number(offlineMethod.fee_amount ?? 0)
929
+ setOptimisticMethodFee(tab === "cod" && fee > 0 ? fee : 0)
930
+ }
931
+
932
+ const tender =
933
+ tab === "card"
934
+ ? cardId
935
+ ? { provider_id: cardId }
936
+ : null
937
+ : codMethodId
938
+ ? { payment_method_id: codMethodId }
939
+ : null
940
+ if (tender) {
941
+ void syncPaymentAmountApi(client, cart.id, tender).catch(() => {})
942
+ }
943
+ },
944
+ [cart.id, client, cardId, codMethodId, offlineMethod]
945
+ )
946
+
947
+ // Shipping selection is client state only. No addShippingMethod call,
948
+ // no metadata-clear updateCart — all of those wrote to the cart
949
+ // between toggles and produced the stale-data bug class. The shipping
950
+ // method ID is sent to the backend exactly once at Buy click via
951
+ // prepareCheckout.
952
+ const handleSelectShipping = useCallback(
953
+ (id: string) => {
954
+ setShippingError(null)
955
+ setSelectedShippingMethod(id)
956
+
957
+ // Optimistic shipping cost — paint the totals row immediately so
958
+ // the customer sees the right number before any network call.
959
+ const option = shippingMethods.find((m) => m.id === id)
960
+ if (option) {
961
+ const price =
962
+ option.price_type === "flat"
963
+ ? option.amount
964
+ : calculatedPricesMap[option.id]
965
+ if (price !== undefined && price !== null)
966
+ setOptimisticShippingCost(price)
967
+ }
968
+
969
+ // Switching shipping invalidates any previously-selected carrier-
970
+ // specific destination (e.g. picking direct address after BoxNow
971
+ // locker). All client state — no eager metadata-clear updateCart.
972
+ setSelectedBoxnowLocker(null)
973
+ setSelectedEcontOffice(null)
974
+ },
975
+ [shippingMethods, calculatedPricesMap]
976
+ )
977
+
978
+ // ── Delivery readiness ──────────────────────────────────────────────
979
+ const selectedFulfillmentOptionId = useMemo(() => {
980
+ const data = selectedShippingOption?.data as
981
+ | { id?: string }
982
+ | undefined
983
+ | null
984
+ return typeof data?.id === "string" ? data.id : null
985
+ }, [selectedShippingOption])
986
+ const selectedIsBoxnow = selectedFulfillmentOptionId === "boxnow-locker"
987
+ const selectedIsEcont = selectedFulfillmentOptionId === "econt-office"
988
+
989
+ // Defensive: trust cart.metadata for locker/office IDs in addition to
990
+ // local React state. On mobile the BoxNow locker selector was seen
991
+ // firing its onSelect handler in a way that updated cart.metadata
992
+ // cleanly but left the local `selectedBoxnowLocker` stale (touch event
993
+ // timing / hydration race). Without this fallback, the local-null kept
994
+ // `deliveryReady` false and the entire payment section turned into a
995
+ // ghost — even though the cart server-side knew the locker was set.
996
+ const hasBoxnowLockerInCart = !!cart?.metadata?.boxnow_locker_id
997
+ const hasEcontOfficeInCart = !!cart?.metadata?.econt_office_code
998
+
999
+ const deliveryReady =
1000
+ (!!selectedShippingMethod ||
1001
+ (cart?.shipping_methods?.length ?? 0) > 0) &&
1002
+ (!selectedIsBoxnow || !!selectedBoxnowLocker || hasBoxnowLockerInCart) &&
1003
+ (!selectedIsEcont || !!selectedEcontOffice || hasEcontOfficeInCart)
1004
+
1005
+ // Reconcile paymentTab with currently-available methods. When the
1006
+ // store's paymentMethodFilter strips a method in response to a shipping
1007
+ // change (e.g. BoxNow → no COD), the previously selected tab can point
1008
+ // to a method that's no longer rendered — leaving the remaining tab's
1009
+ // radio looking unselected and the form collapsed.
1010
+ useEffect(() => {
1011
+ if (!deliveryReady) return
1012
+ if (paymentTab === "cod" && !hasCod && hasCard) {
1013
+ handlePaymentTab("card")
1014
+ } else if (paymentTab === "card" && !hasCard && hasCod) {
1015
+ handlePaymentTab("cod")
1016
+ }
1017
+ }, [deliveryReady, paymentTab, hasCard, hasCod, handlePaymentTab])
1018
+
1019
+ const handlePaymentElementChange = useCallback(
1020
+ (_e: { complete: boolean; selectedMethod: string | null }) => {
1021
+ setPaymentError(null)
1022
+ },
1023
+ []
1024
+ )
1025
+
1026
+ // ── Place order (complete + navigate) ───────────────────────────────
1027
+ // Successor of @1click's placeOrder server action: write tracking
1028
+ // attribution into cart.metadata (consent-gated by the caller), then
1029
+ // POST /complete via the SDK, then navigate to the confirmed page (or
1030
+ // hand the order to `onOrderPlaced`).
1031
+ const placeOrder = useCallback(async (): Promise<CompletedOrder> => {
1032
+ const trackingMeta = resolveTrackingMetadata?.()
1033
+ if (trackingMeta && Object.keys(trackingMeta).length > 0) {
1034
+ // Best-effort — a failed attribution write must never block the
1035
+ // order. Cart completion copies cart.metadata → order.metadata,
1036
+ // which the backend order.placed forwarder reads.
1037
+ await updateCart(client, cart.id, {
1038
+ metadata: { ...(cart.metadata ?? {}), ...trackingMeta },
1039
+ }).catch(() => {})
1040
+ }
1041
+
1042
+ const { order } = await completeCart(client, cart.id)
1043
+
1044
+ if (onOrderPlaced) {
1045
+ onOrderPlaced(order)
1046
+ } else if (typeof window !== "undefined") {
1047
+ window.location.assign(
1048
+ resolveOrderConfirmedPath(
1049
+ orderConfirmedPath,
1050
+ order,
1051
+ formData["shipping_address.country_code"]
1052
+ )
1053
+ )
1054
+ }
1055
+ return order
1056
+ }, [
1057
+ client,
1058
+ cart.id,
1059
+ cart.metadata,
1060
+ onOrderPlaced,
1061
+ orderConfirmedPath,
1062
+ resolveTrackingMetadata,
1063
+ formData,
1064
+ ])
1065
+
1066
+ // ── Amount sync + dead-PI recovery (SDK-wrapped, exposed) ───────────
1067
+ // syncPaymentAmount: call after anything that changes the total while
1068
+ // a pending session exists (gift-card apply/remove, quantity change,
1069
+ // shipping switch post-prepare). Happy path keeps the SAME
1070
+ // client_secret so <Elements> never remounts.
1071
+ const syncPaymentAmount = useCallback(
1072
+ (providerId?: string): Promise<SyncPaymentAmountResult> =>
1073
+ syncPaymentAmountApi(
1074
+ client,
1075
+ cart.id,
1076
+ providerId ? { provider_id: providerId } : {}
1077
+ ),
1078
+ [client, cart.id]
1079
+ )
1080
+
1081
+ // refreshPaymentIfTerminal: REACTIVE dead-PI recovery — call from
1082
+ // Stripe Elements `loaderror` or on page mount for aged carts, never
1083
+ // proactively per render (the proactive variant caused a production
1084
+ // reload loop; transient Stripe errors deliberately refuse to rotate).
1085
+ const refreshPaymentIfTerminal = useCallback(
1086
+ (): Promise<RefreshPaymentResult> =>
1087
+ refreshPaymentIfTerminalApi(client, cart.id),
1088
+ [client, cart.id]
1089
+ )
1090
+
1091
+ // ── 3DS / bank-redirect return handler ──────────────────────────────
1092
+ // Stripe confirmPayment with redirect: "if_required" navigates to
1093
+ // return_url ONLY when the method demands it (3DS challenge, bank-
1094
+ // redirect APMs). On return the browser carries
1095
+ // ?payment_intent=...&redirect_status=succeeded|...
1096
+ //
1097
+ // In the deferred-intent flow, the PaymentIntent was created at Buy
1098
+ // click via prepareCheckout; the cart already has a pending session
1099
+ // pointing at that PI. After 3DS succeeds the PI is in
1100
+ // requires_capture / succeeded — complete can authorize. We just
1101
+ // call placeOrder (= POST /complete).
1102
+ //
1103
+ // Strips query params before async work so a refresh / re-render
1104
+ // doesn't re-trigger this effect.
1105
+ const threeDSHandledRef = useRef(false)
1106
+ useEffect(() => {
1107
+ if (typeof window === "undefined") return
1108
+ if (threeDSHandledRef.current) return
1109
+
1110
+ const url = new URL(window.location.href)
1111
+ const redirectStatus = url.searchParams.get("redirect_status")
1112
+ const paymentIntentId = url.searchParams.get("payment_intent")
1113
+ if (!redirectStatus || !paymentIntentId) return
1114
+
1115
+ threeDSHandledRef.current = true
1116
+
1117
+ ;[
1118
+ "redirect_status",
1119
+ "payment_intent",
1120
+ "payment_intent_client_secret",
1121
+ ].forEach((k) => url.searchParams.delete(k))
1122
+ window.history.replaceState({}, "", url.toString())
1123
+
1124
+ if (redirectStatus === "succeeded") {
1125
+ placeOrder().catch((err: unknown) => {
1126
+ const translated = translatePaymentError(err, "card", checkoutLabels.paymentErrors)
1127
+ setPaymentError(translated)
1128
+ logError?.(
1129
+ "place_order_error",
1130
+ err instanceof Error ? err.message : String(err),
1131
+ {
1132
+ via: "3ds_return",
1133
+ cartId: cart.id,
1134
+ paymentIntentId,
1135
+ }
1136
+ )
1137
+ })
1138
+ } else {
1139
+ // The store's own words. This was a hardcoded BULGARIAN sentence until
1140
+ // 2026-09-13: the 2026-09-13 morning sweep fixed the three error-copy
1141
+ // modules and missed this one, because it sits in the 3DS-return
1142
+ // effect rather than in a copy module. Same meaning, from the pack.
1143
+ setPaymentError(checkoutLabels.paymentErrors.paymentNotAuthorized)
1144
+ logError?.("place_order_error", "redirect_not_succeeded", {
1145
+ via: "3ds_return",
1146
+ cartId: cart.id,
1147
+ paymentIntentId,
1148
+ redirectStatus,
1149
+ })
1150
+ }
1151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1152
+ }, [])
1153
+
1154
+ const summaryCart = cart
1155
+
1156
+ // ── Optimistic total (display, EUR major units) ─────────────────────
1157
+ // Single source of truth for "what total to show on every checkout
1158
+ // surface that previously read cart.total" — order summary, Place
1159
+ // Order button.
1160
+ //
1161
+ // Why this exists. The deferred-checkout architecture made cart.total
1162
+ // stale until Buy click: shipping & COD fee are now client state, not
1163
+ // written to the cart pre-Buy. Surfaces reading cart.total raw drift
1164
+ // visibly. This memo applies the compensating math once, hoisted so
1165
+ // every consumer shares one formula.
1166
+ //
1167
+ // Formula:
1168
+ // start = cart.total (covers subtotal + tax + any already-written
1169
+ // shipping/fee)
1170
+ // if optimisticShippingCost set: replace cart.shipping_total with it
1171
+ // if optimisticMethodFee set: replace cart.payment_method_fee_total
1172
+ // with it (the fee is CART-LEVEL decoration folded into total,
1173
+ // not a fee line item)
1174
+ // When nothing is optimistic, this collapses to cart.total.
1175
+ const optimisticTotal = useMemo(() => {
1176
+ const realMethodFeeAmount = cart?.payment_method_fee_total ?? 0
1177
+ let total = cart?.total ?? 0
1178
+ if (optimisticShippingCost !== null) {
1179
+ total = total - (cart?.shipping_total ?? 0) + optimisticShippingCost
1180
+ }
1181
+ if (optimisticMethodFee !== null && optimisticMethodFee !== undefined) {
1182
+ total = total - realMethodFeeAmount + optimisticMethodFee
1183
+ }
1184
+ return total
1185
+ }, [
1186
+ cart?.total,
1187
+ cart?.shipping_total,
1188
+ cart?.payment_method_fee_total,
1189
+ optimisticShippingCost,
1190
+ optimisticMethodFee,
1191
+ ])
1192
+
1193
+ // ── Optimistic total in cents (for Stripe Elements deferred-intent) ─
1194
+ // Stripe's deferred-intent <Elements> needs `amount` + `currency` at
1195
+ // mount time (no PaymentIntent on the backend yet). Same value as
1196
+ // `optimisticTotal` above, just in the smallest currency unit and
1197
+ // floor-clamped to Stripe's minimum charge. Round to protect against
1198
+ // float drift in optimistic deltas. (Store-API money is EUR major
1199
+ // units — minor-unit conversion happens ONLY at this Stripe display
1200
+ // boundary; the charged amount is always the server's.)
1201
+ const optimisticTotalCents = useMemo(
1202
+ () => Math.max(50, Math.round(optimisticTotal * 100)),
1203
+ [optimisticTotal]
1204
+ )
1205
+
1206
+ // ── Buy-click payload builder ───────────────────────────────────────
1207
+ // Constructs the prepareCheckout request body from current client
1208
+ // state. Called by PaymentButton on click.
1209
+ const buildPrepareCheckoutPayload =
1210
+ useCallback((): PrepareCheckoutInput => {
1211
+ const carrierMetadata: Record<string, unknown> = {}
1212
+ if (selectedEcontOffice) {
1213
+ const addr = [
1214
+ selectedEcontOffice.address?.street,
1215
+ selectedEcontOffice.address?.num,
1216
+ ]
1217
+ .filter(Boolean)
1218
+ .join(" ")
1219
+ carrierMetadata.econt_office_code = selectedEcontOffice.code
1220
+ carrierMetadata.econt_office_name = selectedEcontOffice.name
1221
+ carrierMetadata.econt_office_city =
1222
+ selectedEcontOffice.address?.city?.name || ""
1223
+ carrierMetadata.econt_office_address = addr
1224
+ carrierMetadata.econt_office_phone =
1225
+ selectedEcontOffice.phones?.[0] || ""
1226
+ }
1227
+ if (selectedBoxnowLocker) {
1228
+ carrierMetadata.boxnow_locker_id = selectedBoxnowLocker.id
1229
+ carrierMetadata.boxnow_locker_title = selectedBoxnowLocker.title
1230
+ carrierMetadata.boxnow_locker_address =
1231
+ selectedBoxnowLocker.addressLine1 ?? ""
1232
+ carrierMetadata.boxnow_locker_postal =
1233
+ selectedBoxnowLocker.postalCode ?? ""
1234
+ }
1235
+
1236
+ const shippingMethodId = selectedShippingMethod
1237
+ if (!shippingMethodId) {
1238
+ throw new Error("No shipping method selected")
1239
+ }
1240
+ // The tender: card tab → the processor; offline tab → the merchant
1241
+ // method (provider-less session, pp_* kill).
1242
+ const tender =
1243
+ paymentTab === "card"
1244
+ ? cardId
1245
+ ? { payment_provider: cardId }
1246
+ : null
1247
+ : codMethodId
1248
+ ? { payment_method_id: codMethodId }
1249
+ : null
1250
+ if (!tender) {
1251
+ throw new Error("No payment method available")
1252
+ }
1253
+
1254
+ return {
1255
+ shipping_address: {
1256
+ first_name: formData["shipping_address.first_name"] ?? "",
1257
+ last_name: formData["shipping_address.last_name"] ?? "",
1258
+ address_1: formData["shipping_address.address_1"] ?? "",
1259
+ address_2: "",
1260
+ city: formData["shipping_address.city"] ?? "",
1261
+ postal_code: formData["shipping_address.postal_code"] ?? "",
1262
+ country_code: formData["shipping_address.country_code"] ?? "",
1263
+ phone: formData["shipping_address.phone"] ?? "",
1264
+ },
1265
+ shipping_method_id: shippingMethodId,
1266
+ carrier_metadata: carrierMetadata,
1267
+ ...tender,
1268
+ }
1269
+ }, [
1270
+ formData,
1271
+ selectedShippingMethod,
1272
+ selectedEcontOffice,
1273
+ selectedBoxnowLocker,
1274
+ paymentTab,
1275
+ cardId,
1276
+ codMethodId,
1277
+ ])
1278
+
1279
+ // ── Buy click ───────────────────────────────────────────────────────
1280
+ // Single source of truth for the Buy-click flow. Called by PaymentButton.
1281
+ // Steps:
1282
+ // 1. Flush any pending address auto-save (so the server has the
1283
+ // latest email/name/phone for tracking + abandoned-cart).
1284
+ // 2. (Card path) elements.submit() — validates the form inside the
1285
+ // Stripe iframe before any server call.
1286
+ // 3. POST /api/store/carts/:id/prepare-checkout — ONE atomic,
1287
+ // compensated write of address + shipping + carrier metadata +
1288
+ // payment collection + session at the FINAL amount.
1289
+ // 4. (Card path) stripe.confirmPayment(elements, clientSecret) —
1290
+ // attaches the payment method and confirms. On 3DS this redirects
1291
+ // and we resume in the threeDSHandledRef effect. SKIPPED entirely
1292
+ // on the zero-remainder gift path (client_secret === null &&
1293
+ // provider_id === null — gift cards cover the whole total).
1294
+ // 5. placeOrder() (POST /complete) — authorize passes because the PI
1295
+ // is now in requires_capture / succeeded (or the gift session
1296
+ // covers everything).
1297
+ type BuyClickStripe = {
1298
+ submit: () => Promise<{ error?: { message?: string } | null }>
1299
+ stripe: {
1300
+ confirmPayment: (args: {
1301
+ elements: unknown
1302
+ clientSecret: string
1303
+ confirmParams: { return_url: string }
1304
+ redirect: "if_required"
1305
+ }) => Promise<{ error?: { message?: string } | null }>
1306
+ }
1307
+ elements: unknown
1308
+ }
1309
+
1310
+ const performBuyClick = useCallback(
1311
+ async (stripeBundle?: BuyClickStripe): Promise<void> => {
1312
+ setPaymentError(null)
1313
+
1314
+ // STATE-AT-CLICK SNAPSHOT — logs the EXACT state the storefront sees
1315
+ // when the customer clicks Buy. Diagnostic-only; no PII beyond
1316
+ // what's already in the order.
1317
+ const stateSnapshot = {
1318
+ cart_id: cart.id,
1319
+ paymentTab,
1320
+ cardId,
1321
+ codMethodId,
1322
+ selectedShippingMethod,
1323
+ selectedEcontOffice: selectedEcontOffice
1324
+ ? { code: selectedEcontOffice.code, name: selectedEcontOffice.name }
1325
+ : null,
1326
+ selectedBoxnowLocker: selectedBoxnowLocker
1327
+ ? {
1328
+ id: selectedBoxnowLocker.id,
1329
+ title: selectedBoxnowLocker.title,
1330
+ }
1331
+ : null,
1332
+ hasStripeBundle: !!stripeBundle,
1333
+ }
1334
+ dbg("[buy-click] STATE", stateSnapshot)
1335
+ // Per-click telemetry goes only to a merchant-wired sink, never to the
1336
+ // default platform error log — the sink is for failures.
1337
+ logErrorProp?.("other", "buy_click_state", stateSnapshot)
1338
+
1339
+ await flushAddressSave()
1340
+
1341
+ if (paymentTab === "card") {
1342
+ if (!stripeBundle) {
1343
+ // eslint-disable-next-line no-console
1344
+ console.error("[buy-click] card path but no stripe bundle")
1345
+ throw new Error("Stripe not ready")
1346
+ }
1347
+ dbg("[buy-click] elements.submit() …")
1348
+ const { error: submitError } = await stripeBundle.submit()
1349
+ if (submitError) {
1350
+ // eslint-disable-next-line no-console
1351
+ console.error("[buy-click] elements.submit() error", submitError)
1352
+ throw submitError
1353
+ }
1354
+ }
1355
+
1356
+ const payload = buildPrepareCheckoutPayload()
1357
+ // debug-only: the full payload carries the customer's name, phone,
1358
+ // email and address — it must never print in a customer's console.
1359
+ dbg("[buy-click] PAYLOAD →", payload)
1360
+ logErrorProp?.("other", "buy_click_payload", {
1361
+ provider_id: payload.payment_provider ?? null,
1362
+ payment_method_id: payload.payment_method_id ?? null,
1363
+ shipping_method_id: payload.shipping_method_id,
1364
+ carrier_metadata_keys: Object.keys(payload.carrier_metadata ?? {}),
1365
+ country_code: payload.shipping_address.country_code,
1366
+ })
1367
+
1368
+ const prep = await prepareCheckout(client, cart.id, payload).catch(
1369
+ (e: unknown) => {
1370
+ // eslint-disable-next-line no-console
1371
+ console.error("[buy-click] prepareCheckout threw", e)
1372
+ throw e
1373
+ }
1374
+ )
1375
+ dbg("[buy-click] prepareCheckout response", {
1376
+ has_client_secret: !!prep.client_secret,
1377
+ provider_id: prep.provider_id,
1378
+ })
1379
+
1380
+ // ── Zero-remainder gift path (Cartbase gift-card tender) ──────────
1381
+ // When applied gift cards cover the whole total, prepare skips the
1382
+ // provider session: client_secret AND provider_id come back null
1383
+ // and the cart completes on the internal pp_giftcard session alone
1384
+ // — no Stripe involved (docs/storefront/checkout.md +
1385
+ // gift-cards.md). Detect it BEFORE the card branch so a card-tab
1386
+ // Buy click on a fully-covered cart doesn't demand a secret.
1387
+ const zeroRemainderGiftPath =
1388
+ prep.client_secret === null && prep.provider_id === null
1389
+
1390
+ if (paymentTab === "card" && !zeroRemainderGiftPath) {
1391
+ if (!stripeBundle || !prep.client_secret) {
1392
+ // eslint-disable-next-line no-console
1393
+ console.error(
1394
+ "[buy-click] card path missing client_secret",
1395
+ { has_bundle: !!stripeBundle, has_secret: !!prep.client_secret }
1396
+ )
1397
+ throw new Error("Stripe client_secret missing after prepare")
1398
+ }
1399
+ const returnUrl =
1400
+ typeof window !== "undefined" ? window.location.href : ""
1401
+
1402
+ // billing_details — passed for AVS / Radar / 3DS risk scoring /
1403
+ // dispute defense. PaymentElement is NO LONGER mounted with
1404
+ // fields.billingDetails.address = "never" (see payment-method-
1405
+ // list.tsx) — that flag put Stripe into strict-completeness mode
1406
+ // and threw IntegrationError on the first missing sub-field
1407
+ // (country → state → next), which is what blocked card payments
1408
+ // on 2026-05-06. Without it, Stripe accepts whatever billing
1409
+ // details we provide and falls back to its iframe-collected data
1410
+ // for anything missing. We still pass full billing_details here
1411
+ // because the data improves auth rates regardless.
1412
+ //
1413
+ // `null` (not `undefined`) for empty fields — explicit null is
1414
+ // robust against any future Stripe SDK reintroducing presence
1415
+ // checks; undefined would read as "missing".
1416
+ const firstName = formData["shipping_address.first_name"] ?? ""
1417
+ const lastName = formData["shipping_address.last_name"] ?? ""
1418
+ const fullName = `${firstName} ${lastName}`.trim()
1419
+ const billingDetails = {
1420
+ name: fullName || null,
1421
+ email: formData.email || null,
1422
+ phone: formData["shipping_address.phone"] || null,
1423
+ address: {
1424
+ line1: formData["shipping_address.address_1"] || null,
1425
+ line2: null,
1426
+ city: formData["shipping_address.city"] || null,
1427
+ state: formData["shipping_address.province"] || null,
1428
+ postal_code: formData["shipping_address.postal_code"] || null,
1429
+ // ISO 3166-1 alpha-2, uppercase per Stripe convention.
1430
+ country:
1431
+ formData["shipping_address.country_code"]?.toUpperCase() || null,
1432
+ },
1433
+ }
1434
+
1435
+ dbg("[buy-click] stripe.confirmPayment() …", {
1436
+ has_country: !!billingDetails.address.country,
1437
+ })
1438
+ const { error } = await (stripeBundle.stripe as unknown as {
1439
+ confirmPayment: (args: unknown) => Promise<{ error?: unknown }>
1440
+ }).confirmPayment({
1441
+ elements: stripeBundle.elements,
1442
+ clientSecret: prep.client_secret,
1443
+ confirmParams: {
1444
+ return_url: returnUrl,
1445
+ payment_method_data: {
1446
+ billing_details: billingDetails,
1447
+ },
1448
+ },
1449
+ redirect: "if_required",
1450
+ })
1451
+ if (error) {
1452
+ const stripeErr = error as {
1453
+ type?: string
1454
+ code?: string
1455
+ decline_code?: string
1456
+ message?: string
1457
+ payment_intent?: { id?: string; status?: string }
1458
+ }
1459
+ // eslint-disable-next-line no-console
1460
+ console.error("[buy-click] stripe.confirmPayment error", stripeErr)
1461
+ // Log so the actual Stripe error code + message is readable
1462
+ // without needing the customer's browser console.
1463
+ logError?.(
1464
+ "stripe_confirm_error",
1465
+ stripeErr.message ?? "unknown",
1466
+ {
1467
+ type: stripeErr.type,
1468
+ code: stripeErr.code,
1469
+ decline_code: stripeErr.decline_code,
1470
+ pi_id: stripeErr.payment_intent?.id,
1471
+ pi_status: stripeErr.payment_intent?.status,
1472
+ cart_id: cart.id,
1473
+ client_secret_prefix: prep.client_secret.slice(0, 8),
1474
+ }
1475
+ )
1476
+ throw error
1477
+ }
1478
+ // confirmPayment succeeded synchronously (no redirect needed,
1479
+ // e.g. non-3DS card flow). The PaymentIntent is now in
1480
+ // requires_capture or succeeded — complete will pass authorize.
1481
+ dbg("[buy-click] stripe.confirmPayment SUCCESS (no redirect)")
1482
+ logErrorProp?.("stripe_confirm_succeeded", "ok", {
1483
+ cart_id: cart.id,
1484
+ client_secret_prefix: prep.client_secret.slice(0, 8),
1485
+ })
1486
+ } else if (zeroRemainderGiftPath) {
1487
+ dbg(
1488
+ "[buy-click] zero-remainder gift path — skipping Stripe, completing on the gift session"
1489
+ )
1490
+ logErrorProp?.("other", "zero_remainder_gift_path", { cart_id: cart.id })
1491
+ }
1492
+
1493
+ dbg("[buy-click] placeOrder() …")
1494
+ logErrorProp?.("order_placed", "called", {
1495
+ cart_id: cart.id,
1496
+ path: paymentTab,
1497
+ })
1498
+ await placeOrder().catch((e: unknown) => {
1499
+ // eslint-disable-next-line no-console
1500
+ console.error("[buy-click] placeOrder threw", e)
1501
+ throw e
1502
+ })
1503
+ },
1504
+ [
1505
+ client,
1506
+ cart.id,
1507
+ paymentTab,
1508
+ cardId,
1509
+ codMethodId,
1510
+ selectedShippingMethod,
1511
+ selectedEcontOffice,
1512
+ selectedBoxnowLocker,
1513
+ flushAddressSave,
1514
+ buildPrepareCheckoutPayload,
1515
+ placeOrder,
1516
+ formData,
1517
+ logError,
1518
+ ]
1519
+ )
1520
+
1521
+ return {
1522
+ // Completed-cart guard
1523
+ cartIsCompleted,
1524
+
1525
+ // Address form
1526
+ formData,
1527
+ setFormData,
1528
+ addressError,
1529
+ addressReady,
1530
+ allRequiredFilled,
1531
+ setFormAddress,
1532
+ handleFormChange,
1533
+ handleFieldBlur,
1534
+ addressInput,
1535
+ regionCountries,
1536
+ addressesInRegion,
1537
+ saveAddress,
1538
+ flushAddressSave,
1539
+ pulseFields,
1540
+
1541
+ // Shipping
1542
+ shippingMethods,
1543
+ calculatedPricesMap,
1544
+ isLoadingPrices,
1545
+ selectedShippingMethod,
1546
+ selectedShippingOption,
1547
+ selectedFulfillmentOptionId,
1548
+ selectedIsBoxnow,
1549
+ selectedIsEcont,
1550
+ shippingLoading,
1551
+ shippingError,
1552
+ optimisticShippingCost,
1553
+ setOptimisticShippingCost,
1554
+ handleSelectShipping,
1555
+
1556
+ // Carriers
1557
+ selectedEcontOffice,
1558
+ handleSelectEcontOffice,
1559
+ selectedBoxnowLocker,
1560
+ handleSelectBoxnowLocker,
1561
+
1562
+ // Payment
1563
+ paymentTab,
1564
+ hasCard,
1565
+ hasCod,
1566
+ cardId,
1567
+ /** The offline tab's method (the COD switch or a manual method). */
1568
+ codMethodId,
1569
+ offlineMethod,
1570
+ paymentError,
1571
+ setPaymentError,
1572
+ deliveryReady,
1573
+ optimisticMethodFee,
1574
+ setOptimisticMethodFee,
1575
+ handlePaymentTab,
1576
+ handlePaymentElementChange,
1577
+
1578
+ // Amount sync + recovery (SDK-wrapped)
1579
+ syncPaymentAmount,
1580
+ refreshPaymentIfTerminal,
1581
+
1582
+ // Buy click (deferred-intent flow)
1583
+ optimisticTotal,
1584
+ optimisticTotalCents,
1585
+ buildPrepareCheckoutPayload,
1586
+ performBuyClick,
1587
+ placeOrder,
1588
+
1589
+ // Misc
1590
+ summaryCart,
1591
+ }
1592
+ }