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