create-brainerce-store 1.26.0 → 1.27.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,860 +1,972 @@
1
- 'use client';
2
-
3
- import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
- import { useSearchParams } from 'next/navigation';
5
- import Image from 'next/image';
6
- import Link from 'next/link';
7
- import type {
8
- Checkout,
9
- ShippingRate,
10
- SetShippingAddressDto,
11
- ShippingDestinations,
12
- PickupLocation,
13
- CheckoutBumpsResponse,
14
- } from 'brainerce';
15
- import { formatPrice } from 'brainerce';
16
- import { getClient } from '@/lib/brainerce';
17
- import { useStoreInfo, useCart, useAuth } from '@/providers/store-provider';
18
- import { CheckoutForm } from '@/components/checkout/checkout-form';
19
- import { ShippingStep } from '@/components/checkout/shipping-step';
20
- import { PaymentStep } from '@/components/checkout/payment-step';
21
- import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
22
- import { PickupStep } from '@/components/checkout/pickup-step';
23
- import { TaxDisplay } from '@/components/checkout/tax-display';
24
- import { OrderBumpCard } from '@/components/checkout/order-bump-card';
25
- import { CouponInput } from '@/components/cart/coupon-input';
26
- import { ReservationCountdown } from '@/components/cart/reservation-countdown';
27
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
28
- import { useTranslations } from '@/lib/translations';
29
- import { cn } from '@/lib/utils';
30
-
31
- type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
32
-
33
- function CheckoutContent() {
34
- const searchParams = useSearchParams();
35
- const { storeInfo } = useStoreInfo();
36
- const { cart, refreshCart } = useCart();
37
- const { isLoggedIn } = useAuth();
38
- const currency = storeInfo?.currency || 'USD';
39
- const t = useTranslations('checkout');
40
- const tc = useTranslations('common');
41
-
42
- const [step, setStep] = useState<CheckoutStep>('address');
43
- const [checkout, setCheckout] = useState<Checkout | null>(null);
44
- const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
45
- const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
46
- const [loading, setLoading] = useState(false);
47
- const [initializing, setInitializing] = useState(true);
48
- const [error, setError] = useState<string | null>(null);
49
- const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
50
- const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
51
- const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
52
- const [isAllDigital, setIsAllDigital] = useState(false);
53
- const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
54
- const [prefillCustomer, setPrefillCustomer] = useState<{
55
- email: string;
56
- firstName?: string;
57
- lastName?: string;
58
- phone?: string;
59
- } | null>(null);
60
- const [hasSavedAddress, setHasSavedAddress] = useState(false);
61
- const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
62
- const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
63
- const [bumpLoading, setBumpLoading] = useState<string | null>(null);
64
-
65
- // Check for returning from canceled payment
66
- const canceled = searchParams.get('canceled') === 'true';
67
- const existingCheckoutId = searchParams.get('checkout_id');
68
-
69
- // Pre-fill address and customer data from profile when logged in
70
- useEffect(() => {
71
- if (!isLoggedIn) return;
72
- getClient()
73
- .getCheckoutPrefillData()
74
- .then((data) => {
75
- if (data.customer) setPrefillCustomer(data.customer);
76
- if (data.shippingAddress) {
77
- setPrefillAddress(data.shippingAddress);
78
- setHasSavedAddress(true);
79
- }
80
- })
81
- .catch(() => {});
82
- }, [isLoggedIn]);
83
-
84
- // Initialize or resume checkout (only once)
85
- const checkoutInitRef = useRef(false);
86
- const cartIdRef = useRef<string | null>(null);
87
-
88
- useEffect(() => {
89
- // Only init once, or if cart ID actually changed (e.g. cart was replaced)
90
- if (!cart?.id) return;
91
- if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
92
- checkoutInitRef.current = true;
93
- cartIdRef.current = cart.id;
94
-
95
- const initCheckout = async () => {
96
- try {
97
- setInitializing(true);
98
- setError(null);
99
- const client = getClient();
100
-
101
- // Fetch shipping destinations and pickup locations in parallel
102
- client
103
- .getShippingDestinations()
104
- .then(setDestinations)
105
- .catch(() => {});
106
-
107
- const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
108
- setPickupLocations(locations);
109
-
110
- // If returning with existing checkout ID, resume it
111
- if (existingCheckoutId) {
112
- const existing = await client.getCheckout(existingCheckoutId);
113
- setCheckout(existing);
114
-
115
- // Determine step based on checkout state
116
- const allDigital = existing.lineItems.every(
117
- (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
118
- );
119
- setIsAllDigital(allDigital);
120
- if (allDigital) {
121
- // Digital products: show contact info step if email not set, else payment
122
- setStep(existing.email ? 'payment' : 'address');
123
- } else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
124
- setDeliveryType('pickup');
125
- setStep('payment');
126
- } else if (existing.shippingAddress && existing.shippingRateId) {
127
- setStep('payment');
128
- } else if (existing.shippingAddress) {
129
- // Fetch shipping rates
130
- const rates = await client.getShippingRates(existing.id);
131
- setShippingRates(rates);
132
- setStep('shipping');
133
- } else if (locations.length > 0) {
134
- setStep('method');
135
- }
136
- return;
137
- }
138
-
139
- // Create new checkout cart is always server-side now
140
- const newCheckout = await client.createCheckout({ cartId: cart.id });
141
- setCheckout(newCheckout);
142
-
143
- // If all items are downloadable, skip shipping show contact info step
144
- const allDigital = newCheckout.lineItems.every(
145
- (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
146
- );
147
- setIsAllDigital(allDigital);
148
- if (allDigital) {
149
- setStep('address');
150
- return;
151
- }
152
-
153
- // If pickup locations exist, start with delivery method selection
154
- if (locations.length > 0) {
155
- setStep('method');
156
- }
157
- } catch (err) {
158
- const message = err instanceof Error ? err.message : t('failedToInitCheckout');
159
- setError(message);
160
- } finally {
161
- setInitializing(false);
162
- }
163
- };
164
-
165
- initCheckout();
166
- }, [cart?.id, existingCheckoutId]);
167
-
168
- // Load order bumps when checkout is available
169
- useEffect(() => {
170
- if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
171
- setOrderBumps(null);
172
- return;
173
- }
174
- const client = getClient();
175
- client
176
- .getCheckoutBumps(checkout.id)
177
- .then((data) => {
178
- setOrderBumps(data);
179
- // Detect already-added bumps from cart
180
- if (cart?.items) {
181
- const existingBumpIds = new Set<string>();
182
- for (const item of cart.items) {
183
- const meta = item.metadata as Record<string, unknown> | undefined;
184
- if (meta?.isOrderBump && meta?.orderBumpId) {
185
- existingBumpIds.add(meta.orderBumpId as string);
186
- }
187
- }
188
- setAddedBumpIds(existingBumpIds);
189
- }
190
- })
191
- .catch(() => {});
192
- }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
193
-
194
- // Handle bump toggle
195
- async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
196
- if (!cart?.id || bumpLoading) return;
197
- try {
198
- setBumpLoading(bumpId);
199
- const client = getClient();
200
- if (add) {
201
- await client.addOrderBump(cart.id, bumpId, variantId);
202
- setAddedBumpIds((prev) => new Set([...prev, bumpId]));
203
- } else {
204
- await client.removeOrderBump(cart.id, bumpId);
205
- setAddedBumpIds((prev) => {
206
- const next = new Set(prev);
207
- next.delete(bumpId);
208
- return next;
209
- });
210
- }
211
- await refreshCart();
212
- } catch (err) {
213
- console.error('Failed to toggle order bump:', err);
214
- } finally {
215
- setBumpLoading(null);
216
- }
217
- }
218
-
219
- // Handle shipping address submission
220
- async function handleAddressSubmit(
221
- address: SetShippingAddressDto,
222
- consent: { acceptsMarketing: boolean; saveDetails: boolean }
223
- ) {
224
- if (!checkout) return;
225
-
226
- try {
227
- setLoading(true);
228
- setError(null);
229
- const client = getClient();
230
-
231
- if (isAllDigital) {
232
- // Digital products: set customer info only, skip shipping
233
- const updated = await client.setCheckoutCustomer(checkout.id, {
234
- email: address.email,
235
- firstName: address.firstName,
236
- lastName: address.lastName,
237
- phone: address.phone,
238
- acceptsMarketing: consent.acceptsMarketing,
239
- });
240
- setCheckout(updated);
241
- setStep('payment');
242
- } else {
243
- const response = await client.setShippingAddress(checkout.id, address);
244
- setCheckout(response.checkout);
245
- setShippingRates(response.rates);
246
- setStep('shipping');
247
- }
248
-
249
- // Update marketing preference for logged-in users
250
- if (isLoggedIn) {
251
- try {
252
- await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
253
- } catch {
254
- // non-critical
255
- }
256
- }
257
-
258
- // Save address to profile if checkbox was checked and no existing saved address
259
- if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
260
- try {
261
- await client.addMyAddress({
262
- firstName: address.firstName,
263
- lastName: address.lastName,
264
- line1: address.line1,
265
- line2: address.line2,
266
- city: address.city,
267
- region: address.region,
268
- postalCode: address.postalCode,
269
- country: address.country,
270
- phone: address.phone,
271
- isDefault: true,
272
- });
273
- } catch {
274
- // non-critical
275
- }
276
- }
277
- } catch (err) {
278
- const message = err instanceof Error ? err.message : t('failedToSaveAddress');
279
- setError(message);
280
- } finally {
281
- setLoading(false);
282
- }
283
- }
284
-
285
- // Handle shipping method selection
286
- async function handleShippingSelect(rateId: string) {
287
- if (!checkout) return;
288
-
289
- try {
290
- setLoading(true);
291
- setError(null);
292
- setSelectedRateId(rateId);
293
- const client = getClient();
294
-
295
- const updated = await client.selectShippingMethod(checkout.id, rateId);
296
- setCheckout(updated);
297
- setStep('payment');
298
- } catch (err) {
299
- const message = err instanceof Error ? err.message : t('failedToSelectShipping');
300
- setError(message);
301
- } finally {
302
- setLoading(false);
303
- }
304
- }
305
-
306
- // Handle delivery method selection
307
- async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
308
- if (!checkout) return;
309
-
310
- try {
311
- setLoading(true);
312
- setError(null);
313
- setDeliveryType(method);
314
- const client = getClient();
315
-
316
- await client.setDeliveryType(checkout.id, method);
317
-
318
- if (method === 'shipping') {
319
- setStep('address');
320
- } else {
321
- setStep('pickup');
322
- }
323
- } catch (err) {
324
- const message = err instanceof Error ? err.message : t('failedToSetDeliveryMethod');
325
- setError(message);
326
- } finally {
327
- setLoading(false);
328
- }
329
- }
330
-
331
- // Handle pickup location selection
332
- async function handlePickupSelect(
333
- locationId: string,
334
- customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
335
- ) {
336
- if (!checkout) return;
337
-
338
- try {
339
- setLoading(true);
340
- setError(null);
341
- const client = getClient();
342
-
343
- const updated = await client.selectPickupLocation(checkout.id, {
344
- pickupRateId: locationId,
345
- email: customerInfo.email,
346
- firstName: customerInfo.firstName,
347
- lastName: customerInfo.lastName,
348
- phone: customerInfo.phone,
349
- });
350
- setCheckout(updated);
351
- setStep('payment');
352
- } catch (err) {
353
- const message = err instanceof Error ? err.message : t('failedToSelectPickup');
354
- setError(message);
355
- } finally {
356
- setLoading(false);
357
- }
358
- }
359
-
360
- // Refresh cart and checkout after coupon apply/remove
361
- const handleCouponUpdate = useCallback(async () => {
362
- await refreshCart();
363
- if (checkout) {
364
- try {
365
- const client = getClient();
366
- const updated = await client.getCheckout(checkout.id);
367
- setCheckout(updated);
368
- } catch (err) {
369
- console.error('Failed to refresh checkout after coupon update:', err);
370
- }
371
- }
372
- }, [checkout, refreshCart]);
373
-
374
- if (initializing) {
375
- return (
376
- <div className="flex min-h-[60vh] items-center justify-center">
377
- <LoadingSpinner size="lg" />
378
- </div>
379
- );
380
- }
381
-
382
- // Empty cart
383
- if (!cart || cart.items.length === 0) {
384
- return (
385
- <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
386
- <h1 className="text-foreground text-2xl font-bold">{t('emptyCart')}</h1>
387
- <p className="text-muted-foreground mt-2">{t('emptyCartSubtitle')}</p>
388
- <Link
389
- href="/products"
390
- className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
391
- >
392
- {tc('shopNow')}
393
- </Link>
394
- </div>
395
- );
396
- }
397
-
398
- if (error && !checkout) {
399
- return (
400
- <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
401
- <h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
402
- <p className="text-destructive mt-2">{error}</p>
403
- <Link
404
- href="/cart"
405
- className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
406
- >
407
- {t('returnToCart')}
408
- </Link>
409
- </div>
410
- );
411
- }
412
-
413
- const steps: { key: CheckoutStep; label: string }[] = isAllDigital
414
- ? [
415
- { key: 'address', label: t('stepContactInfo') },
416
- { key: 'payment', label: t('stepPayment') },
417
- ]
418
- : pickupLocations.length > 0
419
- ? deliveryType === 'pickup'
420
- ? [
421
- { key: 'method', label: t('stepMethod') },
422
- { key: 'pickup', label: t('stepPickup') },
423
- { key: 'payment', label: t('stepPayment') },
424
- ]
425
- : [
426
- { key: 'method', label: t('stepMethod') },
427
- { key: 'address', label: t('stepAddress') },
428
- { key: 'shipping', label: t('stepShipping') },
429
- { key: 'payment', label: t('stepPayment') },
430
- ]
431
- : [
432
- { key: 'address', label: t('stepAddress') },
433
- { key: 'shipping', label: t('stepShipping') },
434
- { key: 'payment', label: t('stepPayment') },
435
- ];
436
-
437
- const currentStepIndex = steps.findIndex((s) => s.key === step);
438
-
439
- return (
440
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
441
- <h1 className="text-foreground mb-6 text-2xl font-bold">{t('title')}</h1>
442
-
443
- {/* Canceled payment banner */}
444
- {canceled && (
445
- <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">
446
- {t('paymentCanceledBanner')}
447
- </div>
448
- )}
449
-
450
- {/* Reservation countdown */}
451
- {checkout?.reservation?.hasReservation && (
452
- <ReservationCountdown reservation={checkout.reservation} className="mb-6" />
453
- )}
454
-
455
- {/* Step indicator */}
456
- <div className="mb-8 flex items-center gap-2">
457
- {steps.map((s, index) => (
458
- <div key={s.key} className="flex items-center">
459
- {index > 0 && (
460
- <div
461
- className={cn(
462
- 'mx-2 h-px w-8 sm:w-12',
463
- index <= currentStepIndex ? 'bg-primary' : 'bg-border'
464
- )}
465
- />
466
- )}
467
- <div className="flex items-center gap-2">
468
- <div
469
- className={cn(
470
- 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
471
- index < currentStepIndex
472
- ? 'bg-primary text-primary-foreground'
473
- : index === currentStepIndex
474
- ? 'bg-primary text-primary-foreground'
475
- : 'bg-muted text-muted-foreground'
476
- )}
477
- >
478
- {index < currentStepIndex ? (
479
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
480
- <path
481
- strokeLinecap="round"
482
- strokeLinejoin="round"
483
- strokeWidth={2}
484
- d="M5 13l4 4L19 7"
485
- />
486
- </svg>
487
- ) : (
488
- index + 1
489
- )}
490
- </div>
491
- <span
492
- className={cn(
493
- 'hidden text-sm sm:block',
494
- index <= currentStepIndex
495
- ? 'text-foreground font-medium'
496
- : 'text-muted-foreground'
497
- )}
498
- >
499
- {s.label}
500
- </span>
501
- </div>
502
- </div>
503
- ))}
504
- </div>
505
-
506
- {/* Error banner */}
507
- {error && checkout && (
508
- <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
509
- {error}
510
- </div>
511
- )}
512
-
513
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
514
- {/* Main content */}
515
- <div className="lg:col-span-2">
516
- {/* Delivery Method */}
517
- {step === 'method' && (
518
- <div>
519
- <h2 className="text-foreground mb-4 text-lg font-semibold">{t('deliveryMethod')}</h2>
520
- <DeliveryMethodStep onSelect={handleDeliveryTypeSelect} />
521
- </div>
522
- )}
523
-
524
- {/* Address */}
525
- {step === 'address' && (
526
- <div>
527
- <div className="mb-4 flex items-center justify-between">
528
- <h2 className="text-foreground text-lg font-semibold">
529
- {isAllDigital ? t('contactInfo') : t('shippingAddress')}
530
- </h2>
531
- {!isAllDigital && pickupLocations.length > 0 && (
532
- <button
533
- type="button"
534
- onClick={() => setStep('method')}
535
- className="text-primary text-sm hover:underline"
536
- >
537
- {t('changeMethod')}
538
- </button>
539
- )}
540
- </div>
541
- <CheckoutForm
542
- onSubmit={handleAddressSubmit}
543
- loading={loading}
544
- destinations={isAllDigital ? null : destinations}
545
- showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
546
- emailOnly={isAllDigital}
547
- initialValues={
548
- checkout?.shippingAddress
549
- ? {
550
- email: checkout.email || '',
551
- firstName: checkout.shippingAddress.firstName,
552
- lastName: checkout.shippingAddress.lastName,
553
- line1: checkout.shippingAddress.line1,
554
- line2: checkout.shippingAddress.line2 || '',
555
- city: checkout.shippingAddress.city,
556
- region: checkout.shippingAddress.region || '',
557
- postalCode: checkout.shippingAddress.postalCode,
558
- country: checkout.shippingAddress.country,
559
- phone: checkout.shippingAddress.phone || '',
560
- }
561
- : prefillAddress
562
- ? {
563
- email: prefillAddress.email,
564
- firstName: prefillAddress.firstName,
565
- lastName: prefillAddress.lastName,
566
- line1: prefillAddress.line1,
567
- line2: prefillAddress.line2 || '',
568
- city: prefillAddress.city,
569
- region: prefillAddress.region || '',
570
- postalCode: prefillAddress.postalCode,
571
- country: prefillAddress.country,
572
- phone: prefillAddress.phone || '',
573
- }
574
- : prefillCustomer
575
- ? {
576
- email: prefillCustomer.email,
577
- firstName: prefillCustomer.firstName || '',
578
- lastName: prefillCustomer.lastName || '',
579
- phone: prefillCustomer.phone || '',
580
- }
581
- : undefined
582
- }
583
- />
584
- </div>
585
- )}
586
-
587
- {/* Step 2: Shipping */}
588
- {step === 'shipping' && (
589
- <div>
590
- <div className="mb-4 flex items-center justify-between">
591
- <h2 className="text-foreground text-lg font-semibold">{t('shippingMethod')}</h2>
592
- <button
593
- type="button"
594
- onClick={() => setStep('address')}
595
- className="text-primary text-sm hover:underline"
596
- >
597
- {t('editAddress')}
598
- </button>
599
- </div>
600
-
601
- <ShippingStep
602
- rates={shippingRates}
603
- selectedRateId={selectedRateId}
604
- onSelect={handleShippingSelect}
605
- loading={loading}
606
- />
607
- </div>
608
- )}
609
-
610
- {/* Pickup */}
611
- {step === 'pickup' && (
612
- <div>
613
- <div className="mb-4 flex items-center justify-between">
614
- <h2 className="text-foreground text-lg font-semibold">{t('pickupLocation')}</h2>
615
- <button
616
- type="button"
617
- onClick={() => setStep('method')}
618
- className="text-primary text-sm hover:underline"
619
- >
620
- {t('changeMethod')}
621
- </button>
622
- </div>
623
- <PickupStep
624
- locations={pickupLocations}
625
- onSelect={handlePickupSelect}
626
- loading={loading}
627
- initialEmail={checkout?.email || ''}
628
- />
629
- </div>
630
- )}
631
-
632
- {/* Payment */}
633
- {step === 'payment' && checkout && (
634
- <div>
635
- <div className="mb-4 flex items-center justify-between">
636
- <h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
637
- {!isAllDigital && (
638
- <button
639
- type="button"
640
- onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
641
- className="text-primary text-sm hover:underline"
642
- >
643
- {deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
644
- </button>
645
- )}
646
- </div>
647
-
648
- <PaymentStep checkoutId={checkout.id} />
649
- </div>
650
- )}
651
- </div>
652
-
653
- {/* Order summary sidebar */}
654
- <div className="lg:col-span-1">
655
- <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
656
- <h3 className="text-foreground mb-4 text-lg font-semibold">{t('orderSummary')}</h3>
657
-
658
- {/* Line items */}
659
- {checkout?.lineItems && checkout.lineItems.length > 0 ? (
660
- <div className="mb-4 space-y-3">
661
- {checkout.lineItems.map((item) => {
662
- const imageUrl = item.product.images?.[0]?.url || null;
663
- const name = item.variant?.name || item.product.name;
664
- const lineTotal = parseFloat(item.unitPrice) * item.quantity;
665
-
666
- return (
667
- <div key={item.id} className="flex gap-3">
668
- <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
669
- {imageUrl ? (
670
- <Image
671
- src={imageUrl}
672
- alt={name}
673
- fill
674
- sizes="48px"
675
- className="object-cover"
676
- />
677
- ) : (
678
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
679
- <svg
680
- className="h-5 w-5"
681
- fill="none"
682
- viewBox="0 0 24 24"
683
- stroke="currentColor"
684
- >
685
- <path
686
- strokeLinecap="round"
687
- strokeLinejoin="round"
688
- strokeWidth={1.5}
689
- 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"
690
- />
691
- </svg>
692
- </div>
693
- )}
694
- </div>
695
-
696
- <div className="min-w-0 flex-1">
697
- <p className="text-foreground truncate text-sm">{name}</p>
698
- <p className="text-muted-foreground text-xs">
699
- {tc('qty')} {item.quantity}
700
- </p>
701
- </div>
702
-
703
- <span className="text-foreground flex-shrink-0 text-sm font-medium">
704
- {formatPrice(lineTotal, { currency }) as string}
705
- </span>
706
- </div>
707
- );
708
- })}
709
- </div>
710
- ) : (
711
- // Fallback to cart items if checkout line items aren't loaded yet
712
- cart && (
713
- <div className="mb-4 space-y-2">
714
- <p className="text-muted-foreground text-sm">
715
- {cart.items.length} {cart.items.length === 1 ? tc('item') : tc('items')}
716
- </p>
717
- </div>
718
- )
719
- )}
720
-
721
- {/* Order bumps */}
722
- {orderBumps?.bumps && orderBumps.bumps.length > 0 && (
723
- <div className="border-border space-y-2 border-t pt-4">
724
- <p className="text-foreground text-xs font-semibold uppercase tracking-wide">
725
- {t('addToYourOrder')}
726
- </p>
727
- {orderBumps.bumps.map((bump) => (
728
- <OrderBumpCard
729
- key={bump.id}
730
- bump={bump}
731
- isAdded={addedBumpIds.has(bump.id)}
732
- onToggle={handleBumpToggle}
733
- loading={bumpLoading === bump.id}
734
- />
735
- ))}
736
- </div>
737
- )}
738
-
739
- {/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
740
- {cart &&
741
- (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
742
- <div className="border-border border-t pt-4">
743
- <CouponInput cart={cart} onUpdate={handleCouponUpdate} />
744
- </div>
745
- )}
746
-
747
- {/* Totals */}
748
- {checkout && (
749
- <div className="border-border space-y-2 border-t pt-4 text-sm">
750
- <div className="flex items-center justify-between">
751
- <span className="text-muted-foreground">{tc('subtotal')}</span>
752
- <span className="text-foreground">
753
- {formatPrice(parseFloat(checkout.subtotal), { currency }) as string}
754
- </span>
755
- </div>
756
-
757
- {(() => {
758
- const totalDiscount = parseFloat(checkout.discountAmount);
759
- const ruleAmt = parseFloat(checkout.ruleDiscountAmount || '0');
760
- const couponAmt = totalDiscount - ruleAmt;
761
- const rules = cart?.appliedDiscounts;
762
- if (totalDiscount <= 0) return null;
763
- return (
764
- <>
765
- {rules && rules.length > 0
766
- ? rules.map((rule) => (
767
- <div key={rule.ruleId} className="flex items-center justify-between">
768
- <span className="text-muted-foreground">{rule.ruleName}</span>
769
- <span className="text-destructive">
770
- -
771
- {
772
- formatPrice(parseFloat(rule.discountAmount), {
773
- currency,
774
- }) as string
775
- }
776
- </span>
777
- </div>
778
- ))
779
- : ruleAmt > 0 && (
780
- <div className="flex items-center justify-between">
781
- <span className="text-muted-foreground">{tc('generalDiscount')}</span>
782
- <span className="text-destructive">
783
- -{formatPrice(ruleAmt, { currency }) as string}
784
- </span>
785
- </div>
786
- )}
787
- {checkout.couponCode && couponAmt > 0 && (
788
- <div className="flex items-center justify-between">
789
- <span className="text-muted-foreground">
790
- {tc('couponDiscount')} ({checkout.couponCode})
791
- </span>
792
- <span className="text-destructive">
793
- -{formatPrice(couponAmt, { currency }) as string}
794
- </span>
795
- </div>
796
- )}
797
- {!checkout.couponCode && ruleAmt <= 0 && (!rules || rules.length === 0) && (
798
- <div className="flex items-center justify-between">
799
- <span className="text-muted-foreground">{tc('discount')}</span>
800
- <span className="text-destructive">
801
- -{formatPrice(totalDiscount, { currency }) as string}
802
- </span>
803
- </div>
804
- )}
805
- </>
806
- );
807
- })()}
808
-
809
- {(parseFloat(checkout.shippingAmount) > 0 ||
810
- checkout.deliveryType === 'pickup') && (
811
- <div className="flex items-center justify-between">
812
- <span className="text-muted-foreground">
813
- {checkout.deliveryType === 'pickup' ? tc('pickup') : tc('shipping')}
814
- </span>
815
- <span className="text-foreground">
816
- {parseFloat(checkout.shippingAmount) === 0
817
- ? tc('free')
818
- : (formatPrice(parseFloat(checkout.shippingAmount), {
819
- currency,
820
- }) as string)}
821
- </span>
822
- </div>
823
- )}
824
-
825
- <TaxDisplay
826
- addressSet={!!checkout.shippingAddress}
827
- taxAmount={checkout.taxAmount}
828
- taxBreakdown={checkout.taxBreakdown}
829
- />
830
-
831
- <div className="border-border mt-2 border-t pt-2">
832
- <div className="flex items-center justify-between">
833
- <span className="text-foreground font-semibold">{tc('total')}</span>
834
- <span className="text-foreground text-base font-semibold">
835
- {formatPrice(parseFloat(checkout.total), { currency }) as string}
836
- </span>
837
- </div>
838
- </div>
839
- </div>
840
- )}
841
- </div>
842
- </div>
843
- </div>
844
- </div>
845
- );
846
- }
847
-
848
- export default function CheckoutPage() {
849
- return (
850
- <Suspense
851
- fallback={
852
- <div className="flex min-h-[60vh] items-center justify-center">
853
- <LoadingSpinner size="lg" />
854
- </div>
855
- }
856
- >
857
- <CheckoutContent />
858
- </Suspense>
859
- );
860
- }
1
+ 'use client';
2
+
3
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import Image from 'next/image';
6
+ import Link from 'next/link';
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 '@/lib/brainerce';
18
+ import { useStoreInfo, useCart, useAuth } from '@/providers/store-provider';
19
+ import { CheckoutForm } from '@/components/checkout/checkout-form';
20
+ import { ShippingStep } from '@/components/checkout/shipping-step';
21
+ import { PaymentStep } from '@/components/checkout/payment-step';
22
+ import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
23
+ import { PickupStep } from '@/components/checkout/pickup-step';
24
+ import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
25
+ import { TaxDisplay } from '@/components/checkout/tax-display';
26
+ import { OrderBumpCard } from '@/components/checkout/order-bump-card';
27
+ import { CouponInput } from '@/components/cart/coupon-input';
28
+ import { ReservationCountdown } from '@/components/cart/reservation-countdown';
29
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
30
+ import { useTranslations } from '@/lib/translations';
31
+ import { cn } from '@/lib/utils';
32
+
33
+ type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
34
+
35
+ function CheckoutContent() {
36
+ const searchParams = useSearchParams();
37
+ const { storeInfo } = useStoreInfo();
38
+ const { cart, refreshCart } = useCart();
39
+ const { isLoggedIn } = useAuth();
40
+ const currency = storeInfo?.currency || 'USD';
41
+ const t = useTranslations('checkout');
42
+ const tc = useTranslations('common');
43
+
44
+ const [step, setStep] = useState<CheckoutStep>('address');
45
+ const [checkout, setCheckout] = useState<Checkout | null>(null);
46
+ const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
47
+ const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
48
+ const [loading, setLoading] = useState(false);
49
+ const [initializing, setInitializing] = useState(true);
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
52
+ const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
53
+ const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
54
+ const [isAllDigital, setIsAllDigital] = useState(false);
55
+ const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
56
+ const [prefillCustomer, setPrefillCustomer] = useState<{
57
+ email: string;
58
+ firstName?: string;
59
+ lastName?: string;
60
+ phone?: string;
61
+ } | null>(null);
62
+ const [hasSavedAddress, setHasSavedAddress] = useState(false);
63
+ const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
64
+ const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
65
+ const [bumpLoading, setBumpLoading] = useState<string | null>(null);
66
+ const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
67
+ const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
68
+ const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
69
+
70
+ // Check for returning from canceled payment
71
+ const canceled = searchParams.get('canceled') === 'true';
72
+ const existingCheckoutId = searchParams.get('checkout_id');
73
+
74
+ // Pre-fill address and customer data from profile when logged in
75
+ useEffect(() => {
76
+ if (!isLoggedIn) return;
77
+ getClient()
78
+ .getCheckoutPrefillData()
79
+ .then((data) => {
80
+ if (data.customer) setPrefillCustomer(data.customer);
81
+ if (data.shippingAddress) {
82
+ setPrefillAddress(data.shippingAddress);
83
+ setHasSavedAddress(true);
84
+ }
85
+ })
86
+ .catch(() => {});
87
+ }, [isLoggedIn]);
88
+
89
+ // Initialize or resume checkout (only once)
90
+ const checkoutInitRef = useRef(false);
91
+ const cartIdRef = useRef<string | null>(null);
92
+
93
+ useEffect(() => {
94
+ // Only init once, or if cart ID actually changed (e.g. cart was replaced)
95
+ if (!cart?.id) return;
96
+ if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
97
+ checkoutInitRef.current = true;
98
+ cartIdRef.current = cart.id;
99
+
100
+ const initCheckout = async () => {
101
+ try {
102
+ setInitializing(true);
103
+ setError(null);
104
+ const client = getClient();
105
+
106
+ // Fetch shipping destinations and pickup locations in parallel
107
+ client
108
+ .getShippingDestinations()
109
+ .then(setDestinations)
110
+ .catch(() => {});
111
+
112
+ const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
113
+ setPickupLocations(locations);
114
+
115
+ // If returning with existing checkout ID, resume it
116
+ if (existingCheckoutId) {
117
+ const existing = await client.getCheckout(existingCheckoutId);
118
+ setCheckout(existing);
119
+
120
+ // Preload custom field definitions and any existing values so the
121
+ // step indicator and "change options" affordance work on resume.
122
+ client
123
+ .getCheckoutCustomFields(existing.id)
124
+ .then((fields) => {
125
+ setCustomFields(fields);
126
+ const existingValues = (
127
+ existing as unknown as {
128
+ customFieldValues?: Record<string, unknown> | null;
129
+ }
130
+ ).customFieldValues;
131
+ if (existingValues) setCustomFieldValues(existingValues);
132
+ })
133
+ .catch(() => {
134
+ setCustomFields([]);
135
+ });
136
+
137
+ // Determine step based on checkout state
138
+ const allDigital = existing.lineItems.every(
139
+ (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
140
+ );
141
+ setIsAllDigital(allDigital);
142
+ if (allDigital) {
143
+ // Digital products: show contact info step if email not set, else payment
144
+ setStep(existing.email ? 'payment' : 'address');
145
+ } else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
146
+ setDeliveryType('pickup');
147
+ setStep('payment');
148
+ } else if (existing.shippingAddress && existing.shippingRateId) {
149
+ setStep('payment');
150
+ } else if (existing.shippingAddress) {
151
+ // Fetch shipping rates
152
+ const rates = await client.getShippingRates(existing.id);
153
+ setShippingRates(rates);
154
+ setStep('shipping');
155
+ } else if (locations.length > 0) {
156
+ setStep('method');
157
+ }
158
+ return;
159
+ }
160
+
161
+ // Create new checkout — cart is always server-side now
162
+ const newCheckout = await client.createCheckout({ cartId: cart.id });
163
+ setCheckout(newCheckout);
164
+
165
+ // If all items are downloadable, skip shipping — show contact info step
166
+ const allDigital = newCheckout.lineItems.every(
167
+ (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
168
+ );
169
+ setIsAllDigital(allDigital);
170
+ if (allDigital) {
171
+ setStep('address');
172
+ return;
173
+ }
174
+
175
+ // If pickup locations exist, start with delivery method selection
176
+ if (locations.length > 0) {
177
+ setStep('method');
178
+ }
179
+ } catch (err) {
180
+ const message = err instanceof Error ? err.message : t('failedToInitCheckout');
181
+ setError(message);
182
+ } finally {
183
+ setInitializing(false);
184
+ }
185
+ };
186
+
187
+ initCheckout();
188
+ }, [cart?.id, existingCheckoutId]);
189
+
190
+ // Load order bumps when checkout is available
191
+ useEffect(() => {
192
+ if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
193
+ setOrderBumps(null);
194
+ return;
195
+ }
196
+ const client = getClient();
197
+ client
198
+ .getCheckoutBumps(checkout.id)
199
+ .then((data) => {
200
+ setOrderBumps(data);
201
+ // Detect already-added bumps from cart
202
+ if (cart?.items) {
203
+ const existingBumpIds = new Set<string>();
204
+ for (const item of cart.items) {
205
+ const meta = item.metadata as Record<string, unknown> | undefined;
206
+ if (meta?.isOrderBump && meta?.orderBumpId) {
207
+ existingBumpIds.add(meta.orderBumpId as string);
208
+ }
209
+ }
210
+ setAddedBumpIds(existingBumpIds);
211
+ }
212
+ })
213
+ .catch(() => {});
214
+ }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
215
+
216
+ // Handle bump toggle
217
+ async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
218
+ if (!cart?.id || bumpLoading) return;
219
+ try {
220
+ setBumpLoading(bumpId);
221
+ const client = getClient();
222
+ if (add) {
223
+ await client.addOrderBump(cart.id, bumpId, variantId);
224
+ setAddedBumpIds((prev) => new Set([...prev, bumpId]));
225
+ } else {
226
+ await client.removeOrderBump(cart.id, bumpId);
227
+ setAddedBumpIds((prev) => {
228
+ const next = new Set(prev);
229
+ next.delete(bumpId);
230
+ return next;
231
+ });
232
+ }
233
+ await refreshCart();
234
+ } catch (err) {
235
+ console.error('Failed to toggle order bump:', err);
236
+ } finally {
237
+ setBumpLoading(null);
238
+ }
239
+ }
240
+
241
+ // Handle shipping address submission
242
+ async function handleAddressSubmit(
243
+ address: SetShippingAddressDto,
244
+ consent: { acceptsMarketing: boolean; saveDetails: boolean }
245
+ ) {
246
+ if (!checkout) return;
247
+
248
+ try {
249
+ setLoading(true);
250
+ setError(null);
251
+ const client = getClient();
252
+
253
+ if (isAllDigital) {
254
+ // Digital products: set customer info only, skip shipping
255
+ const updated = await client.setCheckoutCustomer(checkout.id, {
256
+ email: address.email,
257
+ firstName: address.firstName,
258
+ lastName: address.lastName,
259
+ phone: address.phone,
260
+ acceptsMarketing: consent.acceptsMarketing,
261
+ });
262
+ setCheckout(updated);
263
+ setStep('payment');
264
+ } else {
265
+ const response = await client.setShippingAddress(checkout.id, address);
266
+ setCheckout(response.checkout);
267
+ setShippingRates(response.rates);
268
+ setStep('shipping');
269
+ }
270
+
271
+ // Update marketing preference for logged-in users
272
+ if (isLoggedIn) {
273
+ try {
274
+ await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
275
+ } catch {
276
+ // non-critical
277
+ }
278
+ }
279
+
280
+ // Save address to profile if checkbox was checked and no existing saved address
281
+ if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
282
+ try {
283
+ await client.addMyAddress({
284
+ firstName: address.firstName,
285
+ lastName: address.lastName,
286
+ line1: address.line1,
287
+ line2: address.line2,
288
+ city: address.city,
289
+ region: address.region,
290
+ postalCode: address.postalCode,
291
+ country: address.country,
292
+ phone: address.phone,
293
+ isDefault: true,
294
+ });
295
+ } catch {
296
+ // non-critical
297
+ }
298
+ }
299
+ } catch (err) {
300
+ const message = err instanceof Error ? err.message : t('failedToSaveAddress');
301
+ setError(message);
302
+ } finally {
303
+ setLoading(false);
304
+ }
305
+ }
306
+
307
+ // After shipping/pickup is set, decide whether to show the custom-fields step
308
+ // or jump straight to payment. Returns the next step.
309
+ async function loadCustomFieldsOrSkip(checkoutId: string): Promise<CheckoutStep> {
310
+ try {
311
+ const fields = await getClient().getCheckoutCustomFields(checkoutId);
312
+ setCustomFields(fields);
313
+ return fields.length > 0 ? 'custom-fields' : 'payment';
314
+ } catch {
315
+ // If the endpoint isn't available or fails, fall through to payment
316
+ // rather than blocking the customer.
317
+ setCustomFields([]);
318
+ return 'payment';
319
+ }
320
+ }
321
+
322
+ // Handle shipping method selection
323
+ async function handleShippingSelect(rateId: string) {
324
+ if (!checkout) return;
325
+
326
+ try {
327
+ setLoading(true);
328
+ setError(null);
329
+ setSelectedRateId(rateId);
330
+ const client = getClient();
331
+
332
+ const updated = await client.selectShippingMethod(checkout.id, rateId);
333
+ setCheckout(updated);
334
+ setStep(await loadCustomFieldsOrSkip(updated.id));
335
+ } catch (err) {
336
+ const message = err instanceof Error ? err.message : t('failedToSelectShipping');
337
+ setError(message);
338
+ } finally {
339
+ setLoading(false);
340
+ }
341
+ }
342
+
343
+ // Submit custom fields
344
+ async function handleCustomFieldsApply() {
345
+ if (!checkout) return;
346
+ try {
347
+ setCustomFieldsLoading(true);
348
+ setError(null);
349
+ const updated = await getClient().setCheckoutCustomFields(checkout.id, customFieldValues);
350
+ setCheckout(updated);
351
+ setStep('payment');
352
+ } catch (err) {
353
+ const message = err instanceof Error ? err.message : t('customFieldsFailed');
354
+ setError(message);
355
+ } finally {
356
+ setCustomFieldsLoading(false);
357
+ }
358
+ }
359
+
360
+ // Handle delivery method selection
361
+ async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
362
+ if (!checkout) return;
363
+
364
+ try {
365
+ setLoading(true);
366
+ setError(null);
367
+ setDeliveryType(method);
368
+ const client = getClient();
369
+
370
+ await client.setDeliveryType(checkout.id, method);
371
+
372
+ if (method === 'shipping') {
373
+ setStep('address');
374
+ } else {
375
+ setStep('pickup');
376
+ }
377
+ } catch (err) {
378
+ const message = err instanceof Error ? err.message : t('failedToSetDeliveryMethod');
379
+ setError(message);
380
+ } finally {
381
+ setLoading(false);
382
+ }
383
+ }
384
+
385
+ // Handle pickup location selection
386
+ async function handlePickupSelect(
387
+ locationId: string,
388
+ customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
389
+ ) {
390
+ if (!checkout) return;
391
+
392
+ try {
393
+ setLoading(true);
394
+ setError(null);
395
+ const client = getClient();
396
+
397
+ const updated = await client.selectPickupLocation(checkout.id, {
398
+ pickupRateId: locationId,
399
+ email: customerInfo.email,
400
+ firstName: customerInfo.firstName,
401
+ lastName: customerInfo.lastName,
402
+ phone: customerInfo.phone,
403
+ });
404
+ setCheckout(updated);
405
+ setStep(await loadCustomFieldsOrSkip(updated.id));
406
+ } catch (err) {
407
+ const message = err instanceof Error ? err.message : t('failedToSelectPickup');
408
+ setError(message);
409
+ } finally {
410
+ setLoading(false);
411
+ }
412
+ }
413
+
414
+ // Refresh cart and checkout after coupon apply/remove
415
+ const handleCouponUpdate = useCallback(async () => {
416
+ await refreshCart();
417
+ if (checkout) {
418
+ try {
419
+ const client = getClient();
420
+ const updated = await client.getCheckout(checkout.id);
421
+ setCheckout(updated);
422
+ } catch (err) {
423
+ console.error('Failed to refresh checkout after coupon update:', err);
424
+ }
425
+ }
426
+ }, [checkout, refreshCart]);
427
+
428
+ if (initializing) {
429
+ return (
430
+ <div className="flex min-h-[60vh] items-center justify-center">
431
+ <LoadingSpinner size="lg" />
432
+ </div>
433
+ );
434
+ }
435
+
436
+ // Empty cart
437
+ if (!cart || cart.items.length === 0) {
438
+ return (
439
+ <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
440
+ <h1 className="text-foreground text-2xl font-bold">{t('emptyCart')}</h1>
441
+ <p className="text-muted-foreground mt-2">{t('emptyCartSubtitle')}</p>
442
+ <Link
443
+ href="/products"
444
+ className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
445
+ >
446
+ {tc('shopNow')}
447
+ </Link>
448
+ </div>
449
+ );
450
+ }
451
+
452
+ if (error && !checkout) {
453
+ return (
454
+ <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
455
+ <h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
456
+ <p className="text-destructive mt-2">{error}</p>
457
+ <Link
458
+ href="/cart"
459
+ className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
460
+ >
461
+ {t('returnToCart')}
462
+ </Link>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ const customFieldsStep =
468
+ customFields.length > 0
469
+ ? [{ key: 'custom-fields' as CheckoutStep, label: t('stepCustomFields') }]
470
+ : [];
471
+
472
+ const steps: { key: CheckoutStep; label: string }[] = isAllDigital
473
+ ? [
474
+ { key: 'address', label: t('stepContactInfo') },
475
+ ...customFieldsStep,
476
+ { key: 'payment', label: t('stepPayment') },
477
+ ]
478
+ : pickupLocations.length > 0
479
+ ? deliveryType === 'pickup'
480
+ ? [
481
+ { key: 'method', label: t('stepMethod') },
482
+ { key: 'pickup', label: t('stepPickup') },
483
+ ...customFieldsStep,
484
+ { key: 'payment', label: t('stepPayment') },
485
+ ]
486
+ : [
487
+ { key: 'method', label: t('stepMethod') },
488
+ { key: 'address', label: t('stepAddress') },
489
+ { key: 'shipping', label: t('stepShipping') },
490
+ ...customFieldsStep,
491
+ { key: 'payment', label: t('stepPayment') },
492
+ ]
493
+ : [
494
+ { key: 'address', label: t('stepAddress') },
495
+ { key: 'shipping', label: t('stepShipping') },
496
+ ...customFieldsStep,
497
+ { key: 'payment', label: t('stepPayment') },
498
+ ];
499
+
500
+ const currentStepIndex = steps.findIndex((s) => s.key === step);
501
+
502
+ return (
503
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
504
+ <h1 className="text-foreground mb-6 text-2xl font-bold">{t('title')}</h1>
505
+
506
+ {/* Canceled payment banner */}
507
+ {canceled && (
508
+ <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">
509
+ {t('paymentCanceledBanner')}
510
+ </div>
511
+ )}
512
+
513
+ {/* Reservation countdown */}
514
+ {checkout?.reservation?.hasReservation && (
515
+ <ReservationCountdown reservation={checkout.reservation} className="mb-6" />
516
+ )}
517
+
518
+ {/* Step indicator */}
519
+ <div className="mb-8 flex items-center gap-2">
520
+ {steps.map((s, index) => (
521
+ <div key={s.key} className="flex items-center">
522
+ {index > 0 && (
523
+ <div
524
+ className={cn(
525
+ 'mx-2 h-px w-8 sm:w-12',
526
+ index <= currentStepIndex ? 'bg-primary' : 'bg-border'
527
+ )}
528
+ />
529
+ )}
530
+ <div className="flex items-center gap-2">
531
+ <div
532
+ className={cn(
533
+ 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
534
+ index < currentStepIndex
535
+ ? 'bg-primary text-primary-foreground'
536
+ : index === currentStepIndex
537
+ ? 'bg-primary text-primary-foreground'
538
+ : 'bg-muted text-muted-foreground'
539
+ )}
540
+ >
541
+ {index < currentStepIndex ? (
542
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
543
+ <path
544
+ strokeLinecap="round"
545
+ strokeLinejoin="round"
546
+ strokeWidth={2}
547
+ d="M5 13l4 4L19 7"
548
+ />
549
+ </svg>
550
+ ) : (
551
+ index + 1
552
+ )}
553
+ </div>
554
+ <span
555
+ className={cn(
556
+ 'hidden text-sm sm:block',
557
+ index <= currentStepIndex
558
+ ? 'text-foreground font-medium'
559
+ : 'text-muted-foreground'
560
+ )}
561
+ >
562
+ {s.label}
563
+ </span>
564
+ </div>
565
+ </div>
566
+ ))}
567
+ </div>
568
+
569
+ {/* Error banner */}
570
+ {error && checkout && (
571
+ <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
572
+ {error}
573
+ </div>
574
+ )}
575
+
576
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
577
+ {/* Main content */}
578
+ <div className="lg:col-span-2">
579
+ {/* Delivery Method */}
580
+ {step === 'method' && (
581
+ <div>
582
+ <h2 className="text-foreground mb-4 text-lg font-semibold">{t('deliveryMethod')}</h2>
583
+ <DeliveryMethodStep onSelect={handleDeliveryTypeSelect} />
584
+ </div>
585
+ )}
586
+
587
+ {/* Address */}
588
+ {step === 'address' && (
589
+ <div>
590
+ <div className="mb-4 flex items-center justify-between">
591
+ <h2 className="text-foreground text-lg font-semibold">
592
+ {isAllDigital ? t('contactInfo') : t('shippingAddress')}
593
+ </h2>
594
+ {!isAllDigital && pickupLocations.length > 0 && (
595
+ <button
596
+ type="button"
597
+ onClick={() => setStep('method')}
598
+ className="text-primary text-sm hover:underline"
599
+ >
600
+ {t('changeMethod')}
601
+ </button>
602
+ )}
603
+ </div>
604
+ <CheckoutForm
605
+ onSubmit={handleAddressSubmit}
606
+ loading={loading}
607
+ destinations={isAllDigital ? null : destinations}
608
+ showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
609
+ emailOnly={isAllDigital}
610
+ initialValues={
611
+ checkout?.shippingAddress
612
+ ? {
613
+ email: checkout.email || '',
614
+ firstName: checkout.shippingAddress.firstName,
615
+ lastName: checkout.shippingAddress.lastName,
616
+ line1: checkout.shippingAddress.line1,
617
+ line2: checkout.shippingAddress.line2 || '',
618
+ city: checkout.shippingAddress.city,
619
+ region: checkout.shippingAddress.region || '',
620
+ postalCode: checkout.shippingAddress.postalCode,
621
+ country: checkout.shippingAddress.country,
622
+ phone: checkout.shippingAddress.phone || '',
623
+ }
624
+ : prefillAddress
625
+ ? {
626
+ email: prefillAddress.email,
627
+ firstName: prefillAddress.firstName,
628
+ lastName: prefillAddress.lastName,
629
+ line1: prefillAddress.line1,
630
+ line2: prefillAddress.line2 || '',
631
+ city: prefillAddress.city,
632
+ region: prefillAddress.region || '',
633
+ postalCode: prefillAddress.postalCode,
634
+ country: prefillAddress.country,
635
+ phone: prefillAddress.phone || '',
636
+ }
637
+ : prefillCustomer
638
+ ? {
639
+ email: prefillCustomer.email,
640
+ firstName: prefillCustomer.firstName || '',
641
+ lastName: prefillCustomer.lastName || '',
642
+ phone: prefillCustomer.phone || '',
643
+ }
644
+ : undefined
645
+ }
646
+ />
647
+ </div>
648
+ )}
649
+
650
+ {/* Step 2: Shipping */}
651
+ {step === 'shipping' && (
652
+ <div>
653
+ <div className="mb-4 flex items-center justify-between">
654
+ <h2 className="text-foreground text-lg font-semibold">{t('shippingMethod')}</h2>
655
+ <button
656
+ type="button"
657
+ onClick={() => setStep('address')}
658
+ className="text-primary text-sm hover:underline"
659
+ >
660
+ {t('editAddress')}
661
+ </button>
662
+ </div>
663
+
664
+ <ShippingStep
665
+ rates={shippingRates}
666
+ selectedRateId={selectedRateId}
667
+ onSelect={handleShippingSelect}
668
+ loading={loading}
669
+ />
670
+ </div>
671
+ )}
672
+
673
+ {/* Pickup */}
674
+ {step === 'pickup' && (
675
+ <div>
676
+ <div className="mb-4 flex items-center justify-between">
677
+ <h2 className="text-foreground text-lg font-semibold">{t('pickupLocation')}</h2>
678
+ <button
679
+ type="button"
680
+ onClick={() => setStep('method')}
681
+ className="text-primary text-sm hover:underline"
682
+ >
683
+ {t('changeMethod')}
684
+ </button>
685
+ </div>
686
+ <PickupStep
687
+ locations={pickupLocations}
688
+ onSelect={handlePickupSelect}
689
+ loading={loading}
690
+ initialEmail={checkout?.email || ''}
691
+ />
692
+ </div>
693
+ )}
694
+
695
+ {/* Custom Fields (optional, between shipping/pickup and payment) */}
696
+ {step === 'custom-fields' && checkout && (
697
+ <div>
698
+ <div className="mb-4 flex items-center justify-between">
699
+ <h2 className="text-foreground text-lg font-semibold">{t('customFieldsTitle')}</h2>
700
+ <button
701
+ type="button"
702
+ onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
703
+ className="text-primary text-sm hover:underline"
704
+ >
705
+ {deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
706
+ </button>
707
+ </div>
708
+ <CustomFieldsStep
709
+ fields={customFields}
710
+ values={customFieldValues}
711
+ onChange={(key, value) =>
712
+ setCustomFieldValues((prev) => ({ ...prev, [key]: value }))
713
+ }
714
+ onApply={handleCustomFieldsApply}
715
+ loading={customFieldsLoading}
716
+ />
717
+ </div>
718
+ )}
719
+
720
+ {/* Payment */}
721
+ {step === 'payment' && checkout && (
722
+ <div>
723
+ <div className="mb-4 flex items-center justify-between">
724
+ <h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
725
+ {customFields.length > 0 ? (
726
+ <button
727
+ type="button"
728
+ onClick={() => setStep('custom-fields')}
729
+ className="text-primary text-sm hover:underline"
730
+ >
731
+ {t('changeOptions')}
732
+ </button>
733
+ ) : (
734
+ !isAllDigital && (
735
+ <button
736
+ type="button"
737
+ onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
738
+ className="text-primary text-sm hover:underline"
739
+ >
740
+ {deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
741
+ </button>
742
+ )
743
+ )}
744
+ </div>
745
+
746
+ <PaymentStep checkoutId={checkout.id} />
747
+ </div>
748
+ )}
749
+ </div>
750
+
751
+ {/* Order summary sidebar */}
752
+ <div className="lg:col-span-1">
753
+ <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
754
+ <h3 className="text-foreground mb-4 text-lg font-semibold">{t('orderSummary')}</h3>
755
+
756
+ {/* Line items */}
757
+ {checkout?.lineItems && checkout.lineItems.length > 0 ? (
758
+ <div className="mb-4 space-y-3">
759
+ {checkout.lineItems.map((item) => {
760
+ const imageUrl = item.product.images?.[0]?.url || null;
761
+ const name = item.variant?.name || item.product.name;
762
+ const lineTotal = parseFloat(item.unitPrice) * item.quantity;
763
+
764
+ return (
765
+ <div key={item.id} className="flex gap-3">
766
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
767
+ {imageUrl ? (
768
+ <Image
769
+ src={imageUrl}
770
+ alt={name}
771
+ fill
772
+ sizes="48px"
773
+ className="object-cover"
774
+ />
775
+ ) : (
776
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
777
+ <svg
778
+ className="h-5 w-5"
779
+ fill="none"
780
+ viewBox="0 0 24 24"
781
+ stroke="currentColor"
782
+ >
783
+ <path
784
+ strokeLinecap="round"
785
+ strokeLinejoin="round"
786
+ strokeWidth={1.5}
787
+ 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"
788
+ />
789
+ </svg>
790
+ </div>
791
+ )}
792
+ </div>
793
+
794
+ <div className="min-w-0 flex-1">
795
+ <p className="text-foreground truncate text-sm">{name}</p>
796
+ <p className="text-muted-foreground text-xs">
797
+ {tc('qty')} {item.quantity}
798
+ </p>
799
+ </div>
800
+
801
+ <span className="text-foreground flex-shrink-0 text-sm font-medium">
802
+ {formatPrice(lineTotal, { currency }) as string}
803
+ </span>
804
+ </div>
805
+ );
806
+ })}
807
+ </div>
808
+ ) : (
809
+ // Fallback to cart items if checkout line items aren't loaded yet
810
+ cart && (
811
+ <div className="mb-4 space-y-2">
812
+ <p className="text-muted-foreground text-sm">
813
+ {cart.items.length} {cart.items.length === 1 ? tc('item') : tc('items')}
814
+ </p>
815
+ </div>
816
+ )
817
+ )}
818
+
819
+ {/* Order bumps */}
820
+ {orderBumps?.bumps && orderBumps.bumps.length > 0 && (
821
+ <div className="border-border space-y-2 border-t pt-4">
822
+ <p className="text-foreground text-xs font-semibold uppercase tracking-wide">
823
+ {t('addToYourOrder')}
824
+ </p>
825
+ {orderBumps.bumps.map((bump) => (
826
+ <OrderBumpCard
827
+ key={bump.id}
828
+ bump={bump}
829
+ isAdded={addedBumpIds.has(bump.id)}
830
+ onToggle={handleBumpToggle}
831
+ loading={bumpLoading === bump.id}
832
+ />
833
+ ))}
834
+ </div>
835
+ )}
836
+
837
+ {/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
838
+ {cart &&
839
+ (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
840
+ <div className="border-border border-t pt-4">
841
+ <CouponInput cart={cart} onUpdate={handleCouponUpdate} />
842
+ </div>
843
+ )}
844
+
845
+ {/* Totals */}
846
+ {checkout && (
847
+ <div className="border-border space-y-2 border-t pt-4 text-sm">
848
+ <div className="flex items-center justify-between">
849
+ <span className="text-muted-foreground">{tc('subtotal')}</span>
850
+ <span className="text-foreground">
851
+ {formatPrice(parseFloat(checkout.subtotal), { currency }) as string}
852
+ </span>
853
+ </div>
854
+
855
+ {(() => {
856
+ const totalDiscount = parseFloat(checkout.discountAmount);
857
+ const ruleAmt = parseFloat(checkout.ruleDiscountAmount || '0');
858
+ const couponAmt = totalDiscount - ruleAmt;
859
+ const rules = cart?.appliedDiscounts;
860
+ if (totalDiscount <= 0) return null;
861
+ return (
862
+ <>
863
+ {rules && rules.length > 0
864
+ ? rules.map((rule) => (
865
+ <div key={rule.ruleId} className="flex items-center justify-between">
866
+ <span className="text-muted-foreground">{rule.ruleName}</span>
867
+ <span className="text-destructive">
868
+ -
869
+ {
870
+ formatPrice(parseFloat(rule.discountAmount), {
871
+ currency,
872
+ }) as string
873
+ }
874
+ </span>
875
+ </div>
876
+ ))
877
+ : ruleAmt > 0 && (
878
+ <div className="flex items-center justify-between">
879
+ <span className="text-muted-foreground">{tc('generalDiscount')}</span>
880
+ <span className="text-destructive">
881
+ -{formatPrice(ruleAmt, { currency }) as string}
882
+ </span>
883
+ </div>
884
+ )}
885
+ {checkout.couponCode && couponAmt > 0 && (
886
+ <div className="flex items-center justify-between">
887
+ <span className="text-muted-foreground">
888
+ {tc('couponDiscount')} ({checkout.couponCode})
889
+ </span>
890
+ <span className="text-destructive">
891
+ -{formatPrice(couponAmt, { currency }) as string}
892
+ </span>
893
+ </div>
894
+ )}
895
+ {!checkout.couponCode && ruleAmt <= 0 && (!rules || rules.length === 0) && (
896
+ <div className="flex items-center justify-between">
897
+ <span className="text-muted-foreground">{tc('discount')}</span>
898
+ <span className="text-destructive">
899
+ -{formatPrice(totalDiscount, { currency }) as string}
900
+ </span>
901
+ </div>
902
+ )}
903
+ </>
904
+ );
905
+ })()}
906
+
907
+ {(parseFloat(checkout.shippingAmount) > 0 ||
908
+ checkout.deliveryType === 'pickup') && (
909
+ <div className="flex items-center justify-between">
910
+ <span className="text-muted-foreground">
911
+ {checkout.deliveryType === 'pickup' ? tc('pickup') : tc('shipping')}
912
+ </span>
913
+ <span className="text-foreground">
914
+ {parseFloat(checkout.shippingAmount) === 0
915
+ ? tc('free')
916
+ : (formatPrice(parseFloat(checkout.shippingAmount), {
917
+ currency,
918
+ }) as string)}
919
+ </span>
920
+ </div>
921
+ )}
922
+
923
+ <TaxDisplay
924
+ addressSet={!!checkout.shippingAddress}
925
+ taxAmount={checkout.taxAmount}
926
+ taxBreakdown={checkout.taxBreakdown}
927
+ />
928
+
929
+ {/* Custom field surcharges (one line per applied surcharge) */}
930
+ {checkout.appliedSurcharges && checkout.appliedSurcharges.length > 0 && (
931
+ <>
932
+ {checkout.appliedSurcharges.map((s) => (
933
+ <div key={s.key} className="flex items-center justify-between">
934
+ <span className="text-muted-foreground">{s.name}</span>
935
+ <span className="text-foreground">
936
+ {formatPrice(Number(s.amount), { currency }) as string}
937
+ </span>
938
+ </div>
939
+ ))}
940
+ </>
941
+ )}
942
+
943
+ <div className="border-border mt-2 border-t pt-2">
944
+ <div className="flex items-center justify-between">
945
+ <span className="text-foreground font-semibold">{tc('total')}</span>
946
+ <span className="text-foreground text-base font-semibold">
947
+ {formatPrice(parseFloat(checkout.total), { currency }) as string}
948
+ </span>
949
+ </div>
950
+ </div>
951
+ </div>
952
+ )}
953
+ </div>
954
+ </div>
955
+ </div>
956
+ </div>
957
+ );
958
+ }
959
+
960
+ export default function CheckoutPage() {
961
+ return (
962
+ <Suspense
963
+ fallback={
964
+ <div className="flex min-h-[60vh] items-center justify-center">
965
+ <LoadingSpinner size="lg" />
966
+ </div>
967
+ }
968
+ >
969
+ <CheckoutContent />
970
+ </Suspense>
971
+ );
972
+ }