@cartbase/storefront 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/package.json +3 -1
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +367 -168
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/index.ts +73 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -0
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/checkout/use-checkout-orchestration.ts +1604 -1592
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/cookie-names.ts +14 -0
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
- package/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
|
@@ -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
|
|
45
|
-
import {
|
|
46
|
-
import
|
|
47
|
-
import {
|
|
48
|
-
import
|
|
49
|
-
import
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
* `
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* - `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
export type
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
*
|
|
179
|
-
* `
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"
|
|
188
|
-
"shipping_address.
|
|
189
|
-
"shipping_address.
|
|
190
|
-
"shipping_address.
|
|
191
|
-
"shipping_address.
|
|
192
|
-
"shipping_address.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
"shipping_address.
|
|
345
|
-
|
|
346
|
-
"shipping_address.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
"shipping_address.
|
|
350
|
-
"shipping_address.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
cart?.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
[
|
|
423
|
-
)
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
() =>
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
"shipping_address.
|
|
449
|
-
"shipping_address.
|
|
450
|
-
"shipping_address.
|
|
451
|
-
"shipping_address.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
snapshotSeededRef.current
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const [
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
)
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
() =>
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
(
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
)
|
|
883
|
-
)
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
window.
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
})
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
cart?.payment_method_fee_total
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
carrierMetadata.
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
})
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
prep.client_secret
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
+
}
|