create-brainerce-store 1.76.0 → 1.77.0

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