create-brainerce-store 1.28.13 → 1.28.17
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 +390 -389
- package/messages/he.json +390 -389
- package/package.json +46 -46
- package/templates/nextjs/base/next.config.ts +47 -47
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -15
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -66
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -76
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +235 -229
- package/templates/nextjs/base/src/app/checkout/page.tsx +975 -975
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +73 -76
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +529 -501
- package/templates/nextjs/base/src/app/products/page.tsx +475 -482
- package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -138
- package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -245
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -416
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +656 -656
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +88 -88
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +6 -2
- package/templates/nextjs/base/src/lib/csrf.ts +11 -11
- package/templates/nextjs/base/src/lib/nonce.ts +10 -10
- package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -45
- package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -93
- package/templates/nextjs/base/src/lib/validation.ts +37 -37
|
@@ -1,416 +1,416 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useRef } from 'react';
|
|
4
|
-
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/lib/translations';
|
|
6
|
-
import { cn } from '@/lib/utils';
|
|
7
|
-
import { isValidEmail } from '@/lib/validation';
|
|
8
|
-
|
|
9
|
-
interface CheckoutConsent {
|
|
10
|
-
acceptsMarketing: boolean;
|
|
11
|
-
saveDetails: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface CheckoutFormProps {
|
|
15
|
-
onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
|
|
16
|
-
loading?: boolean;
|
|
17
|
-
initialValues?: Partial<SetShippingAddressDto>;
|
|
18
|
-
destinations?: ShippingDestinations | null;
|
|
19
|
-
className?: string;
|
|
20
|
-
showSaveDetails?: boolean;
|
|
21
|
-
emailOnly?: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function CheckoutForm({
|
|
25
|
-
onSubmit,
|
|
26
|
-
loading = false,
|
|
27
|
-
initialValues,
|
|
28
|
-
destinations,
|
|
29
|
-
className,
|
|
30
|
-
showSaveDetails = false,
|
|
31
|
-
emailOnly = false,
|
|
32
|
-
}: CheckoutFormProps) {
|
|
33
|
-
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
34
|
-
email: initialValues?.email || '',
|
|
35
|
-
firstName: initialValues?.firstName || '',
|
|
36
|
-
lastName: initialValues?.lastName || '',
|
|
37
|
-
line1: initialValues?.line1 || '',
|
|
38
|
-
line2: initialValues?.line2 || '',
|
|
39
|
-
city: initialValues?.city || '',
|
|
40
|
-
region: initialValues?.region || '',
|
|
41
|
-
postalCode: initialValues?.postalCode || '',
|
|
42
|
-
country: initialValues?.country || '',
|
|
43
|
-
phone: initialValues?.phone || '',
|
|
44
|
-
});
|
|
45
|
-
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
46
|
-
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
47
|
-
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
48
|
-
const [saveDetails, setSaveDetails] = useState(true);
|
|
49
|
-
const t = useTranslations('checkoutForm');
|
|
50
|
-
const tc = useTranslations('common');
|
|
51
|
-
const hasAppliedPrefill = useRef(!!initialValues);
|
|
52
|
-
|
|
53
|
-
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!initialValues || hasAppliedPrefill.current) return;
|
|
56
|
-
hasAppliedPrefill.current = true;
|
|
57
|
-
setFormData((prev) => ({
|
|
58
|
-
email: initialValues.email || prev.email,
|
|
59
|
-
firstName: initialValues.firstName || prev.firstName,
|
|
60
|
-
lastName: initialValues.lastName || prev.lastName,
|
|
61
|
-
line1: initialValues.line1 || prev.line1,
|
|
62
|
-
line2: initialValues.line2 || prev.line2 || '',
|
|
63
|
-
city: initialValues.city || prev.city,
|
|
64
|
-
region: initialValues.region || prev.region || '',
|
|
65
|
-
postalCode: initialValues.postalCode || prev.postalCode,
|
|
66
|
-
country: initialValues.country || prev.country,
|
|
67
|
-
phone: initialValues.phone || prev.phone || '',
|
|
68
|
-
}));
|
|
69
|
-
}, [initialValues]);
|
|
70
|
-
|
|
71
|
-
const hasCountryOptions = destinations && destinations.countries.length > 0;
|
|
72
|
-
const countryRegions = destinations?.regions[formData.country];
|
|
73
|
-
const hasRegionOptions = countryRegions && countryRegions.length > 0;
|
|
74
|
-
|
|
75
|
-
function validate(): boolean {
|
|
76
|
-
const newErrors: Record<string, string> = {};
|
|
77
|
-
|
|
78
|
-
if (!formData.email.trim()) {
|
|
79
|
-
newErrors.email = t('emailRequired');
|
|
80
|
-
} else if (!isValidEmail(formData.email.trim())) {
|
|
81
|
-
newErrors.email = t('emailInvalid');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!formData.firstName.trim()) {
|
|
85
|
-
newErrors.firstName = t('firstNameRequired');
|
|
86
|
-
}
|
|
87
|
-
if (!formData.lastName.trim()) {
|
|
88
|
-
newErrors.lastName = t('lastNameRequired');
|
|
89
|
-
}
|
|
90
|
-
if (!emailOnly) {
|
|
91
|
-
if (!formData.line1.trim()) {
|
|
92
|
-
newErrors.line1 = t('addressRequired');
|
|
93
|
-
}
|
|
94
|
-
if (!formData.city.trim()) {
|
|
95
|
-
newErrors.city = t('cityRequired');
|
|
96
|
-
}
|
|
97
|
-
if (!formData.postalCode.trim()) {
|
|
98
|
-
newErrors.postalCode = t('postalCodeRequired');
|
|
99
|
-
}
|
|
100
|
-
if (!formData.country.trim()) {
|
|
101
|
-
newErrors.country = t('countryRequired');
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (!privacyAccepted) {
|
|
105
|
-
newErrors.privacy = t('privacyRequired');
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
setErrors(newErrors);
|
|
109
|
-
return Object.keys(newErrors).length === 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function handleSubmit(e: React.FormEvent) {
|
|
113
|
-
e.preventDefault();
|
|
114
|
-
if (validate()) {
|
|
115
|
-
onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function updateField(field: keyof SetShippingAddressDto, value: string) {
|
|
120
|
-
setFormData((prev) => {
|
|
121
|
-
const next = { ...prev, [field]: value };
|
|
122
|
-
// Reset region when country changes
|
|
123
|
-
if (field === 'country' && value !== prev.country) {
|
|
124
|
-
next.region = '';
|
|
125
|
-
}
|
|
126
|
-
return next;
|
|
127
|
-
});
|
|
128
|
-
if (errors[field]) {
|
|
129
|
-
setErrors((prev) => {
|
|
130
|
-
const next = { ...prev };
|
|
131
|
-
delete next[field];
|
|
132
|
-
return next;
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const inputClass =
|
|
138
|
-
'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
|
|
139
|
-
const selectClass =
|
|
140
|
-
'bg-background text-foreground focus:ring-primary/20 focus:border-primary h-10 w-full appearance-none rounded border px-3 text-sm focus:outline-none focus:ring-2';
|
|
141
|
-
|
|
142
|
-
return (
|
|
143
|
-
<form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
|
|
144
|
-
{/* Email */}
|
|
145
|
-
<div>
|
|
146
|
-
<label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
|
|
147
|
-
{t('email')} <span className="text-destructive">*</span>
|
|
148
|
-
</label>
|
|
149
|
-
<input
|
|
150
|
-
id="email"
|
|
151
|
-
type="email"
|
|
152
|
-
value={formData.email}
|
|
153
|
-
onChange={(e) => updateField('email', e.target.value)}
|
|
154
|
-
className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')}
|
|
155
|
-
placeholder="your@email.com"
|
|
156
|
-
/>
|
|
157
|
-
{errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
|
|
158
|
-
</div>
|
|
159
|
-
|
|
160
|
-
{/* Name row */}
|
|
161
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
162
|
-
<div>
|
|
163
|
-
<label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
|
|
164
|
-
{t('firstName')} <span className="text-destructive">*</span>
|
|
165
|
-
</label>
|
|
166
|
-
<input
|
|
167
|
-
id="firstName"
|
|
168
|
-
type="text"
|
|
169
|
-
value={formData.firstName}
|
|
170
|
-
onChange={(e) => updateField('firstName', e.target.value)}
|
|
171
|
-
className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')}
|
|
172
|
-
/>
|
|
173
|
-
{errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
|
|
174
|
-
</div>
|
|
175
|
-
|
|
176
|
-
<div>
|
|
177
|
-
<label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
|
|
178
|
-
{t('lastName')} <span className="text-destructive">*</span>
|
|
179
|
-
</label>
|
|
180
|
-
<input
|
|
181
|
-
id="lastName"
|
|
182
|
-
type="text"
|
|
183
|
-
value={formData.lastName}
|
|
184
|
-
onChange={(e) => updateField('lastName', e.target.value)}
|
|
185
|
-
className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')}
|
|
186
|
-
/>
|
|
187
|
-
{errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
|
|
188
|
-
</div>
|
|
189
|
-
</div>
|
|
190
|
-
|
|
191
|
-
{!emailOnly && (
|
|
192
|
-
<>
|
|
193
|
-
{/* Country + Region row */}
|
|
194
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
195
|
-
<div>
|
|
196
|
-
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
197
|
-
{t('country')} <span className="text-destructive">*</span>
|
|
198
|
-
</label>
|
|
199
|
-
{hasCountryOptions ? (
|
|
200
|
-
<select
|
|
201
|
-
id="country"
|
|
202
|
-
value={formData.country}
|
|
203
|
-
onChange={(e) => updateField('country', e.target.value)}
|
|
204
|
-
className={cn(
|
|
205
|
-
selectClass,
|
|
206
|
-
errors.country ? 'border-destructive' : 'border-border'
|
|
207
|
-
)}
|
|
208
|
-
>
|
|
209
|
-
<option value="">{t('selectCountry')}</option>
|
|
210
|
-
{destinations.countries.map((c) => (
|
|
211
|
-
<option key={c.code} value={c.code}>
|
|
212
|
-
{c.name}
|
|
213
|
-
</option>
|
|
214
|
-
))}
|
|
215
|
-
</select>
|
|
216
|
-
) : (
|
|
217
|
-
<input
|
|
218
|
-
id="country"
|
|
219
|
-
type="text"
|
|
220
|
-
value={formData.country}
|
|
221
|
-
onChange={(e) => updateField('country', e.target.value)}
|
|
222
|
-
className={cn(
|
|
223
|
-
inputClass,
|
|
224
|
-
errors.country ? 'border-destructive' : 'border-border'
|
|
225
|
-
)}
|
|
226
|
-
placeholder={t('countryPlaceholder')}
|
|
227
|
-
/>
|
|
228
|
-
)}
|
|
229
|
-
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
230
|
-
</div>
|
|
231
|
-
|
|
232
|
-
<div>
|
|
233
|
-
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
234
|
-
{t('stateRegion')}
|
|
235
|
-
</label>
|
|
236
|
-
{hasRegionOptions ? (
|
|
237
|
-
<select
|
|
238
|
-
id="region"
|
|
239
|
-
value={formData.region || ''}
|
|
240
|
-
onChange={(e) => updateField('region', e.target.value)}
|
|
241
|
-
className={cn(selectClass, 'border-border')}
|
|
242
|
-
>
|
|
243
|
-
<option value="">{t('selectRegion')}</option>
|
|
244
|
-
{countryRegions.map((r) => (
|
|
245
|
-
<option key={r.code} value={r.code}>
|
|
246
|
-
{r.name}
|
|
247
|
-
</option>
|
|
248
|
-
))}
|
|
249
|
-
</select>
|
|
250
|
-
) : (
|
|
251
|
-
<input
|
|
252
|
-
id="region"
|
|
253
|
-
type="text"
|
|
254
|
-
value={formData.region || ''}
|
|
255
|
-
onChange={(e) => updateField('region', e.target.value)}
|
|
256
|
-
className={cn(inputClass, 'border-border')}
|
|
257
|
-
/>
|
|
258
|
-
)}
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
|
|
262
|
-
{/* Address line 1 */}
|
|
263
|
-
<div>
|
|
264
|
-
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
265
|
-
{t('address')} <span className="text-destructive">*</span>
|
|
266
|
-
</label>
|
|
267
|
-
<input
|
|
268
|
-
id="line1"
|
|
269
|
-
type="text"
|
|
270
|
-
value={formData.line1}
|
|
271
|
-
onChange={(e) => updateField('line1', e.target.value)}
|
|
272
|
-
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
273
|
-
placeholder={t('streetAddress')}
|
|
274
|
-
/>
|
|
275
|
-
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
276
|
-
</div>
|
|
277
|
-
|
|
278
|
-
{/* Address line 2 */}
|
|
279
|
-
<div>
|
|
280
|
-
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
281
|
-
{t('apartmentSuite')}
|
|
282
|
-
</label>
|
|
283
|
-
<input
|
|
284
|
-
id="line2"
|
|
285
|
-
type="text"
|
|
286
|
-
value={formData.line2 || ''}
|
|
287
|
-
onChange={(e) => updateField('line2', e.target.value)}
|
|
288
|
-
className={cn(inputClass, 'border-border')}
|
|
289
|
-
placeholder={t('aptPlaceholder')}
|
|
290
|
-
/>
|
|
291
|
-
</div>
|
|
292
|
-
|
|
293
|
-
{/* City + Postal code row */}
|
|
294
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
295
|
-
<div>
|
|
296
|
-
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
297
|
-
{t('city')} <span className="text-destructive">*</span>
|
|
298
|
-
</label>
|
|
299
|
-
<input
|
|
300
|
-
id="city"
|
|
301
|
-
type="text"
|
|
302
|
-
value={formData.city}
|
|
303
|
-
onChange={(e) => updateField('city', e.target.value)}
|
|
304
|
-
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
305
|
-
/>
|
|
306
|
-
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
307
|
-
</div>
|
|
308
|
-
|
|
309
|
-
<div>
|
|
310
|
-
<label
|
|
311
|
-
htmlFor="postalCode"
|
|
312
|
-
className="text-foreground mb-1 block text-sm font-medium"
|
|
313
|
-
>
|
|
314
|
-
{t('postalCode')} <span className="text-destructive">*</span>
|
|
315
|
-
</label>
|
|
316
|
-
<input
|
|
317
|
-
id="postalCode"
|
|
318
|
-
type="text"
|
|
319
|
-
value={formData.postalCode}
|
|
320
|
-
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
321
|
-
className={cn(
|
|
322
|
-
inputClass,
|
|
323
|
-
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
324
|
-
)}
|
|
325
|
-
/>
|
|
326
|
-
{errors.postalCode && (
|
|
327
|
-
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
328
|
-
)}
|
|
329
|
-
</div>
|
|
330
|
-
</div>
|
|
331
|
-
|
|
332
|
-
{/* Phone */}
|
|
333
|
-
<div>
|
|
334
|
-
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
335
|
-
{t('phone')}
|
|
336
|
-
</label>
|
|
337
|
-
<input
|
|
338
|
-
id="phone"
|
|
339
|
-
type="tel"
|
|
340
|
-
value={formData.phone || ''}
|
|
341
|
-
onChange={(e) => updateField('phone', e.target.value)}
|
|
342
|
-
className={cn(inputClass, 'border-border')}
|
|
343
|
-
placeholder={t('phonePlaceholder')}
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
346
|
-
</>
|
|
347
|
-
)}
|
|
348
|
-
|
|
349
|
-
{/* Privacy Policy (required) */}
|
|
350
|
-
<div>
|
|
351
|
-
<label className="flex cursor-pointer items-start gap-2">
|
|
352
|
-
<input
|
|
353
|
-
type="checkbox"
|
|
354
|
-
checked={privacyAccepted}
|
|
355
|
-
onChange={(e) => {
|
|
356
|
-
setPrivacyAccepted(e.target.checked);
|
|
357
|
-
if (e.target.checked && errors.privacy) {
|
|
358
|
-
setErrors((prev) => {
|
|
359
|
-
const next = { ...prev };
|
|
360
|
-
delete next.privacy;
|
|
361
|
-
return next;
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
}}
|
|
365
|
-
className="accent-primary mt-0.5"
|
|
366
|
-
/>
|
|
367
|
-
<span className="text-muted-foreground text-sm">
|
|
368
|
-
{t('privacyAcceptPrefix')}{' '}
|
|
369
|
-
<a
|
|
370
|
-
href="/privacy"
|
|
371
|
-
target="_blank"
|
|
372
|
-
rel="noopener noreferrer"
|
|
373
|
-
className="text-primary underline underline-offset-2"
|
|
374
|
-
>
|
|
375
|
-
{t('privacyPolicyLink')}
|
|
376
|
-
</a>{' '}
|
|
377
|
-
<span className="text-destructive">*</span>
|
|
378
|
-
</span>
|
|
379
|
-
</label>
|
|
380
|
-
{errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
|
|
381
|
-
</div>
|
|
382
|
-
|
|
383
|
-
{/* Marketing consent (optional) */}
|
|
384
|
-
<label className="flex cursor-pointer items-start gap-2">
|
|
385
|
-
<input
|
|
386
|
-
type="checkbox"
|
|
387
|
-
checked={acceptsMarketing}
|
|
388
|
-
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
389
|
-
className="accent-primary mt-0.5"
|
|
390
|
-
/>
|
|
391
|
-
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
392
|
-
</label>
|
|
393
|
-
|
|
394
|
-
{/* Save details for next time (logged-in users only) */}
|
|
395
|
-
{showSaveDetails && (
|
|
396
|
-
<label className="flex cursor-pointer items-start gap-2">
|
|
397
|
-
<input
|
|
398
|
-
type="checkbox"
|
|
399
|
-
checked={saveDetails}
|
|
400
|
-
onChange={(e) => setSaveDetails(e.target.checked)}
|
|
401
|
-
className="accent-primary mt-0.5"
|
|
402
|
-
/>
|
|
403
|
-
<span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
|
|
404
|
-
</label>
|
|
405
|
-
)}
|
|
406
|
-
|
|
407
|
-
<button
|
|
408
|
-
type="submit"
|
|
409
|
-
disabled={loading}
|
|
410
|
-
className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
411
|
-
>
|
|
412
|
-
{loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
|
|
413
|
-
</button>
|
|
414
|
-
</form>
|
|
415
|
-
);
|
|
416
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/lib/translations';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import { isValidEmail } from '@/lib/validation';
|
|
8
|
+
|
|
9
|
+
interface CheckoutConsent {
|
|
10
|
+
acceptsMarketing: boolean;
|
|
11
|
+
saveDetails: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CheckoutFormProps {
|
|
15
|
+
onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
initialValues?: Partial<SetShippingAddressDto>;
|
|
18
|
+
destinations?: ShippingDestinations | null;
|
|
19
|
+
className?: string;
|
|
20
|
+
showSaveDetails?: boolean;
|
|
21
|
+
emailOnly?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CheckoutForm({
|
|
25
|
+
onSubmit,
|
|
26
|
+
loading = false,
|
|
27
|
+
initialValues,
|
|
28
|
+
destinations,
|
|
29
|
+
className,
|
|
30
|
+
showSaveDetails = false,
|
|
31
|
+
emailOnly = false,
|
|
32
|
+
}: CheckoutFormProps) {
|
|
33
|
+
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
34
|
+
email: initialValues?.email || '',
|
|
35
|
+
firstName: initialValues?.firstName || '',
|
|
36
|
+
lastName: initialValues?.lastName || '',
|
|
37
|
+
line1: initialValues?.line1 || '',
|
|
38
|
+
line2: initialValues?.line2 || '',
|
|
39
|
+
city: initialValues?.city || '',
|
|
40
|
+
region: initialValues?.region || '',
|
|
41
|
+
postalCode: initialValues?.postalCode || '',
|
|
42
|
+
country: initialValues?.country || '',
|
|
43
|
+
phone: initialValues?.phone || '',
|
|
44
|
+
});
|
|
45
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
46
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
47
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
48
|
+
const [saveDetails, setSaveDetails] = useState(true);
|
|
49
|
+
const t = useTranslations('checkoutForm');
|
|
50
|
+
const tc = useTranslations('common');
|
|
51
|
+
const hasAppliedPrefill = useRef(!!initialValues);
|
|
52
|
+
|
|
53
|
+
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!initialValues || hasAppliedPrefill.current) return;
|
|
56
|
+
hasAppliedPrefill.current = true;
|
|
57
|
+
setFormData((prev) => ({
|
|
58
|
+
email: initialValues.email || prev.email,
|
|
59
|
+
firstName: initialValues.firstName || prev.firstName,
|
|
60
|
+
lastName: initialValues.lastName || prev.lastName,
|
|
61
|
+
line1: initialValues.line1 || prev.line1,
|
|
62
|
+
line2: initialValues.line2 || prev.line2 || '',
|
|
63
|
+
city: initialValues.city || prev.city,
|
|
64
|
+
region: initialValues.region || prev.region || '',
|
|
65
|
+
postalCode: initialValues.postalCode || prev.postalCode,
|
|
66
|
+
country: initialValues.country || prev.country,
|
|
67
|
+
phone: initialValues.phone || prev.phone || '',
|
|
68
|
+
}));
|
|
69
|
+
}, [initialValues]);
|
|
70
|
+
|
|
71
|
+
const hasCountryOptions = destinations && destinations.countries.length > 0;
|
|
72
|
+
const countryRegions = destinations?.regions[formData.country];
|
|
73
|
+
const hasRegionOptions = countryRegions && countryRegions.length > 0;
|
|
74
|
+
|
|
75
|
+
function validate(): boolean {
|
|
76
|
+
const newErrors: Record<string, string> = {};
|
|
77
|
+
|
|
78
|
+
if (!formData.email.trim()) {
|
|
79
|
+
newErrors.email = t('emailRequired');
|
|
80
|
+
} else if (!isValidEmail(formData.email.trim())) {
|
|
81
|
+
newErrors.email = t('emailInvalid');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!formData.firstName.trim()) {
|
|
85
|
+
newErrors.firstName = t('firstNameRequired');
|
|
86
|
+
}
|
|
87
|
+
if (!formData.lastName.trim()) {
|
|
88
|
+
newErrors.lastName = t('lastNameRequired');
|
|
89
|
+
}
|
|
90
|
+
if (!emailOnly) {
|
|
91
|
+
if (!formData.line1.trim()) {
|
|
92
|
+
newErrors.line1 = t('addressRequired');
|
|
93
|
+
}
|
|
94
|
+
if (!formData.city.trim()) {
|
|
95
|
+
newErrors.city = t('cityRequired');
|
|
96
|
+
}
|
|
97
|
+
if (!formData.postalCode.trim()) {
|
|
98
|
+
newErrors.postalCode = t('postalCodeRequired');
|
|
99
|
+
}
|
|
100
|
+
if (!formData.country.trim()) {
|
|
101
|
+
newErrors.country = t('countryRequired');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!privacyAccepted) {
|
|
105
|
+
newErrors.privacy = t('privacyRequired');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setErrors(newErrors);
|
|
109
|
+
return Object.keys(newErrors).length === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleSubmit(e: React.FormEvent) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
if (validate()) {
|
|
115
|
+
onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function updateField(field: keyof SetShippingAddressDto, value: string) {
|
|
120
|
+
setFormData((prev) => {
|
|
121
|
+
const next = { ...prev, [field]: value };
|
|
122
|
+
// Reset region when country changes
|
|
123
|
+
if (field === 'country' && value !== prev.country) {
|
|
124
|
+
next.region = '';
|
|
125
|
+
}
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
if (errors[field]) {
|
|
129
|
+
setErrors((prev) => {
|
|
130
|
+
const next = { ...prev };
|
|
131
|
+
delete next[field];
|
|
132
|
+
return next;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const inputClass =
|
|
138
|
+
'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
|
|
139
|
+
const selectClass =
|
|
140
|
+
'bg-background text-foreground focus:ring-primary/20 focus:border-primary h-10 w-full appearance-none rounded border px-3 text-sm focus:outline-none focus:ring-2';
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
|
|
144
|
+
{/* Email */}
|
|
145
|
+
<div>
|
|
146
|
+
<label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
|
|
147
|
+
{t('email')} <span className="text-destructive">*</span>
|
|
148
|
+
</label>
|
|
149
|
+
<input
|
|
150
|
+
id="email"
|
|
151
|
+
type="email"
|
|
152
|
+
value={formData.email}
|
|
153
|
+
onChange={(e) => updateField('email', e.target.value)}
|
|
154
|
+
className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')}
|
|
155
|
+
placeholder="your@email.com"
|
|
156
|
+
/>
|
|
157
|
+
{errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Name row */}
|
|
161
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
162
|
+
<div>
|
|
163
|
+
<label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
|
|
164
|
+
{t('firstName')} <span className="text-destructive">*</span>
|
|
165
|
+
</label>
|
|
166
|
+
<input
|
|
167
|
+
id="firstName"
|
|
168
|
+
type="text"
|
|
169
|
+
value={formData.firstName}
|
|
170
|
+
onChange={(e) => updateField('firstName', e.target.value)}
|
|
171
|
+
className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')}
|
|
172
|
+
/>
|
|
173
|
+
{errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div>
|
|
177
|
+
<label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
|
|
178
|
+
{t('lastName')} <span className="text-destructive">*</span>
|
|
179
|
+
</label>
|
|
180
|
+
<input
|
|
181
|
+
id="lastName"
|
|
182
|
+
type="text"
|
|
183
|
+
value={formData.lastName}
|
|
184
|
+
onChange={(e) => updateField('lastName', e.target.value)}
|
|
185
|
+
className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')}
|
|
186
|
+
/>
|
|
187
|
+
{errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{!emailOnly && (
|
|
192
|
+
<>
|
|
193
|
+
{/* Country + Region row */}
|
|
194
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
195
|
+
<div>
|
|
196
|
+
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
197
|
+
{t('country')} <span className="text-destructive">*</span>
|
|
198
|
+
</label>
|
|
199
|
+
{hasCountryOptions ? (
|
|
200
|
+
<select
|
|
201
|
+
id="country"
|
|
202
|
+
value={formData.country}
|
|
203
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
204
|
+
className={cn(
|
|
205
|
+
selectClass,
|
|
206
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
207
|
+
)}
|
|
208
|
+
>
|
|
209
|
+
<option value="">{t('selectCountry')}</option>
|
|
210
|
+
{destinations.countries.map((c) => (
|
|
211
|
+
<option key={c.code} value={c.code}>
|
|
212
|
+
{c.name}
|
|
213
|
+
</option>
|
|
214
|
+
))}
|
|
215
|
+
</select>
|
|
216
|
+
) : (
|
|
217
|
+
<input
|
|
218
|
+
id="country"
|
|
219
|
+
type="text"
|
|
220
|
+
value={formData.country}
|
|
221
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
222
|
+
className={cn(
|
|
223
|
+
inputClass,
|
|
224
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
225
|
+
)}
|
|
226
|
+
placeholder={t('countryPlaceholder')}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div>
|
|
233
|
+
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
234
|
+
{t('stateRegion')}
|
|
235
|
+
</label>
|
|
236
|
+
{hasRegionOptions ? (
|
|
237
|
+
<select
|
|
238
|
+
id="region"
|
|
239
|
+
value={formData.region || ''}
|
|
240
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
241
|
+
className={cn(selectClass, 'border-border')}
|
|
242
|
+
>
|
|
243
|
+
<option value="">{t('selectRegion')}</option>
|
|
244
|
+
{countryRegions.map((r) => (
|
|
245
|
+
<option key={r.code} value={r.code}>
|
|
246
|
+
{r.name}
|
|
247
|
+
</option>
|
|
248
|
+
))}
|
|
249
|
+
</select>
|
|
250
|
+
) : (
|
|
251
|
+
<input
|
|
252
|
+
id="region"
|
|
253
|
+
type="text"
|
|
254
|
+
value={formData.region || ''}
|
|
255
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
256
|
+
className={cn(inputClass, 'border-border')}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Address line 1 */}
|
|
263
|
+
<div>
|
|
264
|
+
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
265
|
+
{t('address')} <span className="text-destructive">*</span>
|
|
266
|
+
</label>
|
|
267
|
+
<input
|
|
268
|
+
id="line1"
|
|
269
|
+
type="text"
|
|
270
|
+
value={formData.line1}
|
|
271
|
+
onChange={(e) => updateField('line1', e.target.value)}
|
|
272
|
+
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
273
|
+
placeholder={t('streetAddress')}
|
|
274
|
+
/>
|
|
275
|
+
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Address line 2 */}
|
|
279
|
+
<div>
|
|
280
|
+
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
281
|
+
{t('apartmentSuite')}
|
|
282
|
+
</label>
|
|
283
|
+
<input
|
|
284
|
+
id="line2"
|
|
285
|
+
type="text"
|
|
286
|
+
value={formData.line2 || ''}
|
|
287
|
+
onChange={(e) => updateField('line2', e.target.value)}
|
|
288
|
+
className={cn(inputClass, 'border-border')}
|
|
289
|
+
placeholder={t('aptPlaceholder')}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
{/* City + Postal code row */}
|
|
294
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
295
|
+
<div>
|
|
296
|
+
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
297
|
+
{t('city')} <span className="text-destructive">*</span>
|
|
298
|
+
</label>
|
|
299
|
+
<input
|
|
300
|
+
id="city"
|
|
301
|
+
type="text"
|
|
302
|
+
value={formData.city}
|
|
303
|
+
onChange={(e) => updateField('city', e.target.value)}
|
|
304
|
+
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
305
|
+
/>
|
|
306
|
+
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div>
|
|
310
|
+
<label
|
|
311
|
+
htmlFor="postalCode"
|
|
312
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
313
|
+
>
|
|
314
|
+
{t('postalCode')} <span className="text-destructive">*</span>
|
|
315
|
+
</label>
|
|
316
|
+
<input
|
|
317
|
+
id="postalCode"
|
|
318
|
+
type="text"
|
|
319
|
+
value={formData.postalCode}
|
|
320
|
+
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
321
|
+
className={cn(
|
|
322
|
+
inputClass,
|
|
323
|
+
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
324
|
+
)}
|
|
325
|
+
/>
|
|
326
|
+
{errors.postalCode && (
|
|
327
|
+
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* Phone */}
|
|
333
|
+
<div>
|
|
334
|
+
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
335
|
+
{t('phone')}
|
|
336
|
+
</label>
|
|
337
|
+
<input
|
|
338
|
+
id="phone"
|
|
339
|
+
type="tel"
|
|
340
|
+
value={formData.phone || ''}
|
|
341
|
+
onChange={(e) => updateField('phone', e.target.value)}
|
|
342
|
+
className={cn(inputClass, 'border-border')}
|
|
343
|
+
placeholder={t('phonePlaceholder')}
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
{/* Privacy Policy (required) */}
|
|
350
|
+
<div>
|
|
351
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
352
|
+
<input
|
|
353
|
+
type="checkbox"
|
|
354
|
+
checked={privacyAccepted}
|
|
355
|
+
onChange={(e) => {
|
|
356
|
+
setPrivacyAccepted(e.target.checked);
|
|
357
|
+
if (e.target.checked && errors.privacy) {
|
|
358
|
+
setErrors((prev) => {
|
|
359
|
+
const next = { ...prev };
|
|
360
|
+
delete next.privacy;
|
|
361
|
+
return next;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}}
|
|
365
|
+
className="accent-primary mt-0.5"
|
|
366
|
+
/>
|
|
367
|
+
<span className="text-muted-foreground text-sm">
|
|
368
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
369
|
+
<a
|
|
370
|
+
href="/privacy"
|
|
371
|
+
target="_blank"
|
|
372
|
+
rel="noopener noreferrer"
|
|
373
|
+
className="text-primary underline underline-offset-2"
|
|
374
|
+
>
|
|
375
|
+
{t('privacyPolicyLink')}
|
|
376
|
+
</a>{' '}
|
|
377
|
+
<span className="text-destructive">*</span>
|
|
378
|
+
</span>
|
|
379
|
+
</label>
|
|
380
|
+
{errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* Marketing consent (optional) */}
|
|
384
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
385
|
+
<input
|
|
386
|
+
type="checkbox"
|
|
387
|
+
checked={acceptsMarketing}
|
|
388
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
389
|
+
className="accent-primary mt-0.5"
|
|
390
|
+
/>
|
|
391
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
392
|
+
</label>
|
|
393
|
+
|
|
394
|
+
{/* Save details for next time (logged-in users only) */}
|
|
395
|
+
{showSaveDetails && (
|
|
396
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
397
|
+
<input
|
|
398
|
+
type="checkbox"
|
|
399
|
+
checked={saveDetails}
|
|
400
|
+
onChange={(e) => setSaveDetails(e.target.checked)}
|
|
401
|
+
className="accent-primary mt-0.5"
|
|
402
|
+
/>
|
|
403
|
+
<span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
|
|
404
|
+
</label>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
<button
|
|
408
|
+
type="submit"
|
|
409
|
+
disabled={loading}
|
|
410
|
+
className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
411
|
+
>
|
|
412
|
+
{loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
|
|
413
|
+
</button>
|
|
414
|
+
</form>
|
|
415
|
+
);
|
|
416
|
+
}
|