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