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