create-brainerce-store 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,463 +1,477 @@
1
- 'use client';
2
-
3
- import { Suspense, useEffect, useState, useCallback } from 'react';
4
- import { useSearchParams } from 'next/navigation';
5
- import Image from 'next/image';
6
- import Link from 'next/link';
7
- import type { Checkout, ShippingRate, SetShippingAddressDto } from 'brainerce';
8
- import { formatPrice } from 'brainerce';
9
- import { getClient } from '@/lib/brainerce';
10
- import { useStoreInfo, useAuth, useCart } from '@/providers/store-provider';
11
- import { CheckoutForm } from '@/components/checkout/checkout-form';
12
- import { ShippingStep } from '@/components/checkout/shipping-step';
13
- import { PaymentStep } from '@/components/checkout/payment-step';
14
- import { TaxDisplay } from '@/components/checkout/tax-display';
15
- import { ReservationCountdown } from '@/components/cart/reservation-countdown';
16
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
17
- import { cn } from '@/lib/utils';
18
-
19
- type CheckoutStep = 'address' | 'shipping' | 'payment';
20
-
21
- function CheckoutContent() {
22
- const searchParams = useSearchParams();
23
- const { storeInfo } = useStoreInfo();
24
- const { isLoggedIn } = useAuth();
25
- const { cart, isServerCart } = useCart();
26
- const currency = storeInfo?.currency || 'USD';
27
-
28
- const [step, setStep] = useState<CheckoutStep>('address');
29
- const [checkout, setCheckout] = useState<Checkout | null>(null);
30
- const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
31
- const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
32
- const [loading, setLoading] = useState(false);
33
- const [initializing, setInitializing] = useState(true);
34
- const [error, setError] = useState<string | null>(null);
35
-
36
- // Check for returning from canceled payment
37
- const canceled = searchParams.get('canceled') === 'true';
38
- const existingCheckoutId = searchParams.get('checkout_id');
39
-
40
- // Initialize or resume checkout
41
- const initCheckout = useCallback(async () => {
42
- try {
43
- setInitializing(true);
44
- setError(null);
45
- const client = getClient();
46
-
47
- // If returning with existing checkout ID, resume it
48
- if (existingCheckoutId) {
49
- const existing = await client.getCheckout(existingCheckoutId);
50
- setCheckout(existing);
51
-
52
- // Determine step based on checkout state
53
- if (existing.shippingAddress && existing.shippingRateId) {
54
- setStep('payment');
55
- } else if (existing.shippingAddress) {
56
- // Fetch shipping rates
57
- const rates = await client.getShippingRates(existing.id);
58
- setShippingRates(rates);
59
- setStep('shipping');
60
- }
61
- return;
62
- }
63
-
64
- // Create new checkout
65
- if (isLoggedIn && cart && isServerCart(cart)) {
66
- // Logged-in user: create checkout from server cart
67
- const newCheckout = await client.createCheckout({ cartId: cart.id });
68
- setCheckout(newCheckout);
69
- } else if (cart && !isServerCart(cart)) {
70
- // Guest user: start guest checkout
71
- const result = await client.startGuestCheckout();
72
-
73
- if (result.tracked) {
74
- const newCheckout = await client.getCheckout(result.checkoutId);
75
- setCheckout(newCheckout);
76
- } else {
77
- setError('Unable to start checkout. Please try again.');
78
- }
79
- } else {
80
- setError('Your cart is empty.');
81
- }
82
- } catch (err) {
83
- const message = err instanceof Error ? err.message : 'Failed to initialize checkout';
84
- setError(message);
85
- } finally {
86
- setInitializing(false);
87
- }
88
- }, [existingCheckoutId, isLoggedIn, cart, isServerCart]);
89
-
90
- const cartLoaded = cart !== null;
91
- useEffect(() => {
92
- if (cartLoaded) {
93
- initCheckout();
94
- }
95
- }, [cartLoaded, initCheckout]);
96
-
97
- // Handle shipping address submission
98
- async function handleAddressSubmit(address: SetShippingAddressDto) {
99
- if (!checkout) return;
100
-
101
- try {
102
- setLoading(true);
103
- setError(null);
104
- const client = getClient();
105
-
106
- const response = await client.setShippingAddress(checkout.id, address);
107
- setCheckout(response.checkout);
108
- setShippingRates(response.rates);
109
- setStep('shipping');
110
- } catch (err) {
111
- const message = err instanceof Error ? err.message : 'Failed to save address';
112
- setError(message);
113
- } finally {
114
- setLoading(false);
115
- }
116
- }
117
-
118
- // Handle shipping method selection
119
- async function handleShippingSelect(rateId: string) {
120
- if (!checkout) return;
121
-
122
- try {
123
- setLoading(true);
124
- setError(null);
125
- setSelectedRateId(rateId);
126
- const client = getClient();
127
-
128
- const updated = await client.selectShippingMethod(checkout.id, rateId);
129
- setCheckout(updated);
130
- setStep('payment');
131
- } catch (err) {
132
- const message = err instanceof Error ? err.message : 'Failed to select shipping';
133
- setError(message);
134
- } finally {
135
- setLoading(false);
136
- }
137
- }
138
-
139
- if (initializing) {
140
- return (
141
- <div className="flex min-h-[60vh] items-center justify-center">
142
- <LoadingSpinner size="lg" />
143
- </div>
144
- );
145
- }
146
-
147
- // Empty cart
148
- if (!cart || cart.items.length === 0) {
149
- return (
150
- <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
151
- <h1 className="text-foreground text-2xl font-bold">Your cart is empty</h1>
152
- <p className="text-muted-foreground mt-2">
153
- Add some items to your cart before checking out.
154
- </p>
155
- <Link
156
- href="/products"
157
- className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
158
- >
159
- Shop Now
160
- </Link>
161
- </div>
162
- );
163
- }
164
-
165
- if (error && !checkout) {
166
- return (
167
- <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
168
- <h1 className="text-foreground text-2xl font-bold">Checkout Error</h1>
169
- <p className="text-destructive mt-2">{error}</p>
170
- <Link
171
- href="/cart"
172
- className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
173
- >
174
- Return to Cart
175
- </Link>
176
- </div>
177
- );
178
- }
179
-
180
- const steps: { key: CheckoutStep; label: string; number: number }[] = [
181
- { key: 'address', label: 'Address', number: 1 },
182
- { key: 'shipping', label: 'Shipping', number: 2 },
183
- { key: 'payment', label: 'Payment', number: 3 },
184
- ];
185
-
186
- const currentStepIndex = steps.findIndex((s) => s.key === step);
187
-
188
- return (
189
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
190
- <h1 className="text-foreground mb-6 text-2xl font-bold">Checkout</h1>
191
-
192
- {/* Canceled payment banner */}
193
- {canceled && (
194
- <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">
195
- Payment was canceled. You can try again or change your payment method.
196
- </div>
197
- )}
198
-
199
- {/* Reservation countdown */}
200
- {checkout?.reservation?.hasReservation && (
201
- <ReservationCountdown reservation={checkout.reservation} className="mb-6" />
202
- )}
203
-
204
- {/* Step indicator */}
205
- <div className="mb-8 flex items-center gap-2">
206
- {steps.map((s, index) => (
207
- <div key={s.key} className="flex items-center">
208
- {index > 0 && (
209
- <div
210
- className={cn(
211
- 'mx-2 h-px w-8 sm:w-12',
212
- index <= currentStepIndex ? 'bg-primary' : 'bg-border'
213
- )}
214
- />
215
- )}
216
- <div className="flex items-center gap-2">
217
- <div
218
- className={cn(
219
- 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
220
- index < currentStepIndex
221
- ? 'bg-primary text-primary-foreground'
222
- : index === currentStepIndex
223
- ? 'bg-primary text-primary-foreground'
224
- : 'bg-muted text-muted-foreground'
225
- )}
226
- >
227
- {index < currentStepIndex ? (
228
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
229
- <path
230
- strokeLinecap="round"
231
- strokeLinejoin="round"
232
- strokeWidth={2}
233
- d="M5 13l4 4L19 7"
234
- />
235
- </svg>
236
- ) : (
237
- s.number
238
- )}
239
- </div>
240
- <span
241
- className={cn(
242
- 'hidden text-sm sm:block',
243
- index <= currentStepIndex
244
- ? 'text-foreground font-medium'
245
- : 'text-muted-foreground'
246
- )}
247
- >
248
- {s.label}
249
- </span>
250
- </div>
251
- </div>
252
- ))}
253
- </div>
254
-
255
- {/* Error banner */}
256
- {error && checkout && (
257
- <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
258
- {error}
259
- </div>
260
- )}
261
-
262
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
263
- {/* Main content */}
264
- <div className="lg:col-span-2">
265
- {/* Step 1: Address */}
266
- {step === 'address' && (
267
- <div>
268
- <h2 className="text-foreground mb-4 text-lg font-semibold">Shipping Address</h2>
269
- <CheckoutForm
270
- onSubmit={handleAddressSubmit}
271
- loading={loading}
272
- initialValues={
273
- checkout?.shippingAddress
274
- ? {
275
- email: checkout.email || '',
276
- firstName: checkout.shippingAddress.firstName,
277
- lastName: checkout.shippingAddress.lastName,
278
- line1: checkout.shippingAddress.line1,
279
- line2: checkout.shippingAddress.line2 || '',
280
- city: checkout.shippingAddress.city,
281
- region: checkout.shippingAddress.region || '',
282
- postalCode: checkout.shippingAddress.postalCode,
283
- country: checkout.shippingAddress.country,
284
- phone: checkout.shippingAddress.phone || '',
285
- }
286
- : undefined
287
- }
288
- />
289
- </div>
290
- )}
291
-
292
- {/* Step 2: Shipping */}
293
- {step === 'shipping' && (
294
- <div>
295
- <div className="mb-4 flex items-center justify-between">
296
- <h2 className="text-foreground text-lg font-semibold">Shipping Method</h2>
297
- <button
298
- type="button"
299
- onClick={() => setStep('address')}
300
- className="text-primary text-sm hover:underline"
301
- >
302
- Edit address
303
- </button>
304
- </div>
305
-
306
- <ShippingStep
307
- rates={shippingRates}
308
- selectedRateId={selectedRateId}
309
- onSelect={handleShippingSelect}
310
- loading={loading}
311
- />
312
- </div>
313
- )}
314
-
315
- {/* Step 3: Payment */}
316
- {step === 'payment' && checkout && (
317
- <div>
318
- <div className="mb-4 flex items-center justify-between">
319
- <h2 className="text-foreground text-lg font-semibold">Payment</h2>
320
- <button
321
- type="button"
322
- onClick={() => setStep('shipping')}
323
- className="text-primary text-sm hover:underline"
324
- >
325
- Change shipping
326
- </button>
327
- </div>
328
-
329
- <PaymentStep checkoutId={checkout.id} />
330
- </div>
331
- )}
332
- </div>
333
-
334
- {/* Order summary sidebar */}
335
- <div className="lg:col-span-1">
336
- <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
337
- <h3 className="text-foreground mb-4 text-lg font-semibold">Order Summary</h3>
338
-
339
- {/* Line items */}
340
- {checkout?.lineItems && checkout.lineItems.length > 0 ? (
341
- <div className="mb-4 space-y-3">
342
- {checkout.lineItems.map((item) => {
343
- const imageUrl = item.product.images?.[0]?.url || null;
344
- const name = item.variant?.name || item.product.name;
345
- const lineTotal = parseFloat(item.unitPrice) * item.quantity;
346
-
347
- return (
348
- <div key={item.id} className="flex gap-3">
349
- <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
350
- {imageUrl ? (
351
- <Image
352
- src={imageUrl}
353
- alt={name}
354
- fill
355
- sizes="48px"
356
- className="object-cover"
357
- />
358
- ) : (
359
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
360
- <svg
361
- className="h-5 w-5"
362
- fill="none"
363
- viewBox="0 0 24 24"
364
- stroke="currentColor"
365
- >
366
- <path
367
- strokeLinecap="round"
368
- strokeLinejoin="round"
369
- strokeWidth={1.5}
370
- 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"
371
- />
372
- </svg>
373
- </div>
374
- )}
375
- </div>
376
-
377
- <div className="min-w-0 flex-1">
378
- <p className="text-foreground truncate text-sm">{name}</p>
379
- <p className="text-muted-foreground text-xs">Qty: {item.quantity}</p>
380
- </div>
381
-
382
- <span className="text-foreground flex-shrink-0 text-sm font-medium">
383
- {formatPrice(lineTotal, { currency }) as string}
384
- </span>
385
- </div>
386
- );
387
- })}
388
- </div>
389
- ) : (
390
- // Fallback to cart items if checkout line items aren't loaded yet
391
- cart && (
392
- <div className="mb-4 space-y-2">
393
- <p className="text-muted-foreground text-sm">
394
- {cart.items.length} {cart.items.length === 1 ? 'item' : 'items'}
395
- </p>
396
- </div>
397
- )
398
- )}
399
-
400
- {/* Totals */}
401
- {checkout && (
402
- <div className="border-border space-y-2 border-t pt-4 text-sm">
403
- <div className="flex items-center justify-between">
404
- <span className="text-muted-foreground">Subtotal</span>
405
- <span className="text-foreground">
406
- {formatPrice(parseFloat(checkout.subtotal), { currency }) as string}
407
- </span>
408
- </div>
409
-
410
- {parseFloat(checkout.discountAmount) > 0 && (
411
- <div className="flex items-center justify-between">
412
- <span className="text-muted-foreground">Discount</span>
413
- <span className="text-destructive">
414
- -{formatPrice(parseFloat(checkout.discountAmount), { currency }) as string}
415
- </span>
416
- </div>
417
- )}
418
-
419
- {parseFloat(checkout.shippingAmount) > 0 && (
420
- <div className="flex items-center justify-between">
421
- <span className="text-muted-foreground">Shipping</span>
422
- <span className="text-foreground">
423
- {formatPrice(parseFloat(checkout.shippingAmount), { currency }) as string}
424
- </span>
425
- </div>
426
- )}
427
-
428
- <TaxDisplay
429
- addressSet={!!checkout.shippingAddress}
430
- taxAmount={checkout.taxAmount}
431
- taxBreakdown={checkout.taxBreakdown}
432
- />
433
-
434
- <div className="border-border mt-2 border-t pt-2">
435
- <div className="flex items-center justify-between">
436
- <span className="text-foreground font-semibold">Total</span>
437
- <span className="text-foreground text-base font-semibold">
438
- {formatPrice(parseFloat(checkout.total), { currency }) as string}
439
- </span>
440
- </div>
441
- </div>
442
- </div>
443
- )}
444
- </div>
445
- </div>
446
- </div>
447
- </div>
448
- );
449
- }
450
-
451
- export default function CheckoutPage() {
452
- return (
453
- <Suspense
454
- fallback={
455
- <div className="flex min-h-[60vh] items-center justify-center">
456
- <LoadingSpinner size="lg" />
457
- </div>
458
- }
459
- >
460
- <CheckoutContent />
461
- </Suspense>
462
- );
463
- }
1
+ 'use client';
2
+
3
+ import { Suspense, useEffect, useState, useCallback } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import Image from 'next/image';
6
+ import Link from 'next/link';
7
+ import type {
8
+ Checkout,
9
+ ShippingRate,
10
+ SetShippingAddressDto,
11
+ ShippingDestinations,
12
+ } from 'brainerce';
13
+ import { formatPrice } from 'brainerce';
14
+ import { getClient } from '@/lib/brainerce';
15
+ import { useStoreInfo, useAuth, useCart } from '@/providers/store-provider';
16
+ import { CheckoutForm } from '@/components/checkout/checkout-form';
17
+ import { ShippingStep } from '@/components/checkout/shipping-step';
18
+ import { PaymentStep } from '@/components/checkout/payment-step';
19
+ import { TaxDisplay } from '@/components/checkout/tax-display';
20
+ import { ReservationCountdown } from '@/components/cart/reservation-countdown';
21
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
22
+ import { cn } from '@/lib/utils';
23
+
24
+ type CheckoutStep = 'address' | 'shipping' | 'payment';
25
+
26
+ function CheckoutContent() {
27
+ const searchParams = useSearchParams();
28
+ const { storeInfo } = useStoreInfo();
29
+ const { isLoggedIn } = useAuth();
30
+ const { cart } = useCart();
31
+ const currency = storeInfo?.currency || 'USD';
32
+
33
+ const [step, setStep] = useState<CheckoutStep>('address');
34
+ const [checkout, setCheckout] = useState<Checkout | null>(null);
35
+ const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
36
+ const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
37
+ const [loading, setLoading] = useState(false);
38
+ const [initializing, setInitializing] = useState(true);
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
41
+
42
+ // Check for returning from canceled payment
43
+ const canceled = searchParams.get('canceled') === 'true';
44
+ const existingCheckoutId = searchParams.get('checkout_id');
45
+
46
+ // Initialize or resume checkout
47
+ const initCheckout = useCallback(async () => {
48
+ try {
49
+ setInitializing(true);
50
+ setError(null);
51
+ const client = getClient();
52
+
53
+ // Fetch shipping destinations for country/region dropdowns
54
+ client
55
+ .getShippingDestinations()
56
+ .then(setDestinations)
57
+ .catch(() => {});
58
+
59
+ // If returning with existing checkout ID, resume it
60
+ if (existingCheckoutId) {
61
+ const existing = await client.getCheckout(existingCheckoutId);
62
+ setCheckout(existing);
63
+
64
+ // Determine step based on checkout state
65
+ if (existing.shippingAddress && existing.shippingRateId) {
66
+ setStep('payment');
67
+ } else if (existing.shippingAddress) {
68
+ // Fetch shipping rates
69
+ const rates = await client.getShippingRates(existing.id);
70
+ setShippingRates(rates);
71
+ setStep('shipping');
72
+ }
73
+ return;
74
+ }
75
+
76
+ // Create new checkout — cart is always server-side now
77
+ if (cart && cart.id) {
78
+ if (isLoggedIn) {
79
+ // Logged-in user: create checkout from customer cart
80
+ const newCheckout = await client.createCheckout({ cartId: cart.id });
81
+ setCheckout(newCheckout);
82
+ } else {
83
+ // Guest user: start guest checkout (creates checkout from session cart)
84
+ const result = await client.startGuestCheckout();
85
+ if (result.tracked) {
86
+ const newCheckout = await client.getCheckout(result.checkoutId);
87
+ setCheckout(newCheckout);
88
+ } else {
89
+ setError('Unable to start checkout. Please try again.');
90
+ }
91
+ }
92
+ } else {
93
+ setError('Your cart is empty.');
94
+ }
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : 'Failed to initialize checkout';
97
+ setError(message);
98
+ } finally {
99
+ setInitializing(false);
100
+ }
101
+ }, [existingCheckoutId, isLoggedIn, cart]);
102
+
103
+ const cartLoaded = cart !== null;
104
+ useEffect(() => {
105
+ if (cartLoaded) {
106
+ initCheckout();
107
+ }
108
+ }, [cartLoaded, initCheckout]);
109
+
110
+ // Handle shipping address submission
111
+ async function handleAddressSubmit(address: SetShippingAddressDto) {
112
+ if (!checkout) return;
113
+
114
+ try {
115
+ setLoading(true);
116
+ setError(null);
117
+ const client = getClient();
118
+
119
+ const response = await client.setShippingAddress(checkout.id, address);
120
+ setCheckout(response.checkout);
121
+ setShippingRates(response.rates);
122
+ setStep('shipping');
123
+ } catch (err) {
124
+ const message = err instanceof Error ? err.message : 'Failed to save address';
125
+ setError(message);
126
+ } finally {
127
+ setLoading(false);
128
+ }
129
+ }
130
+
131
+ // Handle shipping method selection
132
+ async function handleShippingSelect(rateId: string) {
133
+ if (!checkout) return;
134
+
135
+ try {
136
+ setLoading(true);
137
+ setError(null);
138
+ setSelectedRateId(rateId);
139
+ const client = getClient();
140
+
141
+ const updated = await client.selectShippingMethod(checkout.id, rateId);
142
+ setCheckout(updated);
143
+ setStep('payment');
144
+ } catch (err) {
145
+ const message = err instanceof Error ? err.message : 'Failed to select shipping';
146
+ setError(message);
147
+ } finally {
148
+ setLoading(false);
149
+ }
150
+ }
151
+
152
+ if (initializing) {
153
+ return (
154
+ <div className="flex min-h-[60vh] items-center justify-center">
155
+ <LoadingSpinner size="lg" />
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // Empty cart
161
+ if (!cart || cart.items.length === 0) {
162
+ return (
163
+ <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
164
+ <h1 className="text-foreground text-2xl font-bold">Your cart is empty</h1>
165
+ <p className="text-muted-foreground mt-2">
166
+ Add some items to your cart before checking out.
167
+ </p>
168
+ <Link
169
+ href="/products"
170
+ className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
171
+ >
172
+ Shop Now
173
+ </Link>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ if (error && !checkout) {
179
+ return (
180
+ <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
181
+ <h1 className="text-foreground text-2xl font-bold">Checkout Error</h1>
182
+ <p className="text-destructive mt-2">{error}</p>
183
+ <Link
184
+ href="/cart"
185
+ className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
186
+ >
187
+ Return to Cart
188
+ </Link>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ const steps: { key: CheckoutStep; label: string; number: number }[] = [
194
+ { key: 'address', label: 'Address', number: 1 },
195
+ { key: 'shipping', label: 'Shipping', number: 2 },
196
+ { key: 'payment', label: 'Payment', number: 3 },
197
+ ];
198
+
199
+ const currentStepIndex = steps.findIndex((s) => s.key === step);
200
+
201
+ return (
202
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
203
+ <h1 className="text-foreground mb-6 text-2xl font-bold">Checkout</h1>
204
+
205
+ {/* Canceled payment banner */}
206
+ {canceled && (
207
+ <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">
208
+ Payment was canceled. You can try again or change your payment method.
209
+ </div>
210
+ )}
211
+
212
+ {/* Reservation countdown */}
213
+ {checkout?.reservation?.hasReservation && (
214
+ <ReservationCountdown reservation={checkout.reservation} className="mb-6" />
215
+ )}
216
+
217
+ {/* Step indicator */}
218
+ <div className="mb-8 flex items-center gap-2">
219
+ {steps.map((s, index) => (
220
+ <div key={s.key} className="flex items-center">
221
+ {index > 0 && (
222
+ <div
223
+ className={cn(
224
+ 'mx-2 h-px w-8 sm:w-12',
225
+ index <= currentStepIndex ? 'bg-primary' : 'bg-border'
226
+ )}
227
+ />
228
+ )}
229
+ <div className="flex items-center gap-2">
230
+ <div
231
+ className={cn(
232
+ 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
233
+ index < currentStepIndex
234
+ ? 'bg-primary text-primary-foreground'
235
+ : index === currentStepIndex
236
+ ? 'bg-primary text-primary-foreground'
237
+ : 'bg-muted text-muted-foreground'
238
+ )}
239
+ >
240
+ {index < currentStepIndex ? (
241
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
242
+ <path
243
+ strokeLinecap="round"
244
+ strokeLinejoin="round"
245
+ strokeWidth={2}
246
+ d="M5 13l4 4L19 7"
247
+ />
248
+ </svg>
249
+ ) : (
250
+ s.number
251
+ )}
252
+ </div>
253
+ <span
254
+ className={cn(
255
+ 'hidden text-sm sm:block',
256
+ index <= currentStepIndex
257
+ ? 'text-foreground font-medium'
258
+ : 'text-muted-foreground'
259
+ )}
260
+ >
261
+ {s.label}
262
+ </span>
263
+ </div>
264
+ </div>
265
+ ))}
266
+ </div>
267
+
268
+ {/* Error banner */}
269
+ {error && checkout && (
270
+ <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
271
+ {error}
272
+ </div>
273
+ )}
274
+
275
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
276
+ {/* Main content */}
277
+ <div className="lg:col-span-2">
278
+ {/* Step 1: Address */}
279
+ {step === 'address' && (
280
+ <div>
281
+ <h2 className="text-foreground mb-4 text-lg font-semibold">Shipping Address</h2>
282
+ <CheckoutForm
283
+ onSubmit={handleAddressSubmit}
284
+ loading={loading}
285
+ destinations={destinations}
286
+ initialValues={
287
+ checkout?.shippingAddress
288
+ ? {
289
+ email: checkout.email || '',
290
+ firstName: checkout.shippingAddress.firstName,
291
+ lastName: checkout.shippingAddress.lastName,
292
+ line1: checkout.shippingAddress.line1,
293
+ line2: checkout.shippingAddress.line2 || '',
294
+ city: checkout.shippingAddress.city,
295
+ region: checkout.shippingAddress.region || '',
296
+ postalCode: checkout.shippingAddress.postalCode,
297
+ country: checkout.shippingAddress.country,
298
+ phone: checkout.shippingAddress.phone || '',
299
+ }
300
+ : undefined
301
+ }
302
+ />
303
+ </div>
304
+ )}
305
+
306
+ {/* Step 2: Shipping */}
307
+ {step === 'shipping' && (
308
+ <div>
309
+ <div className="mb-4 flex items-center justify-between">
310
+ <h2 className="text-foreground text-lg font-semibold">Shipping Method</h2>
311
+ <button
312
+ type="button"
313
+ onClick={() => setStep('address')}
314
+ className="text-primary text-sm hover:underline"
315
+ >
316
+ Edit address
317
+ </button>
318
+ </div>
319
+
320
+ <ShippingStep
321
+ rates={shippingRates}
322
+ selectedRateId={selectedRateId}
323
+ onSelect={handleShippingSelect}
324
+ loading={loading}
325
+ />
326
+ </div>
327
+ )}
328
+
329
+ {/* Step 3: Payment */}
330
+ {step === 'payment' && checkout && (
331
+ <div>
332
+ <div className="mb-4 flex items-center justify-between">
333
+ <h2 className="text-foreground text-lg font-semibold">Payment</h2>
334
+ <button
335
+ type="button"
336
+ onClick={() => setStep('shipping')}
337
+ className="text-primary text-sm hover:underline"
338
+ >
339
+ Change shipping
340
+ </button>
341
+ </div>
342
+
343
+ <PaymentStep checkoutId={checkout.id} />
344
+ </div>
345
+ )}
346
+ </div>
347
+
348
+ {/* Order summary sidebar */}
349
+ <div className="lg:col-span-1">
350
+ <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
351
+ <h3 className="text-foreground mb-4 text-lg font-semibold">Order Summary</h3>
352
+
353
+ {/* Line items */}
354
+ {checkout?.lineItems && checkout.lineItems.length > 0 ? (
355
+ <div className="mb-4 space-y-3">
356
+ {checkout.lineItems.map((item) => {
357
+ const imageUrl = item.product.images?.[0]?.url || null;
358
+ const name = item.variant?.name || item.product.name;
359
+ const lineTotal = parseFloat(item.unitPrice) * item.quantity;
360
+
361
+ return (
362
+ <div key={item.id} className="flex gap-3">
363
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
364
+ {imageUrl ? (
365
+ <Image
366
+ src={imageUrl}
367
+ alt={name}
368
+ fill
369
+ sizes="48px"
370
+ className="object-cover"
371
+ />
372
+ ) : (
373
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
374
+ <svg
375
+ className="h-5 w-5"
376
+ fill="none"
377
+ viewBox="0 0 24 24"
378
+ stroke="currentColor"
379
+ >
380
+ <path
381
+ strokeLinecap="round"
382
+ strokeLinejoin="round"
383
+ strokeWidth={1.5}
384
+ 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"
385
+ />
386
+ </svg>
387
+ </div>
388
+ )}
389
+ </div>
390
+
391
+ <div className="min-w-0 flex-1">
392
+ <p className="text-foreground truncate text-sm">{name}</p>
393
+ <p className="text-muted-foreground text-xs">Qty: {item.quantity}</p>
394
+ </div>
395
+
396
+ <span className="text-foreground flex-shrink-0 text-sm font-medium">
397
+ {formatPrice(lineTotal, { currency }) as string}
398
+ </span>
399
+ </div>
400
+ );
401
+ })}
402
+ </div>
403
+ ) : (
404
+ // Fallback to cart items if checkout line items aren't loaded yet
405
+ cart && (
406
+ <div className="mb-4 space-y-2">
407
+ <p className="text-muted-foreground text-sm">
408
+ {cart.items.length} {cart.items.length === 1 ? 'item' : 'items'}
409
+ </p>
410
+ </div>
411
+ )
412
+ )}
413
+
414
+ {/* Totals */}
415
+ {checkout && (
416
+ <div className="border-border space-y-2 border-t pt-4 text-sm">
417
+ <div className="flex items-center justify-between">
418
+ <span className="text-muted-foreground">Subtotal</span>
419
+ <span className="text-foreground">
420
+ {formatPrice(parseFloat(checkout.subtotal), { currency }) as string}
421
+ </span>
422
+ </div>
423
+
424
+ {parseFloat(checkout.discountAmount) > 0 && (
425
+ <div className="flex items-center justify-between">
426
+ <span className="text-muted-foreground">Discount</span>
427
+ <span className="text-destructive">
428
+ -{formatPrice(parseFloat(checkout.discountAmount), { currency }) as string}
429
+ </span>
430
+ </div>
431
+ )}
432
+
433
+ {parseFloat(checkout.shippingAmount) > 0 && (
434
+ <div className="flex items-center justify-between">
435
+ <span className="text-muted-foreground">Shipping</span>
436
+ <span className="text-foreground">
437
+ {formatPrice(parseFloat(checkout.shippingAmount), { currency }) as string}
438
+ </span>
439
+ </div>
440
+ )}
441
+
442
+ <TaxDisplay
443
+ addressSet={!!checkout.shippingAddress}
444
+ taxAmount={checkout.taxAmount}
445
+ taxBreakdown={checkout.taxBreakdown}
446
+ />
447
+
448
+ <div className="border-border mt-2 border-t pt-2">
449
+ <div className="flex items-center justify-between">
450
+ <span className="text-foreground font-semibold">Total</span>
451
+ <span className="text-foreground text-base font-semibold">
452
+ {formatPrice(parseFloat(checkout.total), { currency }) as string}
453
+ </span>
454
+ </div>
455
+ </div>
456
+ </div>
457
+ )}
458
+ </div>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ );
463
+ }
464
+
465
+ export default function CheckoutPage() {
466
+ return (
467
+ <Suspense
468
+ fallback={
469
+ <div className="flex min-h-[60vh] items-center justify-center">
470
+ <LoadingSpinner size="lg" />
471
+ </div>
472
+ }
473
+ >
474
+ <CheckoutContent />
475
+ </Suspense>
476
+ );
477
+ }