create-brainerce-store 1.11.1 → 1.12.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.
- package/dist/index.js +1 -1
- package/messages/en.json +303 -302
- package/messages/he.json +303 -302
- package/package.json +45 -45
- package/templates/nextjs/base/next.config.ts +31 -31
- package/templates/nextjs/base/src/app/checkout/page.tsx +666 -666
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +426 -379
|
@@ -1,666 +1,666 @@
|
|
|
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
|
-
PickupLocation,
|
|
13
|
-
} from 'brainerce';
|
|
14
|
-
import { formatPrice } from 'brainerce';
|
|
15
|
-
import { getClient } from '@/lib/brainerce';
|
|
16
|
-
import { useStoreInfo, useCart } from '@/providers/store-provider';
|
|
17
|
-
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
18
|
-
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
19
|
-
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
20
|
-
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
21
|
-
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
22
|
-
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
23
|
-
import { CouponInput } from '@/components/cart/coupon-input';
|
|
24
|
-
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
25
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
26
|
-
import { useTranslations } from '@/lib/translations';
|
|
27
|
-
import { cn } from '@/lib/utils';
|
|
28
|
-
|
|
29
|
-
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
30
|
-
|
|
31
|
-
function CheckoutContent() {
|
|
32
|
-
const searchParams = useSearchParams();
|
|
33
|
-
const { storeInfo } = useStoreInfo();
|
|
34
|
-
const { cart, refreshCart } = useCart();
|
|
35
|
-
const currency = storeInfo?.currency || 'USD';
|
|
36
|
-
const t = useTranslations('checkout');
|
|
37
|
-
const tc = useTranslations('common');
|
|
38
|
-
|
|
39
|
-
const [step, setStep] = useState<CheckoutStep>('address');
|
|
40
|
-
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
41
|
-
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
42
|
-
const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
|
|
43
|
-
const [loading, setLoading] = useState(false);
|
|
44
|
-
const [initializing, setInitializing] = useState(true);
|
|
45
|
-
const [error, setError] = useState<string | null>(null);
|
|
46
|
-
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
47
|
-
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
48
|
-
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
49
|
-
|
|
50
|
-
// Check for returning from canceled payment
|
|
51
|
-
const canceled = searchParams.get('canceled') === 'true';
|
|
52
|
-
const existingCheckoutId = searchParams.get('checkout_id');
|
|
53
|
-
|
|
54
|
-
// Initialize or resume checkout
|
|
55
|
-
const initCheckout = useCallback(async () => {
|
|
56
|
-
try {
|
|
57
|
-
setInitializing(true);
|
|
58
|
-
setError(null);
|
|
59
|
-
const client = getClient();
|
|
60
|
-
|
|
61
|
-
// Fetch shipping destinations and pickup locations in parallel
|
|
62
|
-
client
|
|
63
|
-
.getShippingDestinations()
|
|
64
|
-
.then(setDestinations)
|
|
65
|
-
.catch(() => {});
|
|
66
|
-
|
|
67
|
-
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
68
|
-
setPickupLocations(locations);
|
|
69
|
-
|
|
70
|
-
// If returning with existing checkout ID, resume it
|
|
71
|
-
if (existingCheckoutId) {
|
|
72
|
-
const existing = await client.getCheckout(existingCheckoutId);
|
|
73
|
-
setCheckout(existing);
|
|
74
|
-
|
|
75
|
-
// Determine step based on checkout state
|
|
76
|
-
if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
77
|
-
setDeliveryType('pickup');
|
|
78
|
-
setStep('payment');
|
|
79
|
-
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
80
|
-
setStep('payment');
|
|
81
|
-
} else if (existing.shippingAddress) {
|
|
82
|
-
// Fetch shipping rates
|
|
83
|
-
const rates = await client.getShippingRates(existing.id);
|
|
84
|
-
setShippingRates(rates);
|
|
85
|
-
setStep('shipping');
|
|
86
|
-
} else if (locations.length > 0) {
|
|
87
|
-
setStep('method');
|
|
88
|
-
}
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Create new checkout — cart is always server-side now
|
|
93
|
-
if (cart && cart.id) {
|
|
94
|
-
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
95
|
-
setCheckout(newCheckout);
|
|
96
|
-
} else {
|
|
97
|
-
setError(t('cartIsEmpty'));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// If pickup locations exist, start with delivery method selection
|
|
101
|
-
if (locations.length > 0) {
|
|
102
|
-
setStep('method');
|
|
103
|
-
}
|
|
104
|
-
} catch (err) {
|
|
105
|
-
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
106
|
-
setError(message);
|
|
107
|
-
} finally {
|
|
108
|
-
setInitializing(false);
|
|
109
|
-
}
|
|
110
|
-
}, [existingCheckoutId, cart]);
|
|
111
|
-
|
|
112
|
-
const cartLoaded = cart !== null;
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (cartLoaded) {
|
|
115
|
-
initCheckout();
|
|
116
|
-
}
|
|
117
|
-
}, [cartLoaded, initCheckout]);
|
|
118
|
-
|
|
119
|
-
// Handle shipping address submission
|
|
120
|
-
async function handleAddressSubmit(address: SetShippingAddressDto) {
|
|
121
|
-
if (!checkout) return;
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
setLoading(true);
|
|
125
|
-
setError(null);
|
|
126
|
-
const client = getClient();
|
|
127
|
-
|
|
128
|
-
const response = await client.setShippingAddress(checkout.id, address);
|
|
129
|
-
setCheckout(response.checkout);
|
|
130
|
-
setShippingRates(response.rates);
|
|
131
|
-
setStep('shipping');
|
|
132
|
-
} catch (err) {
|
|
133
|
-
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
134
|
-
setError(message);
|
|
135
|
-
} finally {
|
|
136
|
-
setLoading(false);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Handle shipping method selection
|
|
141
|
-
async function handleShippingSelect(rateId: string) {
|
|
142
|
-
if (!checkout) return;
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
setLoading(true);
|
|
146
|
-
setError(null);
|
|
147
|
-
setSelectedRateId(rateId);
|
|
148
|
-
const client = getClient();
|
|
149
|
-
|
|
150
|
-
const updated = await client.selectShippingMethod(checkout.id, rateId);
|
|
151
|
-
setCheckout(updated);
|
|
152
|
-
setStep('payment');
|
|
153
|
-
} catch (err) {
|
|
154
|
-
const message = err instanceof Error ? err.message : t('failedToSelectShipping');
|
|
155
|
-
setError(message);
|
|
156
|
-
} finally {
|
|
157
|
-
setLoading(false);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Handle delivery method selection
|
|
162
|
-
async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
|
|
163
|
-
if (!checkout) return;
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
setLoading(true);
|
|
167
|
-
setError(null);
|
|
168
|
-
setDeliveryType(method);
|
|
169
|
-
const client = getClient();
|
|
170
|
-
|
|
171
|
-
await client.setDeliveryType(checkout.id, method);
|
|
172
|
-
|
|
173
|
-
if (method === 'shipping') {
|
|
174
|
-
setStep('address');
|
|
175
|
-
} else {
|
|
176
|
-
setStep('pickup');
|
|
177
|
-
}
|
|
178
|
-
} catch (err) {
|
|
179
|
-
const message = err instanceof Error ? err.message : t('failedToSetDeliveryMethod');
|
|
180
|
-
setError(message);
|
|
181
|
-
} finally {
|
|
182
|
-
setLoading(false);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Handle pickup location selection
|
|
187
|
-
async function handlePickupSelect(
|
|
188
|
-
locationId: string,
|
|
189
|
-
customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
|
|
190
|
-
) {
|
|
191
|
-
if (!checkout) return;
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
setLoading(true);
|
|
195
|
-
setError(null);
|
|
196
|
-
const client = getClient();
|
|
197
|
-
|
|
198
|
-
const updated = await client.selectPickupLocation(checkout.id, {
|
|
199
|
-
pickupRateId: locationId,
|
|
200
|
-
email: customerInfo.email,
|
|
201
|
-
firstName: customerInfo.firstName,
|
|
202
|
-
lastName: customerInfo.lastName,
|
|
203
|
-
phone: customerInfo.phone,
|
|
204
|
-
});
|
|
205
|
-
setCheckout(updated);
|
|
206
|
-
setStep('payment');
|
|
207
|
-
} catch (err) {
|
|
208
|
-
const message = err instanceof Error ? err.message : t('failedToSelectPickup');
|
|
209
|
-
setError(message);
|
|
210
|
-
} finally {
|
|
211
|
-
setLoading(false);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Refresh cart and checkout after coupon apply/remove
|
|
216
|
-
const handleCouponUpdate = useCallback(async () => {
|
|
217
|
-
await refreshCart();
|
|
218
|
-
if (checkout) {
|
|
219
|
-
try {
|
|
220
|
-
const client = getClient();
|
|
221
|
-
const updated = await client.getCheckout(checkout.id);
|
|
222
|
-
setCheckout(updated);
|
|
223
|
-
} catch (err) {
|
|
224
|
-
console.error('Failed to refresh checkout after coupon update:', err);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}, [checkout, refreshCart]);
|
|
228
|
-
|
|
229
|
-
if (initializing) {
|
|
230
|
-
return (
|
|
231
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
232
|
-
<LoadingSpinner size="lg" />
|
|
233
|
-
</div>
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Empty cart
|
|
238
|
-
if (!cart || cart.items.length === 0) {
|
|
239
|
-
return (
|
|
240
|
-
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
241
|
-
<h1 className="text-foreground text-2xl font-bold">{t('emptyCart')}</h1>
|
|
242
|
-
<p className="text-muted-foreground mt-2">{t('emptyCartSubtitle')}</p>
|
|
243
|
-
<Link
|
|
244
|
-
href="/products"
|
|
245
|
-
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
246
|
-
>
|
|
247
|
-
{tc('shopNow')}
|
|
248
|
-
</Link>
|
|
249
|
-
</div>
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (error && !checkout) {
|
|
254
|
-
return (
|
|
255
|
-
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
256
|
-
<h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
|
|
257
|
-
<p className="text-destructive mt-2">{error}</p>
|
|
258
|
-
<Link
|
|
259
|
-
href="/cart"
|
|
260
|
-
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
261
|
-
>
|
|
262
|
-
{t('returnToCart')}
|
|
263
|
-
</Link>
|
|
264
|
-
</div>
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const steps: { key: CheckoutStep; label: string }[] =
|
|
269
|
-
pickupLocations.length > 0
|
|
270
|
-
? deliveryType === 'pickup'
|
|
271
|
-
? [
|
|
272
|
-
{ key: 'method', label: t('stepMethod') },
|
|
273
|
-
{ key: 'pickup', label: t('stepPickup') },
|
|
274
|
-
{ key: 'payment', label: t('stepPayment') },
|
|
275
|
-
]
|
|
276
|
-
: [
|
|
277
|
-
{ key: 'method', label: t('stepMethod') },
|
|
278
|
-
{ key: 'address', label: t('stepAddress') },
|
|
279
|
-
{ key: 'shipping', label: t('stepShipping') },
|
|
280
|
-
{ key: 'payment', label: t('stepPayment') },
|
|
281
|
-
]
|
|
282
|
-
: [
|
|
283
|
-
{ key: 'address', label: t('stepAddress') },
|
|
284
|
-
{ key: 'shipping', label: t('stepShipping') },
|
|
285
|
-
{ key: 'payment', label: t('stepPayment') },
|
|
286
|
-
];
|
|
287
|
-
|
|
288
|
-
const currentStepIndex = steps.findIndex((s) => s.key === step);
|
|
289
|
-
|
|
290
|
-
return (
|
|
291
|
-
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
292
|
-
<h1 className="text-foreground mb-6 text-2xl font-bold">{t('title')}</h1>
|
|
293
|
-
|
|
294
|
-
{/* Canceled payment banner */}
|
|
295
|
-
{canceled && (
|
|
296
|
-
<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">
|
|
297
|
-
{t('paymentCanceledBanner')}
|
|
298
|
-
</div>
|
|
299
|
-
)}
|
|
300
|
-
|
|
301
|
-
{/* Reservation countdown */}
|
|
302
|
-
{checkout?.reservation?.hasReservation && (
|
|
303
|
-
<ReservationCountdown reservation={checkout.reservation} className="mb-6" />
|
|
304
|
-
)}
|
|
305
|
-
|
|
306
|
-
{/* Step indicator */}
|
|
307
|
-
<div className="mb-8 flex items-center gap-2">
|
|
308
|
-
{steps.map((s, index) => (
|
|
309
|
-
<div key={s.key} className="flex items-center">
|
|
310
|
-
{index > 0 && (
|
|
311
|
-
<div
|
|
312
|
-
className={cn(
|
|
313
|
-
'mx-2 h-px w-8 sm:w-12',
|
|
314
|
-
index <= currentStepIndex ? 'bg-primary' : 'bg-border'
|
|
315
|
-
)}
|
|
316
|
-
/>
|
|
317
|
-
)}
|
|
318
|
-
<div className="flex items-center gap-2">
|
|
319
|
-
<div
|
|
320
|
-
className={cn(
|
|
321
|
-
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
|
|
322
|
-
index < currentStepIndex
|
|
323
|
-
? 'bg-primary text-primary-foreground'
|
|
324
|
-
: index === currentStepIndex
|
|
325
|
-
? 'bg-primary text-primary-foreground'
|
|
326
|
-
: 'bg-muted text-muted-foreground'
|
|
327
|
-
)}
|
|
328
|
-
>
|
|
329
|
-
{index < currentStepIndex ? (
|
|
330
|
-
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
331
|
-
<path
|
|
332
|
-
strokeLinecap="round"
|
|
333
|
-
strokeLinejoin="round"
|
|
334
|
-
strokeWidth={2}
|
|
335
|
-
d="M5 13l4 4L19 7"
|
|
336
|
-
/>
|
|
337
|
-
</svg>
|
|
338
|
-
) : (
|
|
339
|
-
index + 1
|
|
340
|
-
)}
|
|
341
|
-
</div>
|
|
342
|
-
<span
|
|
343
|
-
className={cn(
|
|
344
|
-
'hidden text-sm sm:block',
|
|
345
|
-
index <= currentStepIndex
|
|
346
|
-
? 'text-foreground font-medium'
|
|
347
|
-
: 'text-muted-foreground'
|
|
348
|
-
)}
|
|
349
|
-
>
|
|
350
|
-
{s.label}
|
|
351
|
-
</span>
|
|
352
|
-
</div>
|
|
353
|
-
</div>
|
|
354
|
-
))}
|
|
355
|
-
</div>
|
|
356
|
-
|
|
357
|
-
{/* Error banner */}
|
|
358
|
-
{error && checkout && (
|
|
359
|
-
<div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
|
|
360
|
-
{error}
|
|
361
|
-
</div>
|
|
362
|
-
)}
|
|
363
|
-
|
|
364
|
-
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
365
|
-
{/* Main content */}
|
|
366
|
-
<div className="lg:col-span-2">
|
|
367
|
-
{/* Delivery Method */}
|
|
368
|
-
{step === 'method' && (
|
|
369
|
-
<div>
|
|
370
|
-
<h2 className="text-foreground mb-4 text-lg font-semibold">{t('deliveryMethod')}</h2>
|
|
371
|
-
<DeliveryMethodStep onSelect={handleDeliveryTypeSelect} />
|
|
372
|
-
</div>
|
|
373
|
-
)}
|
|
374
|
-
|
|
375
|
-
{/* Address */}
|
|
376
|
-
{step === 'address' && (
|
|
377
|
-
<div>
|
|
378
|
-
<div className="mb-4 flex items-center justify-between">
|
|
379
|
-
<h2 className="text-foreground text-lg font-semibold">{t('shippingAddress')}</h2>
|
|
380
|
-
{pickupLocations.length > 0 && (
|
|
381
|
-
<button
|
|
382
|
-
type="button"
|
|
383
|
-
onClick={() => setStep('method')}
|
|
384
|
-
className="text-primary text-sm hover:underline"
|
|
385
|
-
>
|
|
386
|
-
{t('changeMethod')}
|
|
387
|
-
</button>
|
|
388
|
-
)}
|
|
389
|
-
</div>
|
|
390
|
-
<CheckoutForm
|
|
391
|
-
onSubmit={handleAddressSubmit}
|
|
392
|
-
loading={loading}
|
|
393
|
-
destinations={destinations}
|
|
394
|
-
initialValues={
|
|
395
|
-
checkout?.shippingAddress
|
|
396
|
-
? {
|
|
397
|
-
email: checkout.email || '',
|
|
398
|
-
firstName: checkout.shippingAddress.firstName,
|
|
399
|
-
lastName: checkout.shippingAddress.lastName,
|
|
400
|
-
line1: checkout.shippingAddress.line1,
|
|
401
|
-
line2: checkout.shippingAddress.line2 || '',
|
|
402
|
-
city: checkout.shippingAddress.city,
|
|
403
|
-
region: checkout.shippingAddress.region || '',
|
|
404
|
-
postalCode: checkout.shippingAddress.postalCode,
|
|
405
|
-
country: checkout.shippingAddress.country,
|
|
406
|
-
phone: checkout.shippingAddress.phone || '',
|
|
407
|
-
}
|
|
408
|
-
: undefined
|
|
409
|
-
}
|
|
410
|
-
/>
|
|
411
|
-
</div>
|
|
412
|
-
)}
|
|
413
|
-
|
|
414
|
-
{/* Step 2: Shipping */}
|
|
415
|
-
{step === 'shipping' && (
|
|
416
|
-
<div>
|
|
417
|
-
<div className="mb-4 flex items-center justify-between">
|
|
418
|
-
<h2 className="text-foreground text-lg font-semibold">{t('shippingMethod')}</h2>
|
|
419
|
-
<button
|
|
420
|
-
type="button"
|
|
421
|
-
onClick={() => setStep('address')}
|
|
422
|
-
className="text-primary text-sm hover:underline"
|
|
423
|
-
>
|
|
424
|
-
{t('editAddress')}
|
|
425
|
-
</button>
|
|
426
|
-
</div>
|
|
427
|
-
|
|
428
|
-
<ShippingStep
|
|
429
|
-
rates={shippingRates}
|
|
430
|
-
selectedRateId={selectedRateId}
|
|
431
|
-
onSelect={handleShippingSelect}
|
|
432
|
-
loading={loading}
|
|
433
|
-
/>
|
|
434
|
-
</div>
|
|
435
|
-
)}
|
|
436
|
-
|
|
437
|
-
{/* Pickup */}
|
|
438
|
-
{step === 'pickup' && (
|
|
439
|
-
<div>
|
|
440
|
-
<div className="mb-4 flex items-center justify-between">
|
|
441
|
-
<h2 className="text-foreground text-lg font-semibold">{t('pickupLocation')}</h2>
|
|
442
|
-
<button
|
|
443
|
-
type="button"
|
|
444
|
-
onClick={() => setStep('method')}
|
|
445
|
-
className="text-primary text-sm hover:underline"
|
|
446
|
-
>
|
|
447
|
-
{t('changeMethod')}
|
|
448
|
-
</button>
|
|
449
|
-
</div>
|
|
450
|
-
<PickupStep
|
|
451
|
-
locations={pickupLocations}
|
|
452
|
-
onSelect={handlePickupSelect}
|
|
453
|
-
loading={loading}
|
|
454
|
-
initialEmail={checkout?.email || ''}
|
|
455
|
-
/>
|
|
456
|
-
</div>
|
|
457
|
-
)}
|
|
458
|
-
|
|
459
|
-
{/* Payment */}
|
|
460
|
-
{step === 'payment' && checkout && (
|
|
461
|
-
<div>
|
|
462
|
-
<div className="mb-4 flex items-center justify-between">
|
|
463
|
-
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
464
|
-
<button
|
|
465
|
-
type="button"
|
|
466
|
-
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
467
|
-
className="text-primary text-sm hover:underline"
|
|
468
|
-
>
|
|
469
|
-
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
470
|
-
</button>
|
|
471
|
-
</div>
|
|
472
|
-
|
|
473
|
-
<PaymentStep checkoutId={checkout.id} />
|
|
474
|
-
</div>
|
|
475
|
-
)}
|
|
476
|
-
</div>
|
|
477
|
-
|
|
478
|
-
{/* Order summary sidebar */}
|
|
479
|
-
<div className="lg:col-span-1">
|
|
480
|
-
<div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
|
|
481
|
-
<h3 className="text-foreground mb-4 text-lg font-semibold">{t('orderSummary')}</h3>
|
|
482
|
-
|
|
483
|
-
{/* Line items */}
|
|
484
|
-
{checkout?.lineItems && checkout.lineItems.length > 0 ? (
|
|
485
|
-
<div className="mb-4 space-y-3">
|
|
486
|
-
{checkout.lineItems.map((item) => {
|
|
487
|
-
const imageUrl = item.product.images?.[0]?.url || null;
|
|
488
|
-
const name = item.variant?.name || item.product.name;
|
|
489
|
-
const lineTotal = parseFloat(item.unitPrice) * item.quantity;
|
|
490
|
-
|
|
491
|
-
return (
|
|
492
|
-
<div key={item.id} className="flex gap-3">
|
|
493
|
-
<div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
|
|
494
|
-
{imageUrl ? (
|
|
495
|
-
<Image
|
|
496
|
-
src={imageUrl}
|
|
497
|
-
alt={name}
|
|
498
|
-
fill
|
|
499
|
-
sizes="48px"
|
|
500
|
-
className="object-cover"
|
|
501
|
-
/>
|
|
502
|
-
) : (
|
|
503
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
504
|
-
<svg
|
|
505
|
-
className="h-5 w-5"
|
|
506
|
-
fill="none"
|
|
507
|
-
viewBox="0 0 24 24"
|
|
508
|
-
stroke="currentColor"
|
|
509
|
-
>
|
|
510
|
-
<path
|
|
511
|
-
strokeLinecap="round"
|
|
512
|
-
strokeLinejoin="round"
|
|
513
|
-
strokeWidth={1.5}
|
|
514
|
-
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"
|
|
515
|
-
/>
|
|
516
|
-
</svg>
|
|
517
|
-
</div>
|
|
518
|
-
)}
|
|
519
|
-
</div>
|
|
520
|
-
|
|
521
|
-
<div className="min-w-0 flex-1">
|
|
522
|
-
<p className="text-foreground truncate text-sm">{name}</p>
|
|
523
|
-
<p className="text-muted-foreground text-xs">
|
|
524
|
-
{tc('qty')} {item.quantity}
|
|
525
|
-
</p>
|
|
526
|
-
</div>
|
|
527
|
-
|
|
528
|
-
<span className="text-foreground flex-shrink-0 text-sm font-medium">
|
|
529
|
-
{formatPrice(lineTotal, { currency }) as string}
|
|
530
|
-
</span>
|
|
531
|
-
</div>
|
|
532
|
-
);
|
|
533
|
-
})}
|
|
534
|
-
</div>
|
|
535
|
-
) : (
|
|
536
|
-
// Fallback to cart items if checkout line items aren't loaded yet
|
|
537
|
-
cart && (
|
|
538
|
-
<div className="mb-4 space-y-2">
|
|
539
|
-
<p className="text-muted-foreground text-sm">
|
|
540
|
-
{cart.items.length} {cart.items.length === 1 ? tc('item') : tc('items')}
|
|
541
|
-
</p>
|
|
542
|
-
</div>
|
|
543
|
-
)
|
|
544
|
-
)}
|
|
545
|
-
|
|
546
|
-
{/* Coupon input — show from shipping/pickup step onwards */}
|
|
547
|
-
{cart && (step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
548
|
-
<div className="border-border border-t pt-4">
|
|
549
|
-
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
550
|
-
</div>
|
|
551
|
-
)}
|
|
552
|
-
|
|
553
|
-
{/* Totals */}
|
|
554
|
-
{checkout && (
|
|
555
|
-
<div className="border-border space-y-2 border-t pt-4 text-sm">
|
|
556
|
-
<div className="flex items-center justify-between">
|
|
557
|
-
<span className="text-muted-foreground">{tc('subtotal')}</span>
|
|
558
|
-
<span className="text-foreground">
|
|
559
|
-
{formatPrice(parseFloat(checkout.subtotal), { currency }) as string}
|
|
560
|
-
</span>
|
|
561
|
-
</div>
|
|
562
|
-
|
|
563
|
-
{(() => {
|
|
564
|
-
const totalDiscount = parseFloat(checkout.discountAmount);
|
|
565
|
-
const ruleAmt = parseFloat(checkout.ruleDiscountAmount || '0');
|
|
566
|
-
const couponAmt = totalDiscount - ruleAmt;
|
|
567
|
-
const rules = cart?.appliedDiscounts;
|
|
568
|
-
if (totalDiscount <= 0) return null;
|
|
569
|
-
return (
|
|
570
|
-
<>
|
|
571
|
-
{rules && rules.length > 0
|
|
572
|
-
? rules.map((rule) => (
|
|
573
|
-
<div key={rule.ruleId} className="flex items-center justify-between">
|
|
574
|
-
<span className="text-muted-foreground">{rule.ruleName}</span>
|
|
575
|
-
<span className="text-destructive">
|
|
576
|
-
-
|
|
577
|
-
{
|
|
578
|
-
formatPrice(parseFloat(rule.discountAmount), {
|
|
579
|
-
currency,
|
|
580
|
-
}) as string
|
|
581
|
-
}
|
|
582
|
-
</span>
|
|
583
|
-
</div>
|
|
584
|
-
))
|
|
585
|
-
: ruleAmt > 0 && (
|
|
586
|
-
<div className="flex items-center justify-between">
|
|
587
|
-
<span className="text-muted-foreground">{tc('generalDiscount')}</span>
|
|
588
|
-
<span className="text-destructive">
|
|
589
|
-
-{formatPrice(ruleAmt, { currency }) as string}
|
|
590
|
-
</span>
|
|
591
|
-
</div>
|
|
592
|
-
)}
|
|
593
|
-
{checkout.couponCode && couponAmt > 0 && (
|
|
594
|
-
<div className="flex items-center justify-between">
|
|
595
|
-
<span className="text-muted-foreground">
|
|
596
|
-
{tc('couponDiscount')} ({checkout.couponCode})
|
|
597
|
-
</span>
|
|
598
|
-
<span className="text-destructive">
|
|
599
|
-
-{formatPrice(couponAmt, { currency }) as string}
|
|
600
|
-
</span>
|
|
601
|
-
</div>
|
|
602
|
-
)}
|
|
603
|
-
{!checkout.couponCode && ruleAmt <= 0 && (!rules || rules.length === 0) && (
|
|
604
|
-
<div className="flex items-center justify-between">
|
|
605
|
-
<span className="text-muted-foreground">{tc('discount')}</span>
|
|
606
|
-
<span className="text-destructive">
|
|
607
|
-
-{formatPrice(totalDiscount, { currency }) as string}
|
|
608
|
-
</span>
|
|
609
|
-
</div>
|
|
610
|
-
)}
|
|
611
|
-
</>
|
|
612
|
-
);
|
|
613
|
-
})()}
|
|
614
|
-
|
|
615
|
-
{(parseFloat(checkout.shippingAmount) > 0 ||
|
|
616
|
-
checkout.deliveryType === 'pickup') && (
|
|
617
|
-
<div className="flex items-center justify-between">
|
|
618
|
-
<span className="text-muted-foreground">
|
|
619
|
-
{checkout.deliveryType === 'pickup' ? tc('pickup') : tc('shipping')}
|
|
620
|
-
</span>
|
|
621
|
-
<span className="text-foreground">
|
|
622
|
-
{parseFloat(checkout.shippingAmount) === 0
|
|
623
|
-
? tc('free')
|
|
624
|
-
: (formatPrice(parseFloat(checkout.shippingAmount), {
|
|
625
|
-
currency,
|
|
626
|
-
}) as string)}
|
|
627
|
-
</span>
|
|
628
|
-
</div>
|
|
629
|
-
)}
|
|
630
|
-
|
|
631
|
-
<TaxDisplay
|
|
632
|
-
addressSet={!!checkout.shippingAddress}
|
|
633
|
-
taxAmount={checkout.taxAmount}
|
|
634
|
-
taxBreakdown={checkout.taxBreakdown}
|
|
635
|
-
/>
|
|
636
|
-
|
|
637
|
-
<div className="border-border mt-2 border-t pt-2">
|
|
638
|
-
<div className="flex items-center justify-between">
|
|
639
|
-
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
640
|
-
<span className="text-foreground text-base font-semibold">
|
|
641
|
-
{formatPrice(parseFloat(checkout.total), { currency }) as string}
|
|
642
|
-
</span>
|
|
643
|
-
</div>
|
|
644
|
-
</div>
|
|
645
|
-
</div>
|
|
646
|
-
)}
|
|
647
|
-
</div>
|
|
648
|
-
</div>
|
|
649
|
-
</div>
|
|
650
|
-
</div>
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
export default function CheckoutPage() {
|
|
655
|
-
return (
|
|
656
|
-
<Suspense
|
|
657
|
-
fallback={
|
|
658
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
659
|
-
<LoadingSpinner size="lg" />
|
|
660
|
-
</div>
|
|
661
|
-
}
|
|
662
|
-
>
|
|
663
|
-
<CheckoutContent />
|
|
664
|
-
</Suspense>
|
|
665
|
-
);
|
|
666
|
-
}
|
|
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
|
+
PickupLocation,
|
|
13
|
+
} from 'brainerce';
|
|
14
|
+
import { formatPrice } from 'brainerce';
|
|
15
|
+
import { getClient } from '@/lib/brainerce';
|
|
16
|
+
import { useStoreInfo, useCart } from '@/providers/store-provider';
|
|
17
|
+
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
18
|
+
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
19
|
+
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
20
|
+
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
21
|
+
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
22
|
+
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
23
|
+
import { CouponInput } from '@/components/cart/coupon-input';
|
|
24
|
+
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
25
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
26
|
+
import { useTranslations } from '@/lib/translations';
|
|
27
|
+
import { cn } from '@/lib/utils';
|
|
28
|
+
|
|
29
|
+
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
30
|
+
|
|
31
|
+
function CheckoutContent() {
|
|
32
|
+
const searchParams = useSearchParams();
|
|
33
|
+
const { storeInfo } = useStoreInfo();
|
|
34
|
+
const { cart, refreshCart } = useCart();
|
|
35
|
+
const currency = storeInfo?.currency || 'USD';
|
|
36
|
+
const t = useTranslations('checkout');
|
|
37
|
+
const tc = useTranslations('common');
|
|
38
|
+
|
|
39
|
+
const [step, setStep] = useState<CheckoutStep>('address');
|
|
40
|
+
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
41
|
+
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
42
|
+
const [selectedRateId, setSelectedRateId] = useState<string | null>(null);
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [initializing, setInitializing] = useState(true);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
47
|
+
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
48
|
+
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
49
|
+
|
|
50
|
+
// Check for returning from canceled payment
|
|
51
|
+
const canceled = searchParams.get('canceled') === 'true';
|
|
52
|
+
const existingCheckoutId = searchParams.get('checkout_id');
|
|
53
|
+
|
|
54
|
+
// Initialize or resume checkout
|
|
55
|
+
const initCheckout = useCallback(async () => {
|
|
56
|
+
try {
|
|
57
|
+
setInitializing(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
const client = getClient();
|
|
60
|
+
|
|
61
|
+
// Fetch shipping destinations and pickup locations in parallel
|
|
62
|
+
client
|
|
63
|
+
.getShippingDestinations()
|
|
64
|
+
.then(setDestinations)
|
|
65
|
+
.catch(() => {});
|
|
66
|
+
|
|
67
|
+
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
68
|
+
setPickupLocations(locations);
|
|
69
|
+
|
|
70
|
+
// If returning with existing checkout ID, resume it
|
|
71
|
+
if (existingCheckoutId) {
|
|
72
|
+
const existing = await client.getCheckout(existingCheckoutId);
|
|
73
|
+
setCheckout(existing);
|
|
74
|
+
|
|
75
|
+
// Determine step based on checkout state
|
|
76
|
+
if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
77
|
+
setDeliveryType('pickup');
|
|
78
|
+
setStep('payment');
|
|
79
|
+
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
80
|
+
setStep('payment');
|
|
81
|
+
} else if (existing.shippingAddress) {
|
|
82
|
+
// Fetch shipping rates
|
|
83
|
+
const rates = await client.getShippingRates(existing.id);
|
|
84
|
+
setShippingRates(rates);
|
|
85
|
+
setStep('shipping');
|
|
86
|
+
} else if (locations.length > 0) {
|
|
87
|
+
setStep('method');
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Create new checkout — cart is always server-side now
|
|
93
|
+
if (cart && cart.id) {
|
|
94
|
+
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
95
|
+
setCheckout(newCheckout);
|
|
96
|
+
} else {
|
|
97
|
+
setError(t('cartIsEmpty'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If pickup locations exist, start with delivery method selection
|
|
101
|
+
if (locations.length > 0) {
|
|
102
|
+
setStep('method');
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
106
|
+
setError(message);
|
|
107
|
+
} finally {
|
|
108
|
+
setInitializing(false);
|
|
109
|
+
}
|
|
110
|
+
}, [existingCheckoutId, cart]);
|
|
111
|
+
|
|
112
|
+
const cartLoaded = cart !== null;
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (cartLoaded) {
|
|
115
|
+
initCheckout();
|
|
116
|
+
}
|
|
117
|
+
}, [cartLoaded, initCheckout]);
|
|
118
|
+
|
|
119
|
+
// Handle shipping address submission
|
|
120
|
+
async function handleAddressSubmit(address: SetShippingAddressDto) {
|
|
121
|
+
if (!checkout) return;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
setLoading(true);
|
|
125
|
+
setError(null);
|
|
126
|
+
const client = getClient();
|
|
127
|
+
|
|
128
|
+
const response = await client.setShippingAddress(checkout.id, address);
|
|
129
|
+
setCheckout(response.checkout);
|
|
130
|
+
setShippingRates(response.rates);
|
|
131
|
+
setStep('shipping');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
134
|
+
setError(message);
|
|
135
|
+
} finally {
|
|
136
|
+
setLoading(false);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle shipping method selection
|
|
141
|
+
async function handleShippingSelect(rateId: string) {
|
|
142
|
+
if (!checkout) return;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
setLoading(true);
|
|
146
|
+
setError(null);
|
|
147
|
+
setSelectedRateId(rateId);
|
|
148
|
+
const client = getClient();
|
|
149
|
+
|
|
150
|
+
const updated = await client.selectShippingMethod(checkout.id, rateId);
|
|
151
|
+
setCheckout(updated);
|
|
152
|
+
setStep('payment');
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const message = err instanceof Error ? err.message : t('failedToSelectShipping');
|
|
155
|
+
setError(message);
|
|
156
|
+
} finally {
|
|
157
|
+
setLoading(false);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Handle delivery method selection
|
|
162
|
+
async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
|
|
163
|
+
if (!checkout) return;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
setLoading(true);
|
|
167
|
+
setError(null);
|
|
168
|
+
setDeliveryType(method);
|
|
169
|
+
const client = getClient();
|
|
170
|
+
|
|
171
|
+
await client.setDeliveryType(checkout.id, method);
|
|
172
|
+
|
|
173
|
+
if (method === 'shipping') {
|
|
174
|
+
setStep('address');
|
|
175
|
+
} else {
|
|
176
|
+
setStep('pickup');
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const message = err instanceof Error ? err.message : t('failedToSetDeliveryMethod');
|
|
180
|
+
setError(message);
|
|
181
|
+
} finally {
|
|
182
|
+
setLoading(false);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle pickup location selection
|
|
187
|
+
async function handlePickupSelect(
|
|
188
|
+
locationId: string,
|
|
189
|
+
customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
|
|
190
|
+
) {
|
|
191
|
+
if (!checkout) return;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
setLoading(true);
|
|
195
|
+
setError(null);
|
|
196
|
+
const client = getClient();
|
|
197
|
+
|
|
198
|
+
const updated = await client.selectPickupLocation(checkout.id, {
|
|
199
|
+
pickupRateId: locationId,
|
|
200
|
+
email: customerInfo.email,
|
|
201
|
+
firstName: customerInfo.firstName,
|
|
202
|
+
lastName: customerInfo.lastName,
|
|
203
|
+
phone: customerInfo.phone,
|
|
204
|
+
});
|
|
205
|
+
setCheckout(updated);
|
|
206
|
+
setStep('payment');
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const message = err instanceof Error ? err.message : t('failedToSelectPickup');
|
|
209
|
+
setError(message);
|
|
210
|
+
} finally {
|
|
211
|
+
setLoading(false);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Refresh cart and checkout after coupon apply/remove
|
|
216
|
+
const handleCouponUpdate = useCallback(async () => {
|
|
217
|
+
await refreshCart();
|
|
218
|
+
if (checkout) {
|
|
219
|
+
try {
|
|
220
|
+
const client = getClient();
|
|
221
|
+
const updated = await client.getCheckout(checkout.id);
|
|
222
|
+
setCheckout(updated);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error('Failed to refresh checkout after coupon update:', err);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}, [checkout, refreshCart]);
|
|
228
|
+
|
|
229
|
+
if (initializing) {
|
|
230
|
+
return (
|
|
231
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
232
|
+
<LoadingSpinner size="lg" />
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Empty cart
|
|
238
|
+
if (!cart || cart.items.length === 0) {
|
|
239
|
+
return (
|
|
240
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
241
|
+
<h1 className="text-foreground text-2xl font-bold">{t('emptyCart')}</h1>
|
|
242
|
+
<p className="text-muted-foreground mt-2">{t('emptyCartSubtitle')}</p>
|
|
243
|
+
<Link
|
|
244
|
+
href="/products"
|
|
245
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
246
|
+
>
|
|
247
|
+
{tc('shopNow')}
|
|
248
|
+
</Link>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (error && !checkout) {
|
|
254
|
+
return (
|
|
255
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
256
|
+
<h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
|
|
257
|
+
<p className="text-destructive mt-2">{error}</p>
|
|
258
|
+
<Link
|
|
259
|
+
href="/cart"
|
|
260
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
261
|
+
>
|
|
262
|
+
{t('returnToCart')}
|
|
263
|
+
</Link>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const steps: { key: CheckoutStep; label: string }[] =
|
|
269
|
+
pickupLocations.length > 0
|
|
270
|
+
? deliveryType === 'pickup'
|
|
271
|
+
? [
|
|
272
|
+
{ key: 'method', label: t('stepMethod') },
|
|
273
|
+
{ key: 'pickup', label: t('stepPickup') },
|
|
274
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
275
|
+
]
|
|
276
|
+
: [
|
|
277
|
+
{ key: 'method', label: t('stepMethod') },
|
|
278
|
+
{ key: 'address', label: t('stepAddress') },
|
|
279
|
+
{ key: 'shipping', label: t('stepShipping') },
|
|
280
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
281
|
+
]
|
|
282
|
+
: [
|
|
283
|
+
{ key: 'address', label: t('stepAddress') },
|
|
284
|
+
{ key: 'shipping', label: t('stepShipping') },
|
|
285
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
const currentStepIndex = steps.findIndex((s) => s.key === step);
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
292
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">{t('title')}</h1>
|
|
293
|
+
|
|
294
|
+
{/* Canceled payment banner */}
|
|
295
|
+
{canceled && (
|
|
296
|
+
<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">
|
|
297
|
+
{t('paymentCanceledBanner')}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{/* Reservation countdown */}
|
|
302
|
+
{checkout?.reservation?.hasReservation && (
|
|
303
|
+
<ReservationCountdown reservation={checkout.reservation} className="mb-6" />
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{/* Step indicator */}
|
|
307
|
+
<div className="mb-8 flex items-center gap-2">
|
|
308
|
+
{steps.map((s, index) => (
|
|
309
|
+
<div key={s.key} className="flex items-center">
|
|
310
|
+
{index > 0 && (
|
|
311
|
+
<div
|
|
312
|
+
className={cn(
|
|
313
|
+
'mx-2 h-px w-8 sm:w-12',
|
|
314
|
+
index <= currentStepIndex ? 'bg-primary' : 'bg-border'
|
|
315
|
+
)}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
318
|
+
<div className="flex items-center gap-2">
|
|
319
|
+
<div
|
|
320
|
+
className={cn(
|
|
321
|
+
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium',
|
|
322
|
+
index < currentStepIndex
|
|
323
|
+
? 'bg-primary text-primary-foreground'
|
|
324
|
+
: index === currentStepIndex
|
|
325
|
+
? 'bg-primary text-primary-foreground'
|
|
326
|
+
: 'bg-muted text-muted-foreground'
|
|
327
|
+
)}
|
|
328
|
+
>
|
|
329
|
+
{index < currentStepIndex ? (
|
|
330
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
331
|
+
<path
|
|
332
|
+
strokeLinecap="round"
|
|
333
|
+
strokeLinejoin="round"
|
|
334
|
+
strokeWidth={2}
|
|
335
|
+
d="M5 13l4 4L19 7"
|
|
336
|
+
/>
|
|
337
|
+
</svg>
|
|
338
|
+
) : (
|
|
339
|
+
index + 1
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
<span
|
|
343
|
+
className={cn(
|
|
344
|
+
'hidden text-sm sm:block',
|
|
345
|
+
index <= currentStepIndex
|
|
346
|
+
? 'text-foreground font-medium'
|
|
347
|
+
: 'text-muted-foreground'
|
|
348
|
+
)}
|
|
349
|
+
>
|
|
350
|
+
{s.label}
|
|
351
|
+
</span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{/* Error banner */}
|
|
358
|
+
{error && checkout && (
|
|
359
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
|
|
360
|
+
{error}
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
365
|
+
{/* Main content */}
|
|
366
|
+
<div className="lg:col-span-2">
|
|
367
|
+
{/* Delivery Method */}
|
|
368
|
+
{step === 'method' && (
|
|
369
|
+
<div>
|
|
370
|
+
<h2 className="text-foreground mb-4 text-lg font-semibold">{t('deliveryMethod')}</h2>
|
|
371
|
+
<DeliveryMethodStep onSelect={handleDeliveryTypeSelect} />
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Address */}
|
|
376
|
+
{step === 'address' && (
|
|
377
|
+
<div>
|
|
378
|
+
<div className="mb-4 flex items-center justify-between">
|
|
379
|
+
<h2 className="text-foreground text-lg font-semibold">{t('shippingAddress')}</h2>
|
|
380
|
+
{pickupLocations.length > 0 && (
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
onClick={() => setStep('method')}
|
|
384
|
+
className="text-primary text-sm hover:underline"
|
|
385
|
+
>
|
|
386
|
+
{t('changeMethod')}
|
|
387
|
+
</button>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
<CheckoutForm
|
|
391
|
+
onSubmit={handleAddressSubmit}
|
|
392
|
+
loading={loading}
|
|
393
|
+
destinations={destinations}
|
|
394
|
+
initialValues={
|
|
395
|
+
checkout?.shippingAddress
|
|
396
|
+
? {
|
|
397
|
+
email: checkout.email || '',
|
|
398
|
+
firstName: checkout.shippingAddress.firstName,
|
|
399
|
+
lastName: checkout.shippingAddress.lastName,
|
|
400
|
+
line1: checkout.shippingAddress.line1,
|
|
401
|
+
line2: checkout.shippingAddress.line2 || '',
|
|
402
|
+
city: checkout.shippingAddress.city,
|
|
403
|
+
region: checkout.shippingAddress.region || '',
|
|
404
|
+
postalCode: checkout.shippingAddress.postalCode,
|
|
405
|
+
country: checkout.shippingAddress.country,
|
|
406
|
+
phone: checkout.shippingAddress.phone || '',
|
|
407
|
+
}
|
|
408
|
+
: undefined
|
|
409
|
+
}
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{/* Step 2: Shipping */}
|
|
415
|
+
{step === 'shipping' && (
|
|
416
|
+
<div>
|
|
417
|
+
<div className="mb-4 flex items-center justify-between">
|
|
418
|
+
<h2 className="text-foreground text-lg font-semibold">{t('shippingMethod')}</h2>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
onClick={() => setStep('address')}
|
|
422
|
+
className="text-primary text-sm hover:underline"
|
|
423
|
+
>
|
|
424
|
+
{t('editAddress')}
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<ShippingStep
|
|
429
|
+
rates={shippingRates}
|
|
430
|
+
selectedRateId={selectedRateId}
|
|
431
|
+
onSelect={handleShippingSelect}
|
|
432
|
+
loading={loading}
|
|
433
|
+
/>
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
{/* Pickup */}
|
|
438
|
+
{step === 'pickup' && (
|
|
439
|
+
<div>
|
|
440
|
+
<div className="mb-4 flex items-center justify-between">
|
|
441
|
+
<h2 className="text-foreground text-lg font-semibold">{t('pickupLocation')}</h2>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
onClick={() => setStep('method')}
|
|
445
|
+
className="text-primary text-sm hover:underline"
|
|
446
|
+
>
|
|
447
|
+
{t('changeMethod')}
|
|
448
|
+
</button>
|
|
449
|
+
</div>
|
|
450
|
+
<PickupStep
|
|
451
|
+
locations={pickupLocations}
|
|
452
|
+
onSelect={handlePickupSelect}
|
|
453
|
+
loading={loading}
|
|
454
|
+
initialEmail={checkout?.email || ''}
|
|
455
|
+
/>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{/* Payment */}
|
|
460
|
+
{step === 'payment' && checkout && (
|
|
461
|
+
<div>
|
|
462
|
+
<div className="mb-4 flex items-center justify-between">
|
|
463
|
+
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
467
|
+
className="text-primary text-sm hover:underline"
|
|
468
|
+
>
|
|
469
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
<PaymentStep checkoutId={checkout.id} />
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
{/* Order summary sidebar */}
|
|
479
|
+
<div className="lg:col-span-1">
|
|
480
|
+
<div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
|
|
481
|
+
<h3 className="text-foreground mb-4 text-lg font-semibold">{t('orderSummary')}</h3>
|
|
482
|
+
|
|
483
|
+
{/* Line items */}
|
|
484
|
+
{checkout?.lineItems && checkout.lineItems.length > 0 ? (
|
|
485
|
+
<div className="mb-4 space-y-3">
|
|
486
|
+
{checkout.lineItems.map((item) => {
|
|
487
|
+
const imageUrl = item.product.images?.[0]?.url || null;
|
|
488
|
+
const name = item.variant?.name || item.product.name;
|
|
489
|
+
const lineTotal = parseFloat(item.unitPrice) * item.quantity;
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<div key={item.id} className="flex gap-3">
|
|
493
|
+
<div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
|
|
494
|
+
{imageUrl ? (
|
|
495
|
+
<Image
|
|
496
|
+
src={imageUrl}
|
|
497
|
+
alt={name}
|
|
498
|
+
fill
|
|
499
|
+
sizes="48px"
|
|
500
|
+
className="object-cover"
|
|
501
|
+
/>
|
|
502
|
+
) : (
|
|
503
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
504
|
+
<svg
|
|
505
|
+
className="h-5 w-5"
|
|
506
|
+
fill="none"
|
|
507
|
+
viewBox="0 0 24 24"
|
|
508
|
+
stroke="currentColor"
|
|
509
|
+
>
|
|
510
|
+
<path
|
|
511
|
+
strokeLinecap="round"
|
|
512
|
+
strokeLinejoin="round"
|
|
513
|
+
strokeWidth={1.5}
|
|
514
|
+
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"
|
|
515
|
+
/>
|
|
516
|
+
</svg>
|
|
517
|
+
</div>
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
|
|
521
|
+
<div className="min-w-0 flex-1">
|
|
522
|
+
<p className="text-foreground truncate text-sm">{name}</p>
|
|
523
|
+
<p className="text-muted-foreground text-xs">
|
|
524
|
+
{tc('qty')} {item.quantity}
|
|
525
|
+
</p>
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
<span className="text-foreground flex-shrink-0 text-sm font-medium">
|
|
529
|
+
{formatPrice(lineTotal, { currency }) as string}
|
|
530
|
+
</span>
|
|
531
|
+
</div>
|
|
532
|
+
);
|
|
533
|
+
})}
|
|
534
|
+
</div>
|
|
535
|
+
) : (
|
|
536
|
+
// Fallback to cart items if checkout line items aren't loaded yet
|
|
537
|
+
cart && (
|
|
538
|
+
<div className="mb-4 space-y-2">
|
|
539
|
+
<p className="text-muted-foreground text-sm">
|
|
540
|
+
{cart.items.length} {cart.items.length === 1 ? tc('item') : tc('items')}
|
|
541
|
+
</p>
|
|
542
|
+
</div>
|
|
543
|
+
)
|
|
544
|
+
)}
|
|
545
|
+
|
|
546
|
+
{/* Coupon input — show from shipping/pickup step onwards */}
|
|
547
|
+
{cart && (step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
548
|
+
<div className="border-border border-t pt-4">
|
|
549
|
+
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
{/* Totals */}
|
|
554
|
+
{checkout && (
|
|
555
|
+
<div className="border-border space-y-2 border-t pt-4 text-sm">
|
|
556
|
+
<div className="flex items-center justify-between">
|
|
557
|
+
<span className="text-muted-foreground">{tc('subtotal')}</span>
|
|
558
|
+
<span className="text-foreground">
|
|
559
|
+
{formatPrice(parseFloat(checkout.subtotal), { currency }) as string}
|
|
560
|
+
</span>
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
{(() => {
|
|
564
|
+
const totalDiscount = parseFloat(checkout.discountAmount);
|
|
565
|
+
const ruleAmt = parseFloat(checkout.ruleDiscountAmount || '0');
|
|
566
|
+
const couponAmt = totalDiscount - ruleAmt;
|
|
567
|
+
const rules = cart?.appliedDiscounts;
|
|
568
|
+
if (totalDiscount <= 0) return null;
|
|
569
|
+
return (
|
|
570
|
+
<>
|
|
571
|
+
{rules && rules.length > 0
|
|
572
|
+
? rules.map((rule) => (
|
|
573
|
+
<div key={rule.ruleId} className="flex items-center justify-between">
|
|
574
|
+
<span className="text-muted-foreground">{rule.ruleName}</span>
|
|
575
|
+
<span className="text-destructive">
|
|
576
|
+
-
|
|
577
|
+
{
|
|
578
|
+
formatPrice(parseFloat(rule.discountAmount), {
|
|
579
|
+
currency,
|
|
580
|
+
}) as string
|
|
581
|
+
}
|
|
582
|
+
</span>
|
|
583
|
+
</div>
|
|
584
|
+
))
|
|
585
|
+
: ruleAmt > 0 && (
|
|
586
|
+
<div className="flex items-center justify-between">
|
|
587
|
+
<span className="text-muted-foreground">{tc('generalDiscount')}</span>
|
|
588
|
+
<span className="text-destructive">
|
|
589
|
+
-{formatPrice(ruleAmt, { currency }) as string}
|
|
590
|
+
</span>
|
|
591
|
+
</div>
|
|
592
|
+
)}
|
|
593
|
+
{checkout.couponCode && couponAmt > 0 && (
|
|
594
|
+
<div className="flex items-center justify-between">
|
|
595
|
+
<span className="text-muted-foreground">
|
|
596
|
+
{tc('couponDiscount')} ({checkout.couponCode})
|
|
597
|
+
</span>
|
|
598
|
+
<span className="text-destructive">
|
|
599
|
+
-{formatPrice(couponAmt, { currency }) as string}
|
|
600
|
+
</span>
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
{!checkout.couponCode && ruleAmt <= 0 && (!rules || rules.length === 0) && (
|
|
604
|
+
<div className="flex items-center justify-between">
|
|
605
|
+
<span className="text-muted-foreground">{tc('discount')}</span>
|
|
606
|
+
<span className="text-destructive">
|
|
607
|
+
-{formatPrice(totalDiscount, { currency }) as string}
|
|
608
|
+
</span>
|
|
609
|
+
</div>
|
|
610
|
+
)}
|
|
611
|
+
</>
|
|
612
|
+
);
|
|
613
|
+
})()}
|
|
614
|
+
|
|
615
|
+
{(parseFloat(checkout.shippingAmount) > 0 ||
|
|
616
|
+
checkout.deliveryType === 'pickup') && (
|
|
617
|
+
<div className="flex items-center justify-between">
|
|
618
|
+
<span className="text-muted-foreground">
|
|
619
|
+
{checkout.deliveryType === 'pickup' ? tc('pickup') : tc('shipping')}
|
|
620
|
+
</span>
|
|
621
|
+
<span className="text-foreground">
|
|
622
|
+
{parseFloat(checkout.shippingAmount) === 0
|
|
623
|
+
? tc('free')
|
|
624
|
+
: (formatPrice(parseFloat(checkout.shippingAmount), {
|
|
625
|
+
currency,
|
|
626
|
+
}) as string)}
|
|
627
|
+
</span>
|
|
628
|
+
</div>
|
|
629
|
+
)}
|
|
630
|
+
|
|
631
|
+
<TaxDisplay
|
|
632
|
+
addressSet={!!checkout.shippingAddress}
|
|
633
|
+
taxAmount={checkout.taxAmount}
|
|
634
|
+
taxBreakdown={checkout.taxBreakdown}
|
|
635
|
+
/>
|
|
636
|
+
|
|
637
|
+
<div className="border-border mt-2 border-t pt-2">
|
|
638
|
+
<div className="flex items-center justify-between">
|
|
639
|
+
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
640
|
+
<span className="text-foreground text-base font-semibold">
|
|
641
|
+
{formatPrice(parseFloat(checkout.total), { currency }) as string}
|
|
642
|
+
</span>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
)}
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export default function CheckoutPage() {
|
|
655
|
+
return (
|
|
656
|
+
<Suspense
|
|
657
|
+
fallback={
|
|
658
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
659
|
+
<LoadingSpinner size="lg" />
|
|
660
|
+
</div>
|
|
661
|
+
}
|
|
662
|
+
>
|
|
663
|
+
<CheckoutContent />
|
|
664
|
+
</Suspense>
|
|
665
|
+
);
|
|
666
|
+
}
|