@cartbase/storefront 0.1.0

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