@cartbase/storefront 0.17.0 → 0.18.1

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