create-brainerce-store 1.17.0 → 1.19.0

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