create-brainerce-store 1.53.0 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,551 +1,564 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useRef } from 'react';
4
- import type { SetShippingAddressDto, ShippingDestinations, AddressSuggestion } from 'brainerce';
5
- import { useTranslations } from '@/core/lib/translations';
6
- import { cn } from '@/core/lib/utils';
7
- import { isValidEmail } from '@/core/lib/validation';
8
- import { getClient } from '@/core/lib/brainerce';
9
-
10
- // Debounce address-suggestion calls rather than firing on every keystroke —
11
- // both to avoid a jumpy dropdown and to keep autocomplete-session call volume
12
- // reasonable (see `getAddressSuggestions` doc in the SDK).
13
- const ADDRESS_SEARCH_DEBOUNCE_MS = 300;
14
- const ADDRESS_SEARCH_MIN_CHARS = 3;
15
-
16
- interface CheckoutConsent {
17
- acceptsMarketing: boolean;
18
- saveDetails: boolean;
19
- }
20
-
21
- interface CheckoutFormProps {
22
- onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
23
- loading?: boolean;
24
- initialValues?: Partial<SetShippingAddressDto>;
25
- destinations?: ShippingDestinations | null;
26
- className?: string;
27
- showSaveDetails?: boolean;
28
- emailOnly?: boolean;
29
- }
30
-
31
- export function CheckoutForm({
32
- onSubmit,
33
- loading = false,
34
- initialValues,
35
- destinations,
36
- className,
37
- showSaveDetails = false,
38
- emailOnly = false,
39
- }: CheckoutFormProps) {
40
- const [formData, setFormData] = useState<SetShippingAddressDto>({
41
- email: initialValues?.email || '',
42
- firstName: initialValues?.firstName || '',
43
- lastName: initialValues?.lastName || '',
44
- line1: initialValues?.line1 || '',
45
- line2: initialValues?.line2 || '',
46
- city: initialValues?.city || '',
47
- region: initialValues?.region || '',
48
- postalCode: initialValues?.postalCode || '',
49
- country: initialValues?.country || '',
50
- phone: initialValues?.phone || '',
51
- notes: initialValues?.notes || '',
52
- });
53
- const [errors, setErrors] = useState<Record<string, string>>({});
54
- const [privacyAccepted, setPrivacyAccepted] = useState(false);
55
- const [acceptsMarketing, setAcceptsMarketing] = useState(false);
56
- const [saveDetails, setSaveDetails] = useState(true);
57
- const t = useTranslations('checkoutForm');
58
- const tc = useTranslations('common');
59
- const hasAppliedPrefill = useRef(!!initialValues);
60
-
61
- // Address-autocomplete state — groups one address-entry attempt for
62
- // session-token billing (see `getAddressSuggestions` in the SDK). A fresh
63
- // token is minted whenever a suggestion is picked, ending that session.
64
- const [addressSuggestions, setAddressSuggestions] = useState<AddressSuggestion[]>([]);
65
- const [showAddressSuggestions, setShowAddressSuggestions] = useState(false);
66
- const [isSearchingAddress, setIsSearchingAddress] = useState(false);
67
- const [outsideDeliveryZone, setOutsideDeliveryZone] = useState(false);
68
- const addressSessionToken = useRef(
69
- typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
70
- );
71
- const addressSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
72
-
73
- useEffect(() => {
74
- return () => {
75
- if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current);
76
- };
77
- }, []);
78
-
79
- function handleLine1Change(value: string) {
80
- updateField('line1', value);
81
- setOutsideDeliveryZone(false);
82
-
83
- if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current);
84
-
85
- const query = value.trim();
86
- if (query.length < ADDRESS_SEARCH_MIN_CHARS) {
87
- setAddressSuggestions([]);
88
- setShowAddressSuggestions(false);
89
- return;
90
- }
91
-
92
- addressSearchTimer.current = setTimeout(async () => {
93
- setIsSearchingAddress(true);
94
- try {
95
- const results = await getClient().getAddressSuggestions(
96
- query,
97
- addressSessionToken.current
98
- );
99
- setAddressSuggestions(results);
100
- setShowAddressSuggestions(results.length > 0);
101
- } catch {
102
- // Autocomplete is a soft enhancement — a failed lookup just means no
103
- // suggestions this keystroke; the shopper can keep typing manually.
104
- setAddressSuggestions([]);
105
- setShowAddressSuggestions(false);
106
- } finally {
107
- setIsSearchingAddress(false);
108
- }
109
- }, ADDRESS_SEARCH_DEBOUNCE_MS);
110
- }
111
-
112
- async function handleSelectAddressSuggestion(suggestion: AddressSuggestion) {
113
- setShowAddressSuggestions(false);
114
- try {
115
- const { address, inZone } = await getClient().getAddressDetails(
116
- suggestion.placeId,
117
- addressSessionToken.current
118
- );
119
- setFormData((prev) => ({
120
- ...prev,
121
- line1: address.line1 || prev.line1,
122
- city: address.city || prev.city,
123
- region: address.region || prev.region,
124
- postalCode: address.postalCode || prev.postalCode,
125
- country: address.country || prev.country,
126
- }));
127
- setOutsideDeliveryZone(!inZone);
128
- } catch {
129
- // Resolution failed — keep whatever the shopper had typed/selected as
130
- // the visible text; they can still fill in the rest manually.
131
- } finally {
132
- // New session for the next address-entry attempt.
133
- addressSessionToken.current =
134
- typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
135
- setAddressSuggestions([]);
136
- }
137
- }
138
-
139
- // Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
140
- useEffect(() => {
141
- if (!initialValues || hasAppliedPrefill.current) return;
142
- hasAppliedPrefill.current = true;
143
- setFormData((prev) => ({
144
- email: initialValues.email || prev.email,
145
- firstName: initialValues.firstName || prev.firstName,
146
- lastName: initialValues.lastName || prev.lastName,
147
- line1: initialValues.line1 || prev.line1,
148
- line2: initialValues.line2 || prev.line2 || '',
149
- city: initialValues.city || prev.city,
150
- region: initialValues.region || prev.region || '',
151
- postalCode: initialValues.postalCode || prev.postalCode,
152
- country: initialValues.country || prev.country,
153
- phone: initialValues.phone || prev.phone || '',
154
- notes: prev.notes || '',
155
- }));
156
- }, [initialValues]);
157
-
158
- const hasCountryOptions = destinations && destinations.countries.length > 0;
159
- const countryRegions = destinations?.regions[formData.country];
160
- const hasRegionOptions = countryRegions && countryRegions.length > 0;
161
-
162
- function validate(): boolean {
163
- const newErrors: Record<string, string> = {};
164
-
165
- if (!formData.email.trim()) {
166
- newErrors.email = t('emailRequired');
167
- } else if (!isValidEmail(formData.email.trim())) {
168
- newErrors.email = t('emailInvalid');
169
- }
170
-
171
- if (!formData.firstName.trim()) {
172
- newErrors.firstName = t('firstNameRequired');
173
- }
174
- if (!formData.lastName.trim()) {
175
- newErrors.lastName = t('lastNameRequired');
176
- }
177
- if (!emailOnly) {
178
- if (!formData.line1.trim()) {
179
- newErrors.line1 = t('addressRequired');
180
- }
181
- if (!formData.city.trim()) {
182
- newErrors.city = t('cityRequired');
183
- }
184
- if (!formData.postalCode.trim()) {
185
- newErrors.postalCode = t('postalCodeRequired');
186
- }
187
- if (!formData.country.trim()) {
188
- newErrors.country = t('countryRequired');
189
- }
190
- }
191
- if (!privacyAccepted) {
192
- newErrors.privacy = t('privacyRequired');
193
- }
194
-
195
- setErrors(newErrors);
196
- return Object.keys(newErrors).length === 0;
197
- }
198
-
199
- function handleSubmit(e: React.FormEvent) {
200
- e.preventDefault();
201
- if (validate()) {
202
- onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
203
- }
204
- }
205
-
206
- function updateField(field: keyof SetShippingAddressDto, value: string) {
207
- setFormData((prev) => {
208
- const next = { ...prev, [field]: value };
209
- // Reset region when country changes
210
- if (field === 'country' && value !== prev.country) {
211
- next.region = '';
212
- }
213
- return next;
214
- });
215
- if (errors[field]) {
216
- setErrors((prev) => {
217
- const next = { ...prev };
218
- delete next[field];
219
- return next;
220
- });
221
- }
222
- }
223
-
224
- const inputClass =
225
- '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';
226
- const selectClass =
227
- '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';
228
-
229
- return (
230
- <form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
231
- {/* Email */}
232
- <div>
233
- <label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
234
- {t('email')} <span className="text-destructive">*</span>
235
- </label>
236
- <input
237
- id="email"
238
- type="email"
239
- value={formData.email}
240
- onChange={(e) => updateField('email', e.target.value)}
241
- className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')}
242
- placeholder="your@email.com"
243
- />
244
- {errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
245
- </div>
246
-
247
- {/* Name row */}
248
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
249
- <div>
250
- <label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
251
- {t('firstName')} <span className="text-destructive">*</span>
252
- </label>
253
- <input
254
- id="firstName"
255
- type="text"
256
- value={formData.firstName}
257
- onChange={(e) => updateField('firstName', e.target.value)}
258
- className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')}
259
- />
260
- {errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
261
- </div>
262
-
263
- <div>
264
- <label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
265
- {t('lastName')} <span className="text-destructive">*</span>
266
- </label>
267
- <input
268
- id="lastName"
269
- type="text"
270
- value={formData.lastName}
271
- onChange={(e) => updateField('lastName', e.target.value)}
272
- className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')}
273
- />
274
- {errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
275
- </div>
276
- </div>
277
-
278
- {!emailOnly && (
279
- <>
280
- {/* Country + Region row */}
281
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
282
- <div>
283
- <label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
284
- {t('country')} <span className="text-destructive">*</span>
285
- </label>
286
- {hasCountryOptions ? (
287
- <select
288
- id="country"
289
- value={formData.country}
290
- onChange={(e) => updateField('country', e.target.value)}
291
- className={cn(
292
- selectClass,
293
- errors.country ? 'border-destructive' : 'border-border'
294
- )}
295
- >
296
- <option value="">{t('selectCountry')}</option>
297
- {destinations.countries.map((c) => (
298
- <option key={c.code} value={c.code}>
299
- {c.name}
300
- </option>
301
- ))}
302
- </select>
303
- ) : (
304
- <input
305
- id="country"
306
- type="text"
307
- value={formData.country}
308
- onChange={(e) => updateField('country', e.target.value)}
309
- className={cn(
310
- inputClass,
311
- errors.country ? 'border-destructive' : 'border-border'
312
- )}
313
- placeholder={t('countryPlaceholder')}
314
- />
315
- )}
316
- {errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
317
- </div>
318
-
319
- <div>
320
- <label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
321
- {t('stateRegion')}
322
- </label>
323
- {hasRegionOptions ? (
324
- <select
325
- id="region"
326
- value={formData.region || ''}
327
- onChange={(e) => updateField('region', e.target.value)}
328
- className={cn(selectClass, 'border-border')}
329
- >
330
- <option value="">{t('selectRegion')}</option>
331
- {countryRegions.map((r) => (
332
- <option key={r.code} value={r.code}>
333
- {r.name}
334
- </option>
335
- ))}
336
- </select>
337
- ) : (
338
- <input
339
- id="region"
340
- type="text"
341
- value={formData.region || ''}
342
- onChange={(e) => updateField('region', e.target.value)}
343
- className={cn(inputClass, 'border-border')}
344
- />
345
- )}
346
- </div>
347
- </div>
348
-
349
- {/* Address line 1 — autocomplete typeahead */}
350
- <div className="relative">
351
- <label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
352
- {t('address')} <span className="text-destructive">*</span>
353
- </label>
354
- <input
355
- id="line1"
356
- type="text"
357
- autoComplete="off"
358
- value={formData.line1}
359
- onChange={(e) => handleLine1Change(e.target.value)}
360
- onFocus={() => setShowAddressSuggestions(addressSuggestions.length > 0)}
361
- onBlur={() => setTimeout(() => setShowAddressSuggestions(false), 150)}
362
- className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
363
- placeholder={t('streetAddress')}
364
- />
365
- {errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
366
-
367
- {showAddressSuggestions && addressSuggestions.length > 0 && (
368
- <ul className="bg-background border-border absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border shadow-lg">
369
- {addressSuggestions.map((suggestion) => (
370
- <li key={suggestion.placeId}>
371
- <button
372
- type="button"
373
- // onMouseDown fires before the input's onBlur, so the
374
- // click registers before the dropdown closes.
375
- onMouseDown={() => handleSelectAddressSuggestion(suggestion)}
376
- className="hover:bg-muted w-full px-3 py-2 text-start text-sm"
377
- >
378
- {suggestion.description}
379
- </button>
380
- </li>
381
- ))}
382
- </ul>
383
- )}
384
- {isSearchingAddress && (
385
- <p className="text-muted-foreground mt-1 text-xs">{t('searchingAddress')}</p>
386
- )}
387
- {outsideDeliveryZone && (
388
- <p className="mt-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
389
- {t('outsideDeliveryZone')}
390
- </p>
391
- )}
392
- </div>
393
-
394
- {/* Address line 2 */}
395
- <div>
396
- <label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
397
- {t('apartmentSuite')}
398
- </label>
399
- <input
400
- id="line2"
401
- type="text"
402
- value={formData.line2 || ''}
403
- onChange={(e) => updateField('line2', e.target.value)}
404
- className={cn(inputClass, 'border-border')}
405
- placeholder={t('aptPlaceholder')}
406
- />
407
- </div>
408
-
409
- {/* City + Postal code row */}
410
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
411
- <div>
412
- <label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
413
- {t('city')} <span className="text-destructive">*</span>
414
- </label>
415
- <input
416
- id="city"
417
- type="text"
418
- value={formData.city}
419
- onChange={(e) => updateField('city', e.target.value)}
420
- className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
421
- />
422
- {errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
423
- </div>
424
-
425
- <div>
426
- <label
427
- htmlFor="postalCode"
428
- className="text-foreground mb-1 block text-sm font-medium"
429
- >
430
- {t('postalCode')} <span className="text-destructive">*</span>
431
- </label>
432
- <input
433
- id="postalCode"
434
- type="text"
435
- value={formData.postalCode}
436
- onChange={(e) => updateField('postalCode', e.target.value)}
437
- className={cn(
438
- inputClass,
439
- errors.postalCode ? 'border-destructive' : 'border-border'
440
- )}
441
- />
442
- {errors.postalCode && (
443
- <p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
444
- )}
445
- </div>
446
- </div>
447
-
448
- {/* Phone */}
449
- <div>
450
- <label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
451
- {t('phone')}
452
- </label>
453
- <input
454
- id="phone"
455
- type="tel"
456
- value={formData.phone || ''}
457
- onChange={(e) => updateField('phone', e.target.value)}
458
- className={cn(inputClass, 'border-border')}
459
- placeholder={t('phonePlaceholder')}
460
- />
461
- </div>
462
- </>
463
- )}
464
-
465
- {/* Order notes (optional) */}
466
- <div>
467
- <label htmlFor="orderNotes" className="text-foreground mb-1 block text-sm font-medium">
468
- {t('orderNotes')}
469
- </label>
470
- <textarea
471
- id="orderNotes"
472
- value={formData.notes || ''}
473
- onChange={(e) => updateField('notes', e.target.value)}
474
- maxLength={2000}
475
- rows={3}
476
- className={cn(
477
- inputClass,
478
- 'border-border h-auto min-h-[80px] resize-y py-2 leading-relaxed'
479
- )}
480
- placeholder={t('orderNotesPlaceholder')}
481
- />
482
- </div>
483
-
484
- {/* Privacy Policy (required) */}
485
- <div>
486
- <label className="flex cursor-pointer items-start gap-2">
487
- <input
488
- type="checkbox"
489
- checked={privacyAccepted}
490
- onChange={(e) => {
491
- setPrivacyAccepted(e.target.checked);
492
- if (e.target.checked && errors.privacy) {
493
- setErrors((prev) => {
494
- const next = { ...prev };
495
- delete next.privacy;
496
- return next;
497
- });
498
- }
499
- }}
500
- className="accent-primary mt-0.5"
501
- />
502
- <span className="text-muted-foreground text-sm">
503
- {t('privacyAcceptPrefix')}{' '}
504
- <a
505
- href="/privacy"
506
- target="_blank"
507
- rel="noopener noreferrer"
508
- className="text-primary underline underline-offset-2"
509
- >
510
- {t('privacyPolicyLink')}
511
- </a>{' '}
512
- <span className="text-destructive">*</span>
513
- </span>
514
- </label>
515
- {errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
516
- </div>
517
-
518
- {/* Marketing consent (optional) */}
519
- <label className="flex cursor-pointer items-start gap-2">
520
- <input
521
- type="checkbox"
522
- checked={acceptsMarketing}
523
- onChange={(e) => setAcceptsMarketing(e.target.checked)}
524
- className="accent-primary mt-0.5"
525
- />
526
- <span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
527
- </label>
528
-
529
- {/* Save details for next time (logged-in users only) */}
530
- {showSaveDetails && (
531
- <label className="flex cursor-pointer items-start gap-2">
532
- <input
533
- type="checkbox"
534
- checked={saveDetails}
535
- onChange={(e) => setSaveDetails(e.target.checked)}
536
- className="accent-primary mt-0.5"
537
- />
538
- <span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
539
- </label>
540
- )}
541
-
542
- <button
543
- type="submit"
544
- disabled={loading}
545
- 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"
546
- >
547
- {loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
548
- </button>
549
- </form>
550
- );
551
- }
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import type { SetShippingAddressDto, ShippingDestinations, AddressSuggestion } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { cn } from '@/core/lib/utils';
7
+ import { isValidEmail } from '@/core/lib/validation';
8
+ import { getClient } from '@/core/lib/brainerce';
9
+
10
+ // Debounce address-suggestion calls rather than firing on every keystroke —
11
+ // both to avoid a jumpy dropdown and to keep autocomplete-session call volume
12
+ // reasonable (see `getAddressSuggestions` doc in the SDK).
13
+ const ADDRESS_SEARCH_DEBOUNCE_MS = 300;
14
+ const ADDRESS_SEARCH_MIN_CHARS = 3;
15
+
16
+ interface CheckoutConsent {
17
+ acceptsMarketing: boolean;
18
+ saveDetails: boolean;
19
+ }
20
+
21
+ interface CheckoutFormProps {
22
+ onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
23
+ loading?: boolean;
24
+ initialValues?: Partial<SetShippingAddressDto>;
25
+ destinations?: ShippingDestinations | null;
26
+ className?: string;
27
+ showSaveDetails?: boolean;
28
+ emailOnly?: boolean;
29
+ }
30
+
31
+ export function CheckoutForm({
32
+ onSubmit,
33
+ loading = false,
34
+ initialValues,
35
+ destinations,
36
+ className,
37
+ showSaveDetails = false,
38
+ emailOnly = false,
39
+ }: CheckoutFormProps) {
40
+ const [formData, setFormData] = useState<SetShippingAddressDto>({
41
+ email: initialValues?.email || '',
42
+ firstName: initialValues?.firstName || '',
43
+ lastName: initialValues?.lastName || '',
44
+ line1: initialValues?.line1 || '',
45
+ line2: initialValues?.line2 || '',
46
+ city: initialValues?.city || '',
47
+ region: initialValues?.region || '',
48
+ postalCode: initialValues?.postalCode || '',
49
+ country: initialValues?.country || '',
50
+ phone: initialValues?.phone || '',
51
+ notes: initialValues?.notes || '',
52
+ });
53
+ const [errors, setErrors] = useState<Record<string, string>>({});
54
+ const [privacyAccepted, setPrivacyAccepted] = useState(false);
55
+ const [acceptsMarketing, setAcceptsMarketing] = useState(false);
56
+ const [saveDetails, setSaveDetails] = useState(true);
57
+ const t = useTranslations('checkoutForm');
58
+ const tc = useTranslations('common');
59
+ const hasAppliedPrefill = useRef(!!initialValues);
60
+
61
+ // Address-autocomplete state — groups one address-entry attempt for
62
+ // session-token billing (see `getAddressSuggestions` in the SDK). A fresh
63
+ // token is minted whenever a suggestion is picked, ending that session.
64
+ const [addressSuggestions, setAddressSuggestions] = useState<AddressSuggestion[]>([]);
65
+ const [showAddressSuggestions, setShowAddressSuggestions] = useState(false);
66
+ const [isSearchingAddress, setIsSearchingAddress] = useState(false);
67
+ const [outsideDeliveryZone, setOutsideDeliveryZone] = useState(false);
68
+ const addressSessionToken = useRef(
69
+ typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
70
+ );
71
+ const addressSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
72
+
73
+ useEffect(() => {
74
+ return () => {
75
+ if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current);
76
+ };
77
+ }, []);
78
+
79
+ function handleLine1Change(value: string) {
80
+ updateField('line1', value);
81
+ setOutsideDeliveryZone(false);
82
+
83
+ if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current);
84
+
85
+ const query = value.trim();
86
+ if (query.length < ADDRESS_SEARCH_MIN_CHARS) {
87
+ setAddressSuggestions([]);
88
+ setShowAddressSuggestions(false);
89
+ return;
90
+ }
91
+
92
+ addressSearchTimer.current = setTimeout(async () => {
93
+ setIsSearchingAddress(true);
94
+ try {
95
+ const results = await getClient().getAddressSuggestions(query, addressSessionToken.current);
96
+ setAddressSuggestions(results);
97
+ setShowAddressSuggestions(results.length > 0);
98
+ } catch {
99
+ // Autocomplete is a soft enhancement — a failed lookup just means no
100
+ // suggestions this keystroke; the shopper can keep typing manually.
101
+ setAddressSuggestions([]);
102
+ setShowAddressSuggestions(false);
103
+ } finally {
104
+ setIsSearchingAddress(false);
105
+ }
106
+ }, ADDRESS_SEARCH_DEBOUNCE_MS);
107
+ }
108
+
109
+ async function handleSelectAddressSuggestion(suggestion: AddressSuggestion) {
110
+ setShowAddressSuggestions(false);
111
+ try {
112
+ const { address, inZone } = await getClient().getAddressDetails(
113
+ suggestion.placeId,
114
+ addressSessionToken.current
115
+ );
116
+ setFormData((prev) => {
117
+ const country = address.country || prev.country;
118
+ // Only accept address.region if it's one of THIS store's known
119
+ // region codes for the resolved country (destinations.regions —
120
+ // the same list the dropdown below renders, backed by our own
121
+ // geo-data, not Google's). Google's region code usually lines up
122
+ // (both ultimately trace back to ISO 3166-2), but never assign a
123
+ // value the dropdown wouldn't recognize — better an unselected
124
+ // dropdown the shopper fills in than a silently-wrong one.
125
+ const validRegions = destinations?.regions[country] ?? [];
126
+ const resolvedRegionValid = validRegions.some((r) => r.code === address.region);
127
+ return {
128
+ ...prev,
129
+ line1: address.line1 || prev.line1,
130
+ city: address.city || prev.city,
131
+ region: resolvedRegionValid
132
+ ? address.region
133
+ : country !== prev.country
134
+ ? ''
135
+ : prev.region,
136
+ postalCode: address.postalCode || prev.postalCode,
137
+ country,
138
+ };
139
+ });
140
+ setOutsideDeliveryZone(!inZone);
141
+ } catch {
142
+ // Resolution failed — keep whatever the shopper had typed/selected as
143
+ // the visible text; they can still fill in the rest manually.
144
+ } finally {
145
+ // New session for the next address-entry attempt.
146
+ addressSessionToken.current =
147
+ typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
148
+ setAddressSuggestions([]);
149
+ }
150
+ }
151
+
152
+ // Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
153
+ useEffect(() => {
154
+ if (!initialValues || hasAppliedPrefill.current) return;
155
+ hasAppliedPrefill.current = true;
156
+ setFormData((prev) => ({
157
+ email: initialValues.email || prev.email,
158
+ firstName: initialValues.firstName || prev.firstName,
159
+ lastName: initialValues.lastName || prev.lastName,
160
+ line1: initialValues.line1 || prev.line1,
161
+ line2: initialValues.line2 || prev.line2 || '',
162
+ city: initialValues.city || prev.city,
163
+ region: initialValues.region || prev.region || '',
164
+ postalCode: initialValues.postalCode || prev.postalCode,
165
+ country: initialValues.country || prev.country,
166
+ phone: initialValues.phone || prev.phone || '',
167
+ notes: prev.notes || '',
168
+ }));
169
+ }, [initialValues]);
170
+
171
+ const hasCountryOptions = destinations && destinations.countries.length > 0;
172
+ const countryRegions = destinations?.regions[formData.country];
173
+ const hasRegionOptions = countryRegions && countryRegions.length > 0;
174
+
175
+ function validate(): boolean {
176
+ const newErrors: Record<string, string> = {};
177
+
178
+ if (!formData.email.trim()) {
179
+ newErrors.email = t('emailRequired');
180
+ } else if (!isValidEmail(formData.email.trim())) {
181
+ newErrors.email = t('emailInvalid');
182
+ }
183
+
184
+ if (!formData.firstName.trim()) {
185
+ newErrors.firstName = t('firstNameRequired');
186
+ }
187
+ if (!formData.lastName.trim()) {
188
+ newErrors.lastName = t('lastNameRequired');
189
+ }
190
+ if (!emailOnly) {
191
+ if (!formData.line1.trim()) {
192
+ newErrors.line1 = t('addressRequired');
193
+ }
194
+ if (!formData.city.trim()) {
195
+ newErrors.city = t('cityRequired');
196
+ }
197
+ if (!formData.postalCode.trim()) {
198
+ newErrors.postalCode = t('postalCodeRequired');
199
+ }
200
+ if (!formData.country.trim()) {
201
+ newErrors.country = t('countryRequired');
202
+ }
203
+ }
204
+ if (!privacyAccepted) {
205
+ newErrors.privacy = t('privacyRequired');
206
+ }
207
+
208
+ setErrors(newErrors);
209
+ return Object.keys(newErrors).length === 0;
210
+ }
211
+
212
+ function handleSubmit(e: React.FormEvent) {
213
+ e.preventDefault();
214
+ if (validate()) {
215
+ onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
216
+ }
217
+ }
218
+
219
+ function updateField(field: keyof SetShippingAddressDto, value: string) {
220
+ setFormData((prev) => {
221
+ const next = { ...prev, [field]: value };
222
+ // Reset region when country changes
223
+ if (field === 'country' && value !== prev.country) {
224
+ next.region = '';
225
+ }
226
+ return next;
227
+ });
228
+ if (errors[field]) {
229
+ setErrors((prev) => {
230
+ const next = { ...prev };
231
+ delete next[field];
232
+ return next;
233
+ });
234
+ }
235
+ }
236
+
237
+ const inputClass =
238
+ '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';
239
+ const selectClass =
240
+ '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';
241
+
242
+ return (
243
+ <form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
244
+ {/* Email */}
245
+ <div>
246
+ <label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
247
+ {t('email')} <span className="text-destructive">*</span>
248
+ </label>
249
+ <input
250
+ id="email"
251
+ type="email"
252
+ value={formData.email}
253
+ onChange={(e) => updateField('email', e.target.value)}
254
+ className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')}
255
+ placeholder="your@email.com"
256
+ />
257
+ {errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
258
+ </div>
259
+
260
+ {/* Name row */}
261
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
262
+ <div>
263
+ <label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
264
+ {t('firstName')} <span className="text-destructive">*</span>
265
+ </label>
266
+ <input
267
+ id="firstName"
268
+ type="text"
269
+ value={formData.firstName}
270
+ onChange={(e) => updateField('firstName', e.target.value)}
271
+ className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')}
272
+ />
273
+ {errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
274
+ </div>
275
+
276
+ <div>
277
+ <label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
278
+ {t('lastName')} <span className="text-destructive">*</span>
279
+ </label>
280
+ <input
281
+ id="lastName"
282
+ type="text"
283
+ value={formData.lastName}
284
+ onChange={(e) => updateField('lastName', e.target.value)}
285
+ className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')}
286
+ />
287
+ {errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
288
+ </div>
289
+ </div>
290
+
291
+ {!emailOnly && (
292
+ <>
293
+ {/* Country + Region row */}
294
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
295
+ <div>
296
+ <label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
297
+ {t('country')} <span className="text-destructive">*</span>
298
+ </label>
299
+ {hasCountryOptions ? (
300
+ <select
301
+ id="country"
302
+ value={formData.country}
303
+ onChange={(e) => updateField('country', e.target.value)}
304
+ className={cn(
305
+ selectClass,
306
+ errors.country ? 'border-destructive' : 'border-border'
307
+ )}
308
+ >
309
+ <option value="">{t('selectCountry')}</option>
310
+ {destinations.countries.map((c) => (
311
+ <option key={c.code} value={c.code}>
312
+ {c.name}
313
+ </option>
314
+ ))}
315
+ </select>
316
+ ) : (
317
+ <input
318
+ id="country"
319
+ type="text"
320
+ value={formData.country}
321
+ onChange={(e) => updateField('country', e.target.value)}
322
+ className={cn(
323
+ inputClass,
324
+ errors.country ? 'border-destructive' : 'border-border'
325
+ )}
326
+ placeholder={t('countryPlaceholder')}
327
+ />
328
+ )}
329
+ {errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
330
+ </div>
331
+
332
+ <div>
333
+ <label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
334
+ {t('stateRegion')}
335
+ </label>
336
+ {hasRegionOptions ? (
337
+ <select
338
+ id="region"
339
+ value={formData.region || ''}
340
+ onChange={(e) => updateField('region', e.target.value)}
341
+ className={cn(selectClass, 'border-border')}
342
+ >
343
+ <option value="">{t('selectRegion')}</option>
344
+ {countryRegions.map((r) => (
345
+ <option key={r.code} value={r.code}>
346
+ {r.name}
347
+ </option>
348
+ ))}
349
+ </select>
350
+ ) : (
351
+ <input
352
+ id="region"
353
+ type="text"
354
+ value={formData.region || ''}
355
+ onChange={(e) => updateField('region', e.target.value)}
356
+ className={cn(inputClass, 'border-border')}
357
+ />
358
+ )}
359
+ </div>
360
+ </div>
361
+
362
+ {/* Address line 1 autocomplete typeahead */}
363
+ <div className="relative">
364
+ <label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
365
+ {t('address')} <span className="text-destructive">*</span>
366
+ </label>
367
+ <input
368
+ id="line1"
369
+ type="text"
370
+ autoComplete="off"
371
+ value={formData.line1}
372
+ onChange={(e) => handleLine1Change(e.target.value)}
373
+ onFocus={() => setShowAddressSuggestions(addressSuggestions.length > 0)}
374
+ onBlur={() => setTimeout(() => setShowAddressSuggestions(false), 150)}
375
+ className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
376
+ placeholder={t('streetAddress')}
377
+ />
378
+ {errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
379
+
380
+ {showAddressSuggestions && addressSuggestions.length > 0 && (
381
+ <ul className="bg-background border-border absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border shadow-lg">
382
+ {addressSuggestions.map((suggestion) => (
383
+ <li key={suggestion.placeId}>
384
+ <button
385
+ type="button"
386
+ // onMouseDown fires before the input's onBlur, so the
387
+ // click registers before the dropdown closes.
388
+ onMouseDown={() => handleSelectAddressSuggestion(suggestion)}
389
+ className="hover:bg-muted w-full px-3 py-2 text-start text-sm"
390
+ >
391
+ {suggestion.description}
392
+ </button>
393
+ </li>
394
+ ))}
395
+ </ul>
396
+ )}
397
+ {isSearchingAddress && (
398
+ <p className="text-muted-foreground mt-1 text-xs">{t('searchingAddress')}</p>
399
+ )}
400
+ {outsideDeliveryZone && (
401
+ <p className="mt-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
402
+ {t('outsideDeliveryZone')}
403
+ </p>
404
+ )}
405
+ </div>
406
+
407
+ {/* Address line 2 */}
408
+ <div>
409
+ <label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
410
+ {t('apartmentSuite')}
411
+ </label>
412
+ <input
413
+ id="line2"
414
+ type="text"
415
+ value={formData.line2 || ''}
416
+ onChange={(e) => updateField('line2', e.target.value)}
417
+ className={cn(inputClass, 'border-border')}
418
+ placeholder={t('aptPlaceholder')}
419
+ />
420
+ </div>
421
+
422
+ {/* City + Postal code row */}
423
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
424
+ <div>
425
+ <label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
426
+ {t('city')} <span className="text-destructive">*</span>
427
+ </label>
428
+ <input
429
+ id="city"
430
+ type="text"
431
+ value={formData.city}
432
+ onChange={(e) => updateField('city', e.target.value)}
433
+ className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
434
+ />
435
+ {errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
436
+ </div>
437
+
438
+ <div>
439
+ <label
440
+ htmlFor="postalCode"
441
+ className="text-foreground mb-1 block text-sm font-medium"
442
+ >
443
+ {t('postalCode')} <span className="text-destructive">*</span>
444
+ </label>
445
+ <input
446
+ id="postalCode"
447
+ type="text"
448
+ value={formData.postalCode}
449
+ onChange={(e) => updateField('postalCode', e.target.value)}
450
+ className={cn(
451
+ inputClass,
452
+ errors.postalCode ? 'border-destructive' : 'border-border'
453
+ )}
454
+ />
455
+ {errors.postalCode && (
456
+ <p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
457
+ )}
458
+ </div>
459
+ </div>
460
+
461
+ {/* Phone */}
462
+ <div>
463
+ <label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
464
+ {t('phone')}
465
+ </label>
466
+ <input
467
+ id="phone"
468
+ type="tel"
469
+ value={formData.phone || ''}
470
+ onChange={(e) => updateField('phone', e.target.value)}
471
+ className={cn(inputClass, 'border-border')}
472
+ placeholder={t('phonePlaceholder')}
473
+ />
474
+ </div>
475
+ </>
476
+ )}
477
+
478
+ {/* Order notes (optional) */}
479
+ <div>
480
+ <label htmlFor="orderNotes" className="text-foreground mb-1 block text-sm font-medium">
481
+ {t('orderNotes')}
482
+ </label>
483
+ <textarea
484
+ id="orderNotes"
485
+ value={formData.notes || ''}
486
+ onChange={(e) => updateField('notes', e.target.value)}
487
+ maxLength={2000}
488
+ rows={3}
489
+ className={cn(
490
+ inputClass,
491
+ 'border-border h-auto min-h-[80px] resize-y py-2 leading-relaxed'
492
+ )}
493
+ placeholder={t('orderNotesPlaceholder')}
494
+ />
495
+ </div>
496
+
497
+ {/* Privacy Policy (required) */}
498
+ <div>
499
+ <label className="flex cursor-pointer items-start gap-2">
500
+ <input
501
+ type="checkbox"
502
+ checked={privacyAccepted}
503
+ onChange={(e) => {
504
+ setPrivacyAccepted(e.target.checked);
505
+ if (e.target.checked && errors.privacy) {
506
+ setErrors((prev) => {
507
+ const next = { ...prev };
508
+ delete next.privacy;
509
+ return next;
510
+ });
511
+ }
512
+ }}
513
+ className="accent-primary mt-0.5"
514
+ />
515
+ <span className="text-muted-foreground text-sm">
516
+ {t('privacyAcceptPrefix')}{' '}
517
+ <a
518
+ href="/privacy"
519
+ target="_blank"
520
+ rel="noopener noreferrer"
521
+ className="text-primary underline underline-offset-2"
522
+ >
523
+ {t('privacyPolicyLink')}
524
+ </a>{' '}
525
+ <span className="text-destructive">*</span>
526
+ </span>
527
+ </label>
528
+ {errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
529
+ </div>
530
+
531
+ {/* Marketing consent (optional) */}
532
+ <label className="flex cursor-pointer items-start gap-2">
533
+ <input
534
+ type="checkbox"
535
+ checked={acceptsMarketing}
536
+ onChange={(e) => setAcceptsMarketing(e.target.checked)}
537
+ className="accent-primary mt-0.5"
538
+ />
539
+ <span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
540
+ </label>
541
+
542
+ {/* Save details for next time (logged-in users only) */}
543
+ {showSaveDetails && (
544
+ <label className="flex cursor-pointer items-start gap-2">
545
+ <input
546
+ type="checkbox"
547
+ checked={saveDetails}
548
+ onChange={(e) => setSaveDetails(e.target.checked)}
549
+ className="accent-primary mt-0.5"
550
+ />
551
+ <span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
552
+ </label>
553
+ )}
554
+
555
+ <button
556
+ type="submit"
557
+ disabled={loading}
558
+ 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"
559
+ >
560
+ {loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
561
+ </button>
562
+ </form>
563
+ );
564
+ }