create-brainerce-store 1.76.0 → 1.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/messages/en.json +6 -1
- package/messages/he.json +6 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1272 -1179
|
@@ -1,1179 +1,1272 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
4
|
-
import { useSearchParams } from 'next/navigation';
|
|
5
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
|
-
import { Link } from '@/core/lib/navigation';
|
|
7
|
-
import type {
|
|
8
|
-
Checkout,
|
|
9
|
-
ShippingRate,
|
|
10
|
-
SetShippingAddressDto,
|
|
11
|
-
ShippingDestinations,
|
|
12
|
-
PickupLocation,
|
|
13
|
-
CheckoutBumpsResponse,
|
|
14
|
-
CheckoutCustomFieldDefinition,
|
|
15
|
-
} from 'brainerce';
|
|
16
|
-
import { formatPrice } from 'brainerce';
|
|
17
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
18
|
-
import { useStoreInfo, useCart, useAuth, useRegion } from '@/core/providers/store-provider';
|
|
19
|
-
import { useCurrency } from '@/core/lib/use-currency';
|
|
20
|
-
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
21
|
-
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
22
|
-
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
23
|
-
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
24
|
-
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
25
|
-
import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
|
|
26
|
-
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
27
|
-
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
28
|
-
import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
29
|
-
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
30
|
-
import { GiftCardInput } from '@/ui/cart/gift-card-input';
|
|
31
|
-
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
32
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
33
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
-
import { cn } from '@/core/lib/utils';
|
|
35
|
-
import { isValidCheckoutId } from '@/core/lib/safe-redirect';
|
|
36
|
-
import { trackBeginCheckout } from '@/core/lib/tracking';
|
|
37
|
-
|
|
38
|
-
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
39
|
-
|
|
40
|
-
function CheckoutContent() {
|
|
41
|
-
const searchParams = useSearchParams();
|
|
42
|
-
const { storeInfo } = useStoreInfo();
|
|
43
|
-
const { cart, refreshCart } = useCart();
|
|
44
|
-
const { isLoggedIn } = useAuth();
|
|
45
|
-
// ⛔ THE call the region feature exists for. Product reads alone and the
|
|
46
|
-
// shopper is SHOWN one price and CHARGED another: the checkout records the
|
|
47
|
-
// region for reporting and for scoping which payment providers are offered.
|
|
48
|
-
// `undefined` on a store with no regions, and the field is then omitted.
|
|
49
|
-
const { regionId } = useRegion();
|
|
50
|
-
const currency = useCurrency();
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const [
|
|
57
|
-
const [
|
|
58
|
-
const [
|
|
59
|
-
const [
|
|
60
|
-
const [
|
|
61
|
-
const [
|
|
62
|
-
const [
|
|
63
|
-
const [
|
|
64
|
-
const [
|
|
65
|
-
const [
|
|
66
|
-
const [
|
|
67
|
-
const [
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const [
|
|
77
|
-
const [
|
|
78
|
-
const [
|
|
79
|
-
const [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// the
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
const
|
|
148
|
-
const
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const updated = await
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
{t('
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
<div className="
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
|
+
import { Link } from '@/core/lib/navigation';
|
|
7
|
+
import type {
|
|
8
|
+
Checkout,
|
|
9
|
+
ShippingRate,
|
|
10
|
+
SetShippingAddressDto,
|
|
11
|
+
ShippingDestinations,
|
|
12
|
+
PickupLocation,
|
|
13
|
+
CheckoutBumpsResponse,
|
|
14
|
+
CheckoutCustomFieldDefinition,
|
|
15
|
+
} from 'brainerce';
|
|
16
|
+
import { formatPrice } from 'brainerce';
|
|
17
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
18
|
+
import { useStoreInfo, useCart, useAuth, useRegion } from '@/core/providers/store-provider';
|
|
19
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
20
|
+
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
21
|
+
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
22
|
+
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
23
|
+
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
24
|
+
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
25
|
+
import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
|
|
26
|
+
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
27
|
+
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
28
|
+
import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
29
|
+
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
30
|
+
import { GiftCardInput } from '@/ui/cart/gift-card-input';
|
|
31
|
+
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
32
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
33
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
+
import { cn } from '@/core/lib/utils';
|
|
35
|
+
import { isValidCheckoutId } from '@/core/lib/safe-redirect';
|
|
36
|
+
import { trackBeginCheckout } from '@/core/lib/tracking';
|
|
37
|
+
|
|
38
|
+
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
39
|
+
|
|
40
|
+
function CheckoutContent() {
|
|
41
|
+
const searchParams = useSearchParams();
|
|
42
|
+
const { storeInfo } = useStoreInfo();
|
|
43
|
+
const { cart, refreshCart } = useCart();
|
|
44
|
+
const { isLoggedIn } = useAuth();
|
|
45
|
+
// ⛔ THE call the region feature exists for. Product reads alone and the
|
|
46
|
+
// shopper is SHOWN one price and CHARGED another: the checkout records the
|
|
47
|
+
// region for reporting and for scoping which payment providers are offered.
|
|
48
|
+
// `undefined` on a store with no regions, and the field is then omitted.
|
|
49
|
+
const { regionId } = useRegion();
|
|
50
|
+
const currency = useCurrency();
|
|
51
|
+
const router = useRouter();
|
|
52
|
+
const t = useTranslations('checkout');
|
|
53
|
+
const tc = useTranslations('common');
|
|
54
|
+
const tr = useTranslations('reservation');
|
|
55
|
+
|
|
56
|
+
const [step, setStep] = useState<CheckoutStep>('address');
|
|
57
|
+
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
58
|
+
const [placing, setPlacing] = useState(false);
|
|
59
|
+
const [placeError, setPlaceError] = useState<string | null>(null);
|
|
60
|
+
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
61
|
+
const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
|
|
62
|
+
const [loading, setLoading] = useState(false);
|
|
63
|
+
const [initializing, setInitializing] = useState(true);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
66
|
+
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
67
|
+
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
68
|
+
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
69
|
+
const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
|
|
70
|
+
const [prefillCustomer, setPrefillCustomer] = useState<{
|
|
71
|
+
email: string;
|
|
72
|
+
firstName?: string;
|
|
73
|
+
lastName?: string;
|
|
74
|
+
phone?: string;
|
|
75
|
+
} | null>(null);
|
|
76
|
+
const [hasSavedAddress, setHasSavedAddress] = useState(false);
|
|
77
|
+
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
78
|
+
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
79
|
+
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
80
|
+
const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
|
|
81
|
+
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
82
|
+
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
83
|
+
|
|
84
|
+
// ---- Reservation expiry blocks payment ----
|
|
85
|
+
//
|
|
86
|
+
// When the reservation window runs out the held stock goes back on sale, so
|
|
87
|
+
// the amount and the line items on this page may no longer be deliverable.
|
|
88
|
+
// Payment is blocked until the shopper revisits the cart. Both pieces of
|
|
89
|
+
// state key on the reservation's `expiresAt` rather than a bare boolean, so
|
|
90
|
+
// the block survives a remount of the countdown and the ref stops the cart
|
|
91
|
+
// refresh from looping. This page does not refetch the checkout, so the
|
|
92
|
+
// block stands for the rest of the session once it closes.
|
|
93
|
+
const reservationExpiresAt = checkout?.reservation?.expiresAt ?? null;
|
|
94
|
+
const handledExpiryRef = useRef<string | null>(null);
|
|
95
|
+
const [expiredWindow, setExpiredWindow] = useState<string | null>(null);
|
|
96
|
+
const reservationExpired = expiredWindow !== null && expiredWindow === reservationExpiresAt;
|
|
97
|
+
|
|
98
|
+
const handleReservationExpired = useCallback(() => {
|
|
99
|
+
if (!reservationExpiresAt) return;
|
|
100
|
+
setExpiredWindow(reservationExpiresAt);
|
|
101
|
+
if (handledExpiryRef.current === reservationExpiresAt) return;
|
|
102
|
+
handledExpiryRef.current = reservationExpiresAt;
|
|
103
|
+
// Keep the header badge honest: the server may have dropped lines that
|
|
104
|
+
// are no longer purchasable.
|
|
105
|
+
void refreshCart();
|
|
106
|
+
}, [reservationExpiresAt, refreshCart]);
|
|
107
|
+
|
|
108
|
+
// `begin_checkout` — fired once the cart is loaded, guarded by a ref so a
|
|
109
|
+
// re-render (or the shopper returning from a canceled payment) doesn't
|
|
110
|
+
// report a second checkout start for the same cart.
|
|
111
|
+
const beganCheckoutForCartRef = useRef<string | null>(null);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!cart?.id || !cart.items?.length) return;
|
|
114
|
+
if (beganCheckoutForCartRef.current === cart.id) return;
|
|
115
|
+
beganCheckoutForCartRef.current = cart.id;
|
|
116
|
+
trackBeginCheckout(cart, currency);
|
|
117
|
+
}, [cart, currency]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether gift cards already cover the whole order.
|
|
121
|
+
*
|
|
122
|
+
* Read from `providerAmountDue`, which the SERVER computes — never from a
|
|
123
|
+
* subtraction done here. The total is deliberately unchanged by a gift card,
|
|
124
|
+
* so any figure derived in the browser would have to re-implement the tender
|
|
125
|
+
* arithmetic and could disagree with what the backend will actually charge.
|
|
126
|
+
*/
|
|
127
|
+
const isFullyCovered =
|
|
128
|
+
!!checkout && parseFloat(checkout.providerAmountDue ?? checkout.total) <= 0;
|
|
129
|
+
|
|
130
|
+
const handlePlaceFreeOrder = async () => {
|
|
131
|
+
if (!checkout || placing) return;
|
|
132
|
+
try {
|
|
133
|
+
setPlacing(true);
|
|
134
|
+
setPlaceError(null);
|
|
135
|
+
const { orderId } = await getClient().completeCheckout(checkout.id);
|
|
136
|
+
router.push(`/order-confirmation?orderId=${encodeURIComponent(orderId)}`);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// Said out loud. A silent failure here leaves someone staring at a button
|
|
139
|
+
// that did nothing, holding a card they believe they have already spent.
|
|
140
|
+
setPlaceError(err instanceof Error ? err.message : t('placeFailed'));
|
|
141
|
+
setPlacing(false);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Check for returning from canceled payment
|
|
146
|
+
const canceled = searchParams.get('canceled') === 'true';
|
|
147
|
+
const checkoutIdParam = searchParams.get('checkout_id');
|
|
148
|
+
const existingCheckoutId = isValidCheckoutId(checkoutIdParam) ? checkoutIdParam : null;
|
|
149
|
+
|
|
150
|
+
// ---- Partial checkout ----
|
|
151
|
+
//
|
|
152
|
+
// The cart page hands the ticked lines over as `?items=id1,id2` and omits
|
|
153
|
+
// the param entirely for a full cart. Read as a raw string here and resolved
|
|
154
|
+
// against the live cart inside initCheckout: it is URL input, so it is only
|
|
155
|
+
// ever used after filtering against ids the cart actually has, and it is
|
|
156
|
+
// read once at init rather than added to the effect's deps — a change must
|
|
157
|
+
// not silently create a SECOND checkout for the same cart.
|
|
158
|
+
const selectedItemsParam = searchParams.get('items');
|
|
159
|
+
|
|
160
|
+
// Pre-fill address and customer data from profile when logged in
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!isLoggedIn) return;
|
|
163
|
+
getClient()
|
|
164
|
+
.getCheckoutPrefillData()
|
|
165
|
+
.then((data) => {
|
|
166
|
+
if (data.customer) setPrefillCustomer(data.customer);
|
|
167
|
+
if (data.shippingAddress) {
|
|
168
|
+
setPrefillAddress(data.shippingAddress);
|
|
169
|
+
setHasSavedAddress(true);
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
.catch(() => {});
|
|
173
|
+
}, [isLoggedIn]);
|
|
174
|
+
|
|
175
|
+
// Initialize or resume checkout (only once)
|
|
176
|
+
const checkoutInitRef = useRef(false);
|
|
177
|
+
const cartIdRef = useRef<string | null>(null);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
// Only init once, or if cart ID actually changed (e.g. cart was replaced)
|
|
181
|
+
if (!cart?.id) return;
|
|
182
|
+
if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
|
|
183
|
+
checkoutInitRef.current = true;
|
|
184
|
+
cartIdRef.current = cart.id;
|
|
185
|
+
|
|
186
|
+
const initCheckout = async () => {
|
|
187
|
+
try {
|
|
188
|
+
setInitializing(true);
|
|
189
|
+
setError(null);
|
|
190
|
+
const client = getClient();
|
|
191
|
+
|
|
192
|
+
// Fetch shipping destinations and pickup locations in parallel
|
|
193
|
+
client
|
|
194
|
+
.getShippingDestinations()
|
|
195
|
+
.then(setDestinations)
|
|
196
|
+
.catch(() => {});
|
|
197
|
+
|
|
198
|
+
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
199
|
+
setPickupLocations(locations);
|
|
200
|
+
|
|
201
|
+
// If returning with existing checkout ID, resume it
|
|
202
|
+
if (existingCheckoutId) {
|
|
203
|
+
const existing = await client.getCheckout(existingCheckoutId);
|
|
204
|
+
setCheckout(existing);
|
|
205
|
+
|
|
206
|
+
// Preload custom field definitions and any existing values so the
|
|
207
|
+
// step indicator and "change options" affordance work on resume.
|
|
208
|
+
client
|
|
209
|
+
.getCheckoutCustomFields(existing.id)
|
|
210
|
+
.then((fields) => {
|
|
211
|
+
setCustomFields(fields);
|
|
212
|
+
const existingValues = (
|
|
213
|
+
existing as unknown as {
|
|
214
|
+
customFieldValues?: Record<string, unknown> | null;
|
|
215
|
+
}
|
|
216
|
+
).customFieldValues;
|
|
217
|
+
if (existingValues) setCustomFieldValues(existingValues);
|
|
218
|
+
})
|
|
219
|
+
.catch(() => {
|
|
220
|
+
setCustomFields([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Determine step based on checkout state
|
|
224
|
+
const allDigital = existing.lineItems.every(
|
|
225
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
226
|
+
);
|
|
227
|
+
setIsAllDigital(allDigital);
|
|
228
|
+
if (allDigital) {
|
|
229
|
+
// Digital products: show contact info step if email not set, else payment
|
|
230
|
+
setStep(existing.email ? 'payment' : 'address');
|
|
231
|
+
} else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
232
|
+
setDeliveryType('pickup');
|
|
233
|
+
setStep('payment');
|
|
234
|
+
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
235
|
+
setStep('payment');
|
|
236
|
+
} else if (existing.shippingAddress) {
|
|
237
|
+
// Fetch shipping rates
|
|
238
|
+
const rates = await client.getShippingRates(existing.id);
|
|
239
|
+
setShippingRates(rates);
|
|
240
|
+
setStep('shipping');
|
|
241
|
+
} else if (locations.length > 0) {
|
|
242
|
+
setStep('method');
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Create new checkout — cart is always server-side now.
|
|
248
|
+
//
|
|
249
|
+
// `selectedItemIds` scopes the checkout to a subset of the cart: after
|
|
250
|
+
// payment those lines are removed and the rest stay in the still-ACTIVE
|
|
251
|
+
// cart. Sent ONLY when the shopper picked a strict, non-empty subset —
|
|
252
|
+
// an empty result (a stale link, or ids from another cart) and a
|
|
253
|
+
// full-cart selection both fall through to the normal whole-cart call,
|
|
254
|
+
// because omitting the field is what "check out everything" means.
|
|
255
|
+
const cartItemIds = new Set(cart.items.map((item) => item.id));
|
|
256
|
+
const requestedItemIds = (selectedItemsParam ?? '')
|
|
257
|
+
.split(',')
|
|
258
|
+
.map((id) => id.trim())
|
|
259
|
+
.filter((id) => cartItemIds.has(id));
|
|
260
|
+
const isStrictSubset =
|
|
261
|
+
requestedItemIds.length > 0 && requestedItemIds.length < cartItemIds.size;
|
|
262
|
+
|
|
263
|
+
const newCheckout = await client.createCheckout({
|
|
264
|
+
cartId: cart.id,
|
|
265
|
+
...(isStrictSubset ? { selectedItemIds: requestedItemIds } : {}),
|
|
266
|
+
...(regionId ? { regionId } : {}),
|
|
267
|
+
});
|
|
268
|
+
setCheckout(newCheckout);
|
|
269
|
+
|
|
270
|
+
// Put the id in the URL so a RELOAD resumes instead of starting over.
|
|
271
|
+
//
|
|
272
|
+
// The resume path above already reads `?checkout_id=`, but until now only
|
|
273
|
+
// a payment provider's return URL ever set it. A shopper who refreshed
|
|
274
|
+
// lost their address, their shipping choice, and — visibly — any gift
|
|
275
|
+
// card they had applied, because a fresh checkout cannot see holds that
|
|
276
|
+
// belong to the abandoned one. The money was never lost, but it looked
|
|
277
|
+
// lost, and re-entering the same code was correctly refused because the
|
|
278
|
+
// old hold still reserved it.
|
|
279
|
+
//
|
|
280
|
+
// `replaceState`, not router.replace: this is the same page and the same
|
|
281
|
+
// checkout, so it must not push history, re-render, or move the scroll
|
|
282
|
+
// position out from under someone mid-form.
|
|
283
|
+
if (typeof window !== 'undefined') {
|
|
284
|
+
const url = new URL(window.location.href);
|
|
285
|
+
url.searchParams.set('checkout_id', newCheckout.id);
|
|
286
|
+
window.history.replaceState(window.history.state, '', url);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If all items are downloadable, skip shipping — show contact info step
|
|
290
|
+
const allDigital = newCheckout.lineItems.every(
|
|
291
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
292
|
+
);
|
|
293
|
+
setIsAllDigital(allDigital);
|
|
294
|
+
if (allDigital) {
|
|
295
|
+
setStep('address');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// If pickup locations exist, start with delivery method selection
|
|
300
|
+
if (locations.length > 0) {
|
|
301
|
+
setStep('method');
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
305
|
+
setError(message);
|
|
306
|
+
} finally {
|
|
307
|
+
setInitializing(false);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
initCheckout();
|
|
312
|
+
// `regionId` is a dependency because a checkout is created ONCE per cart:
|
|
313
|
+
// if the shopper switches region on the cart page and lands here, the
|
|
314
|
+
// effect has to re-run or the checkout is stamped with the region they
|
|
315
|
+
// just left.
|
|
316
|
+
}, [cart?.id, existingCheckoutId, regionId]);
|
|
317
|
+
|
|
318
|
+
// Load order bumps when checkout is available
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
|
|
321
|
+
setOrderBumps(null);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const client = getClient();
|
|
325
|
+
client
|
|
326
|
+
.getCheckoutBumps(checkout.id)
|
|
327
|
+
.then((data) => {
|
|
328
|
+
setOrderBumps(data);
|
|
329
|
+
// Detect already-added bumps from cart
|
|
330
|
+
if (cart?.items) {
|
|
331
|
+
const existingBumpIds = new Set<string>();
|
|
332
|
+
for (const item of cart.items) {
|
|
333
|
+
const meta = item.metadata as Record<string, unknown> | undefined;
|
|
334
|
+
if (meta?.isOrderBump && meta?.orderBumpId) {
|
|
335
|
+
existingBumpIds.add(meta.orderBumpId as string);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
setAddedBumpIds(existingBumpIds);
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {});
|
|
342
|
+
}, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
|
|
343
|
+
|
|
344
|
+
// Handle bump toggle
|
|
345
|
+
async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
|
|
346
|
+
if (!cart?.id || bumpLoading) return;
|
|
347
|
+
try {
|
|
348
|
+
setBumpLoading(bumpId);
|
|
349
|
+
const client = getClient();
|
|
350
|
+
if (add) {
|
|
351
|
+
await client.addOrderBump(cart.id, bumpId, variantId);
|
|
352
|
+
setAddedBumpIds((prev) => new Set([...prev, bumpId]));
|
|
353
|
+
} else {
|
|
354
|
+
await client.removeOrderBump(cart.id, bumpId);
|
|
355
|
+
setAddedBumpIds((prev) => {
|
|
356
|
+
const next = new Set(prev);
|
|
357
|
+
next.delete(bumpId);
|
|
358
|
+
return next;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
await refreshCart();
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error('Failed to toggle order bump:', err);
|
|
364
|
+
} finally {
|
|
365
|
+
setBumpLoading(null);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Handle shipping address submission
|
|
370
|
+
async function handleAddressSubmit(
|
|
371
|
+
address: SetShippingAddressDto,
|
|
372
|
+
consent: { acceptsMarketing: boolean; saveDetails: boolean }
|
|
373
|
+
) {
|
|
374
|
+
if (!checkout) return;
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
setLoading(true);
|
|
378
|
+
setError(null);
|
|
379
|
+
const client = getClient();
|
|
380
|
+
|
|
381
|
+
if (isAllDigital) {
|
|
382
|
+
// Digital products: set customer info only, skip shipping
|
|
383
|
+
const updated = await client.setCheckoutCustomer(checkout.id, {
|
|
384
|
+
email: address.email,
|
|
385
|
+
firstName: address.firstName,
|
|
386
|
+
lastName: address.lastName,
|
|
387
|
+
phone: address.phone,
|
|
388
|
+
acceptsMarketing: consent.acceptsMarketing,
|
|
389
|
+
notes: address.notes,
|
|
390
|
+
});
|
|
391
|
+
setCheckout(updated);
|
|
392
|
+
setStep('payment');
|
|
393
|
+
} else {
|
|
394
|
+
const response = await client.setShippingAddress(checkout.id, address);
|
|
395
|
+
setCheckout(response.checkout);
|
|
396
|
+
setShippingRates(response.rates);
|
|
397
|
+
setStep('shipping');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Update marketing preference for logged-in users
|
|
401
|
+
if (isLoggedIn) {
|
|
402
|
+
try {
|
|
403
|
+
await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
|
|
404
|
+
} catch {
|
|
405
|
+
// non-critical
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Save address to profile if checkbox was checked and no existing saved address
|
|
410
|
+
if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
|
|
411
|
+
try {
|
|
412
|
+
await client.addMyAddress({
|
|
413
|
+
firstName: address.firstName,
|
|
414
|
+
lastName: address.lastName,
|
|
415
|
+
line1: address.line1,
|
|
416
|
+
line2: address.line2,
|
|
417
|
+
city: address.city,
|
|
418
|
+
region: address.region,
|
|
419
|
+
postalCode: address.postalCode,
|
|
420
|
+
country: address.country,
|
|
421
|
+
phone: address.phone,
|
|
422
|
+
isDefault: true,
|
|
423
|
+
});
|
|
424
|
+
} catch {
|
|
425
|
+
// non-critical
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
430
|
+
setError(message);
|
|
431
|
+
} finally {
|
|
432
|
+
setLoading(false);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// After shipping/pickup is set, decide whether to show the custom-fields step
|
|
437
|
+
// or jump straight to payment. Returns the next step.
|
|
438
|
+
async function loadCustomFieldsOrSkip(checkoutId: string): Promise<CheckoutStep> {
|
|
439
|
+
try {
|
|
440
|
+
const fields = await getClient().getCheckoutCustomFields(checkoutId);
|
|
441
|
+
setCustomFields(fields);
|
|
442
|
+
return fields.length > 0 ? 'custom-fields' : 'payment';
|
|
443
|
+
} catch {
|
|
444
|
+
// If the endpoint isn't available or fails, fall through to payment
|
|
445
|
+
// rather than blocking the customer.
|
|
446
|
+
setCustomFields([]);
|
|
447
|
+
return 'payment';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Handle shipping method selection
|
|
452
|
+
async function handleShippingSelect(rateId: string) {
|
|
453
|
+
if (!checkout) return;
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
setLoading(true);
|
|
457
|
+
setError(null);
|
|
458
|
+
setSelectedRateId(rateId);
|
|
459
|
+
const client = getClient();
|
|
460
|
+
|
|
461
|
+
const updated = await client.selectShippingMethod(checkout.id, rateId);
|
|
462
|
+
setCheckout(updated);
|
|
463
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
464
|
+
} catch (err) {
|
|
465
|
+
const message = err instanceof Error ? err.message : t('failedToSelectShipping');
|
|
466
|
+
setError(message);
|
|
467
|
+
} finally {
|
|
468
|
+
setLoading(false);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Submit custom fields
|
|
473
|
+
async function handleCustomFieldsApply() {
|
|
474
|
+
if (!checkout) return;
|
|
475
|
+
try {
|
|
476
|
+
setCustomFieldsLoading(true);
|
|
477
|
+
setError(null);
|
|
478
|
+
const updated = await getClient().setCheckoutCustomFields(checkout.id, customFieldValues);
|
|
479
|
+
setCheckout(updated);
|
|
480
|
+
setStep('payment');
|
|
481
|
+
} catch (err) {
|
|
482
|
+
const message = err instanceof Error ? err.message : t('customFieldsFailed');
|
|
483
|
+
setError(message);
|
|
484
|
+
} finally {
|
|
485
|
+
setCustomFieldsLoading(false);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Handle delivery method selection
|
|
490
|
+
async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
|
|
491
|
+
if (!checkout) return;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
setLoading(true);
|
|
495
|
+
setError(null);
|
|
496
|
+
setDeliveryType(method);
|
|
497
|
+
const client = getClient();
|
|
498
|
+
|
|
499
|
+
await client.setDeliveryType(checkout.id, method);
|
|
500
|
+
|
|
501
|
+
if (method === 'shipping') {
|
|
502
|
+
setStep('address');
|
|
503
|
+
} else {
|
|
504
|
+
setStep('pickup');
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
const message = err instanceof Error ? err.message : t('failedToSetDeliveryMethod');
|
|
508
|
+
setError(message);
|
|
509
|
+
} finally {
|
|
510
|
+
setLoading(false);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Handle pickup location selection
|
|
515
|
+
async function handlePickupSelect(
|
|
516
|
+
locationId: string,
|
|
517
|
+
customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
|
|
518
|
+
) {
|
|
519
|
+
if (!checkout) return;
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
setLoading(true);
|
|
523
|
+
setError(null);
|
|
524
|
+
const client = getClient();
|
|
525
|
+
|
|
526
|
+
const updated = await client.selectPickupLocation(checkout.id, {
|
|
527
|
+
pickupRateId: locationId,
|
|
528
|
+
email: customerInfo.email,
|
|
529
|
+
firstName: customerInfo.firstName,
|
|
530
|
+
lastName: customerInfo.lastName,
|
|
531
|
+
phone: customerInfo.phone,
|
|
532
|
+
});
|
|
533
|
+
setCheckout(updated);
|
|
534
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
535
|
+
} catch (err) {
|
|
536
|
+
const message = err instanceof Error ? err.message : t('failedToSelectPickup');
|
|
537
|
+
setError(message);
|
|
538
|
+
} finally {
|
|
539
|
+
setLoading(false);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Refresh cart after coupon apply/remove.
|
|
544
|
+
// The checkout totals are updated server-side by applyCheckoutCoupon/removeCheckoutCoupon,
|
|
545
|
+
// so we re-fetch the checkout to get the updated discountAmount and total.
|
|
546
|
+
const handleCouponUpdate = useCallback(async () => {
|
|
547
|
+
await refreshCart();
|
|
548
|
+
if (checkout) {
|
|
549
|
+
try {
|
|
550
|
+
const client = getClient();
|
|
551
|
+
const updated = await client.getCheckout(checkout.id);
|
|
552
|
+
setCheckout(updated);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.error('Failed to refresh checkout after coupon update:', err);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}, [checkout, refreshCart]);
|
|
558
|
+
|
|
559
|
+
if (initializing) {
|
|
560
|
+
return (
|
|
561
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
562
|
+
<LoadingSpinner size="lg" />
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Empty cart
|
|
568
|
+
if (!cart || cart.items.length === 0) {
|
|
569
|
+
return (
|
|
570
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
571
|
+
<h1 className="text-foreground text-2xl font-bold">{t('emptyCart')}</h1>
|
|
572
|
+
<p className="text-muted-foreground mt-2">{t('emptyCartSubtitle')}</p>
|
|
573
|
+
<Link
|
|
574
|
+
href="/products"
|
|
575
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
576
|
+
>
|
|
577
|
+
{tc('shopNow')}
|
|
578
|
+
</Link>
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (error && !checkout) {
|
|
584
|
+
return (
|
|
585
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
586
|
+
<h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
|
|
587
|
+
<p className="text-destructive mt-2">{error}</p>
|
|
588
|
+
<Link
|
|
589
|
+
href="/cart"
|
|
590
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
591
|
+
>
|
|
592
|
+
{t('returnToCart')}
|
|
593
|
+
</Link>
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const customFieldsStep =
|
|
599
|
+
customFields.length > 0
|
|
600
|
+
? [{ key: 'custom-fields' as CheckoutStep, label: t('stepCustomFields') }]
|
|
601
|
+
: [];
|
|
602
|
+
|
|
603
|
+
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
604
|
+
? [
|
|
605
|
+
{ key: 'address', label: t('stepContactInfo') },
|
|
606
|
+
...customFieldsStep,
|
|
607
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
608
|
+
]
|
|
609
|
+
: pickupLocations.length > 0
|
|
610
|
+
? deliveryType === 'pickup'
|
|
611
|
+
? [
|
|
612
|
+
{ key: 'method', label: t('stepMethod') },
|
|
613
|
+
{ key: 'pickup', label: t('stepPickup') },
|
|
614
|
+
...customFieldsStep,
|
|
615
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
616
|
+
]
|
|
617
|
+
: [
|
|
618
|
+
{ key: 'method', label: t('stepMethod') },
|
|
619
|
+
{ key: 'address', label: t('stepAddress') },
|
|
620
|
+
{ key: 'shipping', label: t('stepShipping') },
|
|
621
|
+
...customFieldsStep,
|
|
622
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
623
|
+
]
|
|
624
|
+
: [
|
|
625
|
+
{ key: 'address', label: t('stepAddress') },
|
|
626
|
+
{ key: 'shipping', label: t('stepShipping') },
|
|
627
|
+
...customFieldsStep,
|
|
628
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
629
|
+
];
|
|
630
|
+
|
|
631
|
+
const currentStepIndex = steps.findIndex((s) => s.key === step);
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
635
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">{t('title')}</h1>
|
|
636
|
+
|
|
637
|
+
{/* Canceled payment banner */}
|
|
638
|
+
{canceled && (
|
|
639
|
+
<div className="mb-6 rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
|
|
640
|
+
{t('paymentCanceledBanner')}
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
|
|
644
|
+
{/* Reservation countdown. onExpire is what blocks payment below. */}
|
|
645
|
+
{checkout?.reservation?.hasReservation && (
|
|
646
|
+
<ReservationCountdown
|
|
647
|
+
reservation={checkout.reservation}
|
|
648
|
+
onExpire={handleReservationExpired}
|
|
649
|
+
className="mb-6"
|
|
650
|
+
/>
|
|
651
|
+
)}
|
|
652
|
+
|
|
653
|
+
{/* Expired reservation: payment is off the table until the cart is
|
|
654
|
+
reviewed, so say so on every step, not only on the payment step. */}
|
|
655
|
+
{reservationExpired && (
|
|
656
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
|
|
657
|
+
<p>{tr('expiredCheckout')}</p>
|
|
658
|
+
<Link href="/cart" className="mt-2 inline-flex font-medium underline">
|
|
659
|
+
{tr('backToCart')}
|
|
660
|
+
</Link>
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
{/* Step indicator */}
|
|
665
|
+
<div className="mb-8 flex items-center gap-2">
|
|
666
|
+
{steps.map((s, index) => (
|
|
667
|
+
<div key={s.key} className="flex items-center">
|
|
668
|
+
{index > 0 && (
|
|
669
|
+
<div
|
|
670
|
+
className={cn(
|
|
671
|
+
'mx-2 h-px w-8 sm:w-12',
|
|
672
|
+
index <= currentStepIndex ? 'bg-primary' : 'bg-border'
|
|
673
|
+
)}
|
|
674
|
+
/>
|
|
675
|
+
)}
|
|
676
|
+
<div className="flex items-center gap-2">
|
|
677
|
+
<div
|
|
678
|
+
className={cn(
|
|
679
|
+
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
|
|
680
|
+
index < currentStepIndex
|
|
681
|
+
? 'bg-primary text-primary-foreground'
|
|
682
|
+
: index === currentStepIndex
|
|
683
|
+
? 'bg-primary text-primary-foreground'
|
|
684
|
+
: 'bg-muted text-muted-foreground'
|
|
685
|
+
)}
|
|
686
|
+
>
|
|
687
|
+
{index < currentStepIndex ? (
|
|
688
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
689
|
+
<path
|
|
690
|
+
strokeLinecap="round"
|
|
691
|
+
strokeLinejoin="round"
|
|
692
|
+
strokeWidth={2}
|
|
693
|
+
d="M5 13l4 4L19 7"
|
|
694
|
+
/>
|
|
695
|
+
</svg>
|
|
696
|
+
) : (
|
|
697
|
+
index + 1
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
700
|
+
<span
|
|
701
|
+
className={cn(
|
|
702
|
+
'hidden text-sm sm:block',
|
|
703
|
+
index <= currentStepIndex
|
|
704
|
+
? 'text-foreground font-medium'
|
|
705
|
+
: 'text-muted-foreground'
|
|
706
|
+
)}
|
|
707
|
+
>
|
|
708
|
+
{s.label}
|
|
709
|
+
</span>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
))}
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
{/* Error banner */}
|
|
716
|
+
{error && checkout && (
|
|
717
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
|
|
718
|
+
{error}
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
|
|
722
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
723
|
+
{/* Main content */}
|
|
724
|
+
<div className="lg:col-span-2">
|
|
725
|
+
{/* Delivery Method */}
|
|
726
|
+
{step === 'method' && (
|
|
727
|
+
<div>
|
|
728
|
+
<h2 className="text-foreground mb-4 text-lg font-semibold">{t('deliveryMethod')}</h2>
|
|
729
|
+
<DeliveryMethodStep onSelect={handleDeliveryTypeSelect} />
|
|
730
|
+
</div>
|
|
731
|
+
)}
|
|
732
|
+
|
|
733
|
+
{/* Address */}
|
|
734
|
+
{step === 'address' && (
|
|
735
|
+
<div>
|
|
736
|
+
<div className="mb-4 flex items-center justify-between">
|
|
737
|
+
<h2 className="text-foreground text-lg font-semibold">
|
|
738
|
+
{isAllDigital ? t('contactInfo') : t('shippingAddress')}
|
|
739
|
+
</h2>
|
|
740
|
+
{!isAllDigital && pickupLocations.length > 0 && (
|
|
741
|
+
<button
|
|
742
|
+
type="button"
|
|
743
|
+
onClick={() => setStep('method')}
|
|
744
|
+
className="text-primary text-sm hover:underline"
|
|
745
|
+
>
|
|
746
|
+
{t('changeMethod')}
|
|
747
|
+
</button>
|
|
748
|
+
)}
|
|
749
|
+
</div>
|
|
750
|
+
<CheckoutForm
|
|
751
|
+
onSubmit={handleAddressSubmit}
|
|
752
|
+
loading={loading}
|
|
753
|
+
destinations={isAllDigital ? null : destinations}
|
|
754
|
+
showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
|
|
755
|
+
emailOnly={isAllDigital}
|
|
756
|
+
initialValues={
|
|
757
|
+
checkout?.shippingAddress
|
|
758
|
+
? {
|
|
759
|
+
email: checkout.email || '',
|
|
760
|
+
firstName: checkout.shippingAddress.firstName,
|
|
761
|
+
lastName: checkout.shippingAddress.lastName,
|
|
762
|
+
line1: checkout.shippingAddress.line1,
|
|
763
|
+
line2: checkout.shippingAddress.line2 || '',
|
|
764
|
+
city: checkout.shippingAddress.city,
|
|
765
|
+
region: checkout.shippingAddress.region || '',
|
|
766
|
+
postalCode: checkout.shippingAddress.postalCode,
|
|
767
|
+
country: checkout.shippingAddress.country,
|
|
768
|
+
phone: checkout.shippingAddress.phone || '',
|
|
769
|
+
}
|
|
770
|
+
: prefillAddress
|
|
771
|
+
? {
|
|
772
|
+
email: prefillAddress.email,
|
|
773
|
+
firstName: prefillAddress.firstName,
|
|
774
|
+
lastName: prefillAddress.lastName,
|
|
775
|
+
line1: prefillAddress.line1,
|
|
776
|
+
line2: prefillAddress.line2 || '',
|
|
777
|
+
city: prefillAddress.city,
|
|
778
|
+
region: prefillAddress.region || '',
|
|
779
|
+
postalCode: prefillAddress.postalCode,
|
|
780
|
+
country: prefillAddress.country,
|
|
781
|
+
phone: prefillAddress.phone || '',
|
|
782
|
+
}
|
|
783
|
+
: prefillCustomer
|
|
784
|
+
? {
|
|
785
|
+
email: prefillCustomer.email,
|
|
786
|
+
firstName: prefillCustomer.firstName || '',
|
|
787
|
+
lastName: prefillCustomer.lastName || '',
|
|
788
|
+
phone: prefillCustomer.phone || '',
|
|
789
|
+
}
|
|
790
|
+
: undefined
|
|
791
|
+
}
|
|
792
|
+
/>
|
|
793
|
+
</div>
|
|
794
|
+
)}
|
|
795
|
+
|
|
796
|
+
{/* Step 2: Shipping */}
|
|
797
|
+
{step === 'shipping' && (
|
|
798
|
+
<div>
|
|
799
|
+
<div className="mb-4 flex items-center justify-between">
|
|
800
|
+
<h2 className="text-foreground text-lg font-semibold">{t('shippingMethod')}</h2>
|
|
801
|
+
<button
|
|
802
|
+
type="button"
|
|
803
|
+
onClick={() => setStep('address')}
|
|
804
|
+
className="text-primary text-sm hover:underline"
|
|
805
|
+
>
|
|
806
|
+
{t('editAddress')}
|
|
807
|
+
</button>
|
|
808
|
+
</div>
|
|
809
|
+
|
|
810
|
+
<ShippingStep
|
|
811
|
+
rates={shippingRates}
|
|
812
|
+
selectedRateId={selectedRateId}
|
|
813
|
+
onSelect={handleShippingSelect}
|
|
814
|
+
loading={loading}
|
|
815
|
+
/>
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
|
|
819
|
+
{/* Pickup */}
|
|
820
|
+
{step === 'pickup' && (
|
|
821
|
+
<div>
|
|
822
|
+
<div className="mb-4 flex items-center justify-between">
|
|
823
|
+
<h2 className="text-foreground text-lg font-semibold">{t('pickupLocation')}</h2>
|
|
824
|
+
<button
|
|
825
|
+
type="button"
|
|
826
|
+
onClick={() => setStep('method')}
|
|
827
|
+
className="text-primary text-sm hover:underline"
|
|
828
|
+
>
|
|
829
|
+
{t('changeMethod')}
|
|
830
|
+
</button>
|
|
831
|
+
</div>
|
|
832
|
+
<PickupStep
|
|
833
|
+
locations={pickupLocations}
|
|
834
|
+
onSelect={handlePickupSelect}
|
|
835
|
+
loading={loading}
|
|
836
|
+
initialEmail={checkout?.email || ''}
|
|
837
|
+
/>
|
|
838
|
+
</div>
|
|
839
|
+
)}
|
|
840
|
+
|
|
841
|
+
{/* Custom Fields (optional, between shipping/pickup and payment) */}
|
|
842
|
+
{step === 'custom-fields' && checkout && (
|
|
843
|
+
<div>
|
|
844
|
+
<div className="mb-4 flex items-center justify-between">
|
|
845
|
+
<h2 className="text-foreground text-lg font-semibold">{t('customFieldsTitle')}</h2>
|
|
846
|
+
<button
|
|
847
|
+
type="button"
|
|
848
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
849
|
+
className="text-primary text-sm hover:underline"
|
|
850
|
+
>
|
|
851
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
852
|
+
</button>
|
|
853
|
+
</div>
|
|
854
|
+
<CustomFieldsStep
|
|
855
|
+
fields={customFields}
|
|
856
|
+
values={customFieldValues}
|
|
857
|
+
onChange={(key, value) =>
|
|
858
|
+
setCustomFieldValues((prev) => ({ ...prev, [key]: value }))
|
|
859
|
+
}
|
|
860
|
+
onApply={handleCustomFieldsApply}
|
|
861
|
+
onUploadFile={(file) => getClient().uploadCustomizationFile(file)}
|
|
862
|
+
timezone={storeInfo?.timezone}
|
|
863
|
+
loading={customFieldsLoading}
|
|
864
|
+
/>
|
|
865
|
+
</div>
|
|
866
|
+
)}
|
|
867
|
+
|
|
868
|
+
{/* Payment */}
|
|
869
|
+
{step === 'payment' && checkout && (
|
|
870
|
+
<div>
|
|
871
|
+
<div className="mb-4 flex items-center justify-between">
|
|
872
|
+
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
873
|
+
{customFields.length > 0 ? (
|
|
874
|
+
<button
|
|
875
|
+
type="button"
|
|
876
|
+
onClick={() => setStep('custom-fields')}
|
|
877
|
+
className="text-primary text-sm hover:underline"
|
|
878
|
+
>
|
|
879
|
+
{t('changeOptions')}
|
|
880
|
+
</button>
|
|
881
|
+
) : (
|
|
882
|
+
!isAllDigital && (
|
|
883
|
+
<button
|
|
884
|
+
type="button"
|
|
885
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
886
|
+
className="text-primary text-sm hover:underline"
|
|
887
|
+
>
|
|
888
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
889
|
+
</button>
|
|
890
|
+
)
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
{/* Never mount the payment form on an expired reservation: the
|
|
895
|
+
stock behind these lines is back on sale, so a charge here
|
|
896
|
+
can take money for something that cannot ship. */}
|
|
897
|
+
{reservationExpired ? (
|
|
898
|
+
<div className="border-border rounded-lg border px-4 py-6 text-center">
|
|
899
|
+
<p className="text-foreground text-sm font-medium">{tr('expired')}</p>
|
|
900
|
+
<p className="text-muted-foreground mt-1 text-sm">{tr('expiredCheckout')}</p>
|
|
901
|
+
<Link
|
|
902
|
+
href="/cart"
|
|
903
|
+
className="bg-primary text-primary-foreground mt-4 inline-flex items-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
|
|
904
|
+
>
|
|
905
|
+
{tr('backToCart')}
|
|
906
|
+
</Link>
|
|
907
|
+
</div>
|
|
908
|
+
) : isFullyCovered ? (
|
|
909
|
+
/* Nothing left for a provider to charge.
|
|
910
|
+
Gift cards can cover an order completely, and demanding a
|
|
911
|
+
card for 0.00 is not a formality — most providers reject a
|
|
912
|
+
zero-amount charge outright, so the shopper is stuck holding
|
|
913
|
+
a paid-for order they cannot place. The backend already
|
|
914
|
+
derives this server-side (`isZeroDue`) and completes without
|
|
915
|
+
a payment, so the only thing missing was a way to ask. */
|
|
916
|
+
<div className="border-border rounded-lg border px-4 py-6">
|
|
917
|
+
<p className="text-foreground text-sm font-medium">{t('fullyCovered')}</p>
|
|
918
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
919
|
+
{t('fullyCoveredHint')}
|
|
920
|
+
</p>
|
|
921
|
+
{placeError && (
|
|
922
|
+
<p role="alert" className="text-destructive mt-3 text-sm">
|
|
923
|
+
{placeError}
|
|
924
|
+
</p>
|
|
925
|
+
)}
|
|
926
|
+
<button
|
|
927
|
+
type="button"
|
|
928
|
+
onClick={handlePlaceFreeOrder}
|
|
929
|
+
disabled={placing}
|
|
930
|
+
className="bg-primary text-primary-foreground mt-4 inline-flex items-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
931
|
+
>
|
|
932
|
+
{placing ? t('placing') : t('placeOrder')}
|
|
933
|
+
</button>
|
|
934
|
+
</div>
|
|
935
|
+
) : (
|
|
936
|
+
<PaymentStep checkoutId={checkout.id} />
|
|
937
|
+
)}
|
|
938
|
+
</div>
|
|
939
|
+
)}
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
{/* Order summary sidebar */}
|
|
943
|
+
<div className="lg:col-span-1">
|
|
944
|
+
<div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
|
|
945
|
+
<h3 className="text-foreground mb-4 text-lg font-semibold">{t('orderSummary')}</h3>
|
|
946
|
+
|
|
947
|
+
{/* Line items */}
|
|
948
|
+
{checkout?.lineItems && checkout.lineItems.length > 0 ? (
|
|
949
|
+
<div className="mb-4 space-y-3">
|
|
950
|
+
{checkout.lineItems.map((item) => {
|
|
951
|
+
const imageUrl = item.product.images?.[0]?.url || null;
|
|
952
|
+
const name = item.variant?.name || item.product.name;
|
|
953
|
+
const lineTotal = parseFloat(item.unitPrice) * item.quantity;
|
|
954
|
+
|
|
955
|
+
return (
|
|
956
|
+
<div key={item.id} className="space-y-1">
|
|
957
|
+
<div className="flex gap-3">
|
|
958
|
+
<div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
|
|
959
|
+
{imageUrl ? (
|
|
960
|
+
<Image
|
|
961
|
+
src={imageUrl}
|
|
962
|
+
alt={name}
|
|
963
|
+
fill
|
|
964
|
+
sizes="48px"
|
|
965
|
+
className="object-cover"
|
|
966
|
+
/>
|
|
967
|
+
) : (
|
|
968
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
969
|
+
<svg
|
|
970
|
+
className="h-5 w-5"
|
|
971
|
+
fill="none"
|
|
972
|
+
viewBox="0 0 24 24"
|
|
973
|
+
stroke="currentColor"
|
|
974
|
+
>
|
|
975
|
+
<path
|
|
976
|
+
strokeLinecap="round"
|
|
977
|
+
strokeLinejoin="round"
|
|
978
|
+
strokeWidth={1.5}
|
|
979
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
980
|
+
/>
|
|
981
|
+
</svg>
|
|
982
|
+
</div>
|
|
983
|
+
)}
|
|
984
|
+
</div>
|
|
985
|
+
|
|
986
|
+
<div className="min-w-0 flex-1">
|
|
987
|
+
<p className="text-foreground truncate text-sm">{name}</p>
|
|
988
|
+
<p className="text-muted-foreground text-xs">
|
|
989
|
+
{tc('qty')} {item.quantity}
|
|
990
|
+
</p>
|
|
991
|
+
</div>
|
|
992
|
+
|
|
993
|
+
<span className="text-foreground flex-shrink-0 text-sm font-medium">
|
|
994
|
+
{formatPrice(lineTotal, { currency }) as string}
|
|
995
|
+
</span>
|
|
996
|
+
</div>
|
|
997
|
+
|
|
998
|
+
{/* The buyer's own input on this line — engraving text,
|
|
999
|
+
uploaded photo, picked colour — so it can be checked
|
|
1000
|
+
before paying. `CheckoutLineItem.customizations` is
|
|
1001
|
+
the SDK's resolved label/value/type map, absent on a
|
|
1002
|
+
line with no customization, so a plain order renders
|
|
1003
|
+
exactly as before. */}
|
|
1004
|
+
{item.customizations && (
|
|
1005
|
+
<OrderCustomizations customizations={item.customizations} />
|
|
1006
|
+
)}
|
|
1007
|
+
</div>
|
|
1008
|
+
);
|
|
1009
|
+
})}
|
|
1010
|
+
</div>
|
|
1011
|
+
) : (
|
|
1012
|
+
// Fallback to cart items if checkout line items aren't loaded yet
|
|
1013
|
+
cart && (
|
|
1014
|
+
<div className="mb-4 space-y-2">
|
|
1015
|
+
<p className="text-muted-foreground text-sm">
|
|
1016
|
+
{cart.items.length} {cart.items.length === 1 ? tc('item') : tc('items')}
|
|
1017
|
+
</p>
|
|
1018
|
+
</div>
|
|
1019
|
+
)
|
|
1020
|
+
)}
|
|
1021
|
+
|
|
1022
|
+
{/* Order bumps */}
|
|
1023
|
+
{orderBumps?.bumps && orderBumps.bumps.length > 0 && (
|
|
1024
|
+
<div className="border-border space-y-2 border-t pt-4">
|
|
1025
|
+
<p className="text-foreground text-xs font-semibold uppercase tracking-wide">
|
|
1026
|
+
{t('addToYourOrder')}
|
|
1027
|
+
</p>
|
|
1028
|
+
{orderBumps.bumps.map((bump) => (
|
|
1029
|
+
<OrderBumpCard
|
|
1030
|
+
key={bump.id}
|
|
1031
|
+
bump={bump}
|
|
1032
|
+
isAdded={addedBumpIds.has(bump.id)}
|
|
1033
|
+
onToggle={handleBumpToggle}
|
|
1034
|
+
loading={bumpLoading === bump.id}
|
|
1035
|
+
/>
|
|
1036
|
+
))}
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
|
|
1040
|
+
{/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
|
|
1041
|
+
{cart &&
|
|
1042
|
+
(isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
1043
|
+
<div className="border-border border-t pt-4">
|
|
1044
|
+
<CouponInput
|
|
1045
|
+
cart={cart}
|
|
1046
|
+
checkoutId={checkout?.id}
|
|
1047
|
+
onUpdate={handleCouponUpdate}
|
|
1048
|
+
/>
|
|
1049
|
+
{/* Beside the coupon field, and deliberately NOT inside it. A
|
|
1050
|
+
coupon reduces what the order is worth; a gift card pays
|
|
1051
|
+
for an order still worth the same. They look adjacent
|
|
1052
|
+
because a shopper reaches for them at the same moment, and
|
|
1053
|
+
they behave differently because they are different things. */}
|
|
1054
|
+
{checkout?.id && (
|
|
1055
|
+
<GiftCardInput
|
|
1056
|
+
className="mt-3"
|
|
1057
|
+
checkoutId={checkout.id}
|
|
1058
|
+
tenders={checkout.tenders ?? []}
|
|
1059
|
+
formatAmount={(value) =>
|
|
1060
|
+
formatPrice(parseFloat(value), { currency }) as string
|
|
1061
|
+
}
|
|
1062
|
+
onUpdate={handleCouponUpdate}
|
|
1063
|
+
/>
|
|
1064
|
+
)}
|
|
1065
|
+
</div>
|
|
1066
|
+
)}
|
|
1067
|
+
|
|
1068
|
+
{/* Totals */}
|
|
1069
|
+
{checkout &&
|
|
1070
|
+
(() => {
|
|
1071
|
+
// When the store prices include tax (VAT-style), the on-row
|
|
1072
|
+
// `checkout.subtotal` is GROSS — it already contains the tax.
|
|
1073
|
+
// Show the net (tax-excluded) value here, then a separate VAT
|
|
1074
|
+
// line below, so the customer sees the breakdown the merchant
|
|
1075
|
+
// asked for. Falls back to the raw subtotal when no breakdown
|
|
1076
|
+
// is available yet (e.g. shipping address not entered).
|
|
1077
|
+
const isInclusive = checkout.taxBreakdown?.pricesIncludeTax === true;
|
|
1078
|
+
// `taxBreakdown.subtotal` is the NET of every taxed line, and
|
|
1079
|
+
// shipping is one of those lines — so it already contains the
|
|
1080
|
+
// shipping net. Rendering it and then adding `shippingAmount`
|
|
1081
|
+
// below counts shipping twice: a real checkout read
|
|
1082
|
+
// 54.06 + 9.99 + 7.93 = 71.98 beside a 61.99 total, three rows
|
|
1083
|
+
// that visibly refuse to add up next to the figure being charged.
|
|
1084
|
+
//
|
|
1085
|
+
// Subtract the shipping net back out so the column reconciles.
|
|
1086
|
+
// The shipping row keeps showing the GROSS amount, which is what
|
|
1087
|
+
// a shopper recognises from the rate they picked.
|
|
1088
|
+
// `shippingNet` comes from the server, never from
|
|
1089
|
+
// `shippingAmount` minus a guess: subtracting the GROSS is only
|
|
1090
|
+
// correct when shipping is untaxed, and a store that taxes
|
|
1091
|
+
// shipping would silently show a short subtotal.
|
|
1092
|
+
const shippingNet =
|
|
1093
|
+
typeof checkout.taxBreakdown?.shippingNet === 'number'
|
|
1094
|
+
? checkout.taxBreakdown.shippingNet
|
|
1095
|
+
: parseFloat(checkout.shippingAmount) || 0;
|
|
1096
|
+
const displayedSubtotal =
|
|
1097
|
+
isInclusive && typeof checkout.taxBreakdown?.subtotal === 'number'
|
|
1098
|
+
? checkout.taxBreakdown.subtotal - shippingNet
|
|
1099
|
+
: parseFloat(checkout.subtotal);
|
|
1100
|
+
const subtotalLabel = isInclusive ? tc('subtotalExclTax') : tc('subtotal');
|
|
1101
|
+
return (
|
|
1102
|
+
<div className="border-border space-y-2 border-t pt-4 text-sm">
|
|
1103
|
+
<div className="flex items-center justify-between">
|
|
1104
|
+
<span className="text-muted-foreground">{subtotalLabel}</span>
|
|
1105
|
+
<span className="text-foreground">
|
|
1106
|
+
{formatPrice(displayedSubtotal, { currency }) as string}
|
|
1107
|
+
</span>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
{(() => {
|
|
1111
|
+
const totalDiscount = parseFloat(checkout.discountAmount);
|
|
1112
|
+
const ruleAmt = parseFloat(checkout.ruleDiscountAmount || '0');
|
|
1113
|
+
const couponAmt = totalDiscount - ruleAmt;
|
|
1114
|
+
const rules = cart?.appliedDiscounts;
|
|
1115
|
+
if (totalDiscount <= 0) return null;
|
|
1116
|
+
return (
|
|
1117
|
+
<>
|
|
1118
|
+
{rules && rules.length > 0
|
|
1119
|
+
? rules.map((rule) => (
|
|
1120
|
+
<div
|
|
1121
|
+
key={rule.ruleId}
|
|
1122
|
+
className="flex items-center justify-between"
|
|
1123
|
+
>
|
|
1124
|
+
<span className="text-muted-foreground">{rule.ruleName}</span>
|
|
1125
|
+
<span className="text-destructive">
|
|
1126
|
+
-
|
|
1127
|
+
{
|
|
1128
|
+
formatPrice(parseFloat(rule.discountAmount), {
|
|
1129
|
+
currency,
|
|
1130
|
+
}) as string
|
|
1131
|
+
}
|
|
1132
|
+
</span>
|
|
1133
|
+
</div>
|
|
1134
|
+
))
|
|
1135
|
+
: ruleAmt > 0 && (
|
|
1136
|
+
<div className="flex items-center justify-between">
|
|
1137
|
+
<span className="text-muted-foreground">
|
|
1138
|
+
{tc('generalDiscount')}
|
|
1139
|
+
</span>
|
|
1140
|
+
<span className="text-destructive">
|
|
1141
|
+
-{formatPrice(ruleAmt, { currency }) as string}
|
|
1142
|
+
</span>
|
|
1143
|
+
</div>
|
|
1144
|
+
)}
|
|
1145
|
+
{checkout.couponCode && couponAmt > 0 && (
|
|
1146
|
+
<div className="flex items-center justify-between">
|
|
1147
|
+
<span className="text-muted-foreground">
|
|
1148
|
+
{tc('couponDiscount')} ({checkout.couponCode})
|
|
1149
|
+
</span>
|
|
1150
|
+
<span className="text-destructive">
|
|
1151
|
+
-{formatPrice(couponAmt, { currency }) as string}
|
|
1152
|
+
</span>
|
|
1153
|
+
</div>
|
|
1154
|
+
)}
|
|
1155
|
+
{!checkout.couponCode &&
|
|
1156
|
+
ruleAmt <= 0 &&
|
|
1157
|
+
(!rules || rules.length === 0) && (
|
|
1158
|
+
<div className="flex items-center justify-between">
|
|
1159
|
+
<span className="text-muted-foreground">{tc('discount')}</span>
|
|
1160
|
+
<span className="text-destructive">
|
|
1161
|
+
-{formatPrice(totalDiscount, { currency }) as string}
|
|
1162
|
+
</span>
|
|
1163
|
+
</div>
|
|
1164
|
+
)}
|
|
1165
|
+
</>
|
|
1166
|
+
);
|
|
1167
|
+
})()}
|
|
1168
|
+
|
|
1169
|
+
{(parseFloat(checkout.shippingAmount) > 0 ||
|
|
1170
|
+
checkout.deliveryType === 'pickup') && (
|
|
1171
|
+
<div className="flex items-center justify-between">
|
|
1172
|
+
<span className="text-muted-foreground">
|
|
1173
|
+
{checkout.deliveryType === 'pickup' ? tc('pickup') : tc('shipping')}
|
|
1174
|
+
</span>
|
|
1175
|
+
<span className="text-foreground">
|
|
1176
|
+
{parseFloat(checkout.shippingAmount) === 0
|
|
1177
|
+
? tc('free')
|
|
1178
|
+
: (formatPrice(parseFloat(checkout.shippingAmount), {
|
|
1179
|
+
currency,
|
|
1180
|
+
}) as string)}
|
|
1181
|
+
</span>
|
|
1182
|
+
</div>
|
|
1183
|
+
)}
|
|
1184
|
+
|
|
1185
|
+
<TaxDisplay
|
|
1186
|
+
addressSet={!!checkout.shippingAddress}
|
|
1187
|
+
taxAmount={checkout.taxAmount}
|
|
1188
|
+
taxBreakdown={checkout.taxBreakdown}
|
|
1189
|
+
/>
|
|
1190
|
+
|
|
1191
|
+
{/* Custom field surcharges (one line per applied surcharge) */}
|
|
1192
|
+
{checkout.appliedSurcharges && checkout.appliedSurcharges.length > 0 && (
|
|
1193
|
+
<>
|
|
1194
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
1195
|
+
<div key={s.key} className="flex items-center justify-between">
|
|
1196
|
+
<span className="text-muted-foreground">{s.name}</span>
|
|
1197
|
+
<span className="text-foreground">
|
|
1198
|
+
{formatPrice(Number(s.amount), { currency }) as string}
|
|
1199
|
+
</span>
|
|
1200
|
+
</div>
|
|
1201
|
+
))}
|
|
1202
|
+
</>
|
|
1203
|
+
)}
|
|
1204
|
+
|
|
1205
|
+
<div className="border-border mt-2 border-t pt-2">
|
|
1206
|
+
<div className="flex items-center justify-between">
|
|
1207
|
+
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
1208
|
+
<span className="text-foreground text-base font-semibold">
|
|
1209
|
+
{formatPrice(parseFloat(checkout.total), { currency }) as string}
|
|
1210
|
+
</span>
|
|
1211
|
+
</div>
|
|
1212
|
+
|
|
1213
|
+
{/* Gift cards sit BELOW the total, not among the discounts
|
|
1214
|
+
above it. The total is what the order is worth and does
|
|
1215
|
+
not move; what changes is only what the card leaves for
|
|
1216
|
+
the payment provider to charge. Putting this in the
|
|
1217
|
+
discount block would understate the taxable base to the
|
|
1218
|
+
shopper and on the receipt. */}
|
|
1219
|
+
{checkout.tenders && checkout.tenders.length > 0 && (
|
|
1220
|
+
<>
|
|
1221
|
+
{checkout.tenders.map((tender) => (
|
|
1222
|
+
<div
|
|
1223
|
+
key={tender.tenderId}
|
|
1224
|
+
className="mt-2 flex items-center justify-between text-sm"
|
|
1225
|
+
>
|
|
1226
|
+
<span className="text-muted-foreground">{tc('giftCard')}</span>
|
|
1227
|
+
<span className="text-primary">
|
|
1228
|
+
-
|
|
1229
|
+
{
|
|
1230
|
+
formatPrice(parseFloat(tender.amountApplied), {
|
|
1231
|
+
currency,
|
|
1232
|
+
}) as string
|
|
1233
|
+
}
|
|
1234
|
+
</span>
|
|
1235
|
+
</div>
|
|
1236
|
+
))}
|
|
1237
|
+
<div className="border-border mt-2 flex items-center justify-between border-t pt-2">
|
|
1238
|
+
<span className="text-foreground font-semibold">{tc('amountDue')}</span>
|
|
1239
|
+
<span className="text-foreground text-base font-semibold">
|
|
1240
|
+
{
|
|
1241
|
+
formatPrice(parseFloat(checkout.providerAmountDue ?? checkout.total), {
|
|
1242
|
+
currency,
|
|
1243
|
+
}) as string
|
|
1244
|
+
}
|
|
1245
|
+
</span>
|
|
1246
|
+
</div>
|
|
1247
|
+
</>
|
|
1248
|
+
)}
|
|
1249
|
+
</div>
|
|
1250
|
+
</div>
|
|
1251
|
+
);
|
|
1252
|
+
})()}
|
|
1253
|
+
</div>
|
|
1254
|
+
</div>
|
|
1255
|
+
</div>
|
|
1256
|
+
</div>
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export default function CheckoutPage() {
|
|
1261
|
+
return (
|
|
1262
|
+
<Suspense
|
|
1263
|
+
fallback={
|
|
1264
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
1265
|
+
<LoadingSpinner size="lg" />
|
|
1266
|
+
</div>
|
|
1267
|
+
}
|
|
1268
|
+
>
|
|
1269
|
+
<CheckoutContent />
|
|
1270
|
+
</Suspense>
|
|
1271
|
+
);
|
|
1272
|
+
}
|