'use client'; import { useState, useEffect, useRef } from 'react'; import type { SetShippingAddressDto, ShippingDestinations, AddressSuggestion } from 'brainerce'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; import { isValidEmail } from '@/core/lib/validation'; import { getClient } from '@/core/lib/brainerce'; import { useRegion } from '@/core/providers/store-provider'; // Debounce address-suggestion calls rather than firing on every keystroke — // both to avoid a jumpy dropdown and to keep autocomplete-session call volume // reasonable (see `getAddressSuggestions` doc in the SDK). const ADDRESS_SEARCH_DEBOUNCE_MS = 300; const ADDRESS_SEARCH_MIN_CHARS = 3; // Editing any of these invalidates a previously picked suggestion: its // coordinates describe the address the shopper selected, not what is in the // inputs now. See `pickedPlace` below. const PLACE_INVALIDATING_FIELDS = new Set([ 'line1', 'line2', 'city', 'region', 'postalCode', 'country', ]); interface CheckoutConsent { acceptsMarketing: boolean; saveDetails: boolean; } interface CheckoutFormProps { onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void; loading?: boolean; initialValues?: Partial; destinations?: ShippingDestinations | null; className?: string; showSaveDetails?: boolean; emailOnly?: boolean; } export function CheckoutForm({ onSubmit, loading = false, initialValues, destinations, className, showSaveDetails = false, emailOnly = false, }: CheckoutFormProps) { const [formData, setFormData] = useState({ email: initialValues?.email || '', firstName: initialValues?.firstName || '', lastName: initialValues?.lastName || '', line1: initialValues?.line1 || '', line2: initialValues?.line2 || '', city: initialValues?.city || '', region: initialValues?.region || '', postalCode: initialValues?.postalCode || '', country: initialValues?.country || '', phone: initialValues?.phone || '', notes: initialValues?.notes || '', }); const [errors, setErrors] = useState>({}); const [privacyAccepted, setPrivacyAccepted] = useState(false); const [acceptsMarketing, setAcceptsMarketing] = useState(false); const [saveDetails, setSaveDetails] = useState(true); const t = useTranslations('checkoutForm'); const tc = useTranslations('common'); // Address resolution is region-scoped too: the same lookup can price and // zone differently per region. `undefined` on a store with no regions, and // the SDK omits the query param. const { regionId } = useRegion(); const hasAppliedPrefill = useRef(!!initialValues); // Address-autocomplete state — groups one address-entry attempt for // session-token billing (see `getAddressSuggestions` in the SDK). A fresh // token is minted whenever a suggestion is picked, ending that session. const [addressSuggestions, setAddressSuggestions] = useState([]); const [showAddressSuggestions, setShowAddressSuggestions] = useState(false); const [isSearchingAddress, setIsSearchingAddress] = useState(false); const [outsideDeliveryZone, setOutsideDeliveryZone] = useState(false); // The suggestion the shopper actually picked, kept so it can ride along to // `setShippingAddress`. That is what lets the server match map-drawn // ("polygon") delivery zones against this address's exact coordinates // instead of geocoding the typed text — skipping it is the most common // cause of "the store says it doesn't deliver here, but it does". Null // whenever the address was typed by hand or edited after picking. const [pickedPlace, setPickedPlace] = useState<{ placeId: string; sessionToken: string; } | null>(null); const addressSessionToken = useRef( typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}` ); const addressSearchTimer = useRef | null>(null); useEffect(() => { return () => { if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current); }; }, []); function handleLine1Change(value: string) { updateField('line1', value); setOutsideDeliveryZone(false); if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current); const query = value.trim(); if (query.length < ADDRESS_SEARCH_MIN_CHARS) { setAddressSuggestions([]); setShowAddressSuggestions(false); return; } addressSearchTimer.current = setTimeout(async () => { setIsSearchingAddress(true); try { const results = await getClient().getAddressSuggestions(query, addressSessionToken.current); setAddressSuggestions(results); setShowAddressSuggestions(results.length > 0); } catch { // Autocomplete is a soft enhancement — a failed lookup just means no // suggestions this keystroke; the shopper can keep typing manually. setAddressSuggestions([]); setShowAddressSuggestions(false); } finally { setIsSearchingAddress(false); } }, ADDRESS_SEARCH_DEBOUNCE_MS); } async function handleSelectAddressSuggestion(suggestion: AddressSuggestion) { setShowAddressSuggestions(false); // The token this resolution is billed under — hand the SAME one to // `setShippingAddress` so a server-side cache miss re-resolves within this // autocomplete session instead of opening a new (billed) one. const resolvingSessionToken = addressSessionToken.current; try { const { address, inZone } = await getClient().getAddressDetails( suggestion.placeId, resolvingSessionToken, { regionId } ); setFormData((prev) => { const country = address.country || prev.country; // Only accept address.region if it's one of THIS store's known // region codes for the resolved country (destinations.regions — // the same list the dropdown below renders, backed by our own // geo-data, not Google's). Google's region code usually lines up // (both ultimately trace back to ISO 3166-2), but never assign a // value the dropdown wouldn't recognize — better an unselected // dropdown the shopper fills in than a silently-wrong one. const validRegions = destinations?.regions[country] ?? []; const resolvedRegionValid = validRegions.some((r) => r.code === address.region); return { ...prev, line1: address.line1 || prev.line1, city: address.city || prev.city, region: resolvedRegionValid ? address.region : country !== prev.country ? '' : prev.region, postalCode: address.postalCode || prev.postalCode, country, }; }); setOutsideDeliveryZone(!inZone); // Note we deliberately do NOT copy address.lat/lng into the form: no // address endpoint accepts coordinates (zone matching decides which rate // is charged, so the server resolves them from the placeId itself). // They're yours to use for a map pin and nothing else. setPickedPlace({ placeId: suggestion.placeId, sessionToken: resolvingSessionToken }); } catch { // Resolution failed — keep whatever the shopper had typed/selected as // the visible text; they can still fill in the rest manually. setPickedPlace(null); } finally { // New session for the next address-entry attempt. addressSessionToken.current = typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; setAddressSuggestions([]); } } // Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData) useEffect(() => { if (!initialValues || hasAppliedPrefill.current) return; hasAppliedPrefill.current = true; setFormData((prev) => ({ email: initialValues.email || prev.email, firstName: initialValues.firstName || prev.firstName, lastName: initialValues.lastName || prev.lastName, line1: initialValues.line1 || prev.line1, line2: initialValues.line2 || prev.line2 || '', city: initialValues.city || prev.city, region: initialValues.region || prev.region || '', postalCode: initialValues.postalCode || prev.postalCode, country: initialValues.country || prev.country, phone: initialValues.phone || prev.phone || '', notes: prev.notes || '', })); }, [initialValues]); const hasCountryOptions = destinations && destinations.countries.length > 0; const countryRegions = destinations?.regions[formData.country]; const hasRegionOptions = countryRegions && countryRegions.length > 0; function validate(): boolean { const newErrors: Record = {}; if (!formData.email.trim()) { newErrors.email = t('emailRequired'); } else if (!isValidEmail(formData.email.trim())) { newErrors.email = t('emailInvalid'); } if (!formData.firstName.trim()) { newErrors.firstName = t('firstNameRequired'); } if (!formData.lastName.trim()) { newErrors.lastName = t('lastNameRequired'); } if (!emailOnly) { if (!formData.line1.trim()) { newErrors.line1 = t('addressRequired'); } if (!formData.city.trim()) { newErrors.city = t('cityRequired'); } if (!formData.postalCode.trim()) { newErrors.postalCode = t('postalCodeRequired'); } if (!formData.country.trim()) { newErrors.country = t('countryRequired'); } } if (!privacyAccepted) { newErrors.privacy = t('privacyRequired'); } setErrors(newErrors); return Object.keys(newErrors).length === 0; } function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (validate()) { onSubmit( { ...formData, // Both undefined when the shopper typed the address by hand or // edited it after picking a suggestion — the server then geocodes // the text, which is the right behaviour once the two disagree. placeId: pickedPlace?.placeId, placeSessionToken: pickedPlace?.sessionToken, }, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails } ); } } function updateField(field: keyof SetShippingAddressDto, value: string) { setFormData((prev) => { const next = { ...prev, [field]: value }; // Reset region when country changes if (field === 'country' && value !== prev.country) { next.region = ''; } return next; }); // Once any address field is edited, the picked suggestion no longer // describes what is in the form — keeping its placeId would match delivery // zones against the address the shopper originally selected. if (PLACE_INVALIDATING_FIELDS.has(field)) { setPickedPlace(null); } if (errors[field]) { setErrors((prev) => { const next = { ...prev }; delete next[field]; return next; }); } } const inputClass = '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'; const selectClass = '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'; return (
{/* Email */}
updateField('email', e.target.value)} className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')} placeholder="your@email.com" /> {errors.email &&

{errors.email}

}
{/* Name row */}
updateField('firstName', e.target.value)} className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')} /> {errors.firstName &&

{errors.firstName}

}
updateField('lastName', e.target.value)} className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')} /> {errors.lastName &&

{errors.lastName}

}
{!emailOnly && ( <> {/* Country + Region row */}
{hasCountryOptions ? ( ) : ( updateField('country', e.target.value)} className={cn( inputClass, errors.country ? 'border-destructive' : 'border-border' )} placeholder={t('countryPlaceholder')} /> )} {errors.country &&

{errors.country}

}
{hasRegionOptions ? ( ) : ( updateField('region', e.target.value)} className={cn(inputClass, 'border-border')} /> )}
{/* Address line 1 — autocomplete typeahead */}
handleLine1Change(e.target.value)} onFocus={() => setShowAddressSuggestions(addressSuggestions.length > 0)} onBlur={() => setTimeout(() => setShowAddressSuggestions(false), 150)} className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')} placeholder={t('streetAddress')} /> {errors.line1 &&

{errors.line1}

} {showAddressSuggestions && addressSuggestions.length > 0 && (
    {addressSuggestions.map((suggestion) => (
  • ))}
)} {isSearchingAddress && (

{t('searchingAddress')}

)} {outsideDeliveryZone && (

{t('outsideDeliveryZone')}

)}
{/* Address line 2 */}
updateField('line2', e.target.value)} className={cn(inputClass, 'border-border')} placeholder={t('aptPlaceholder')} />
{/* City + Postal code row */}
updateField('city', e.target.value)} className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')} /> {errors.city &&

{errors.city}

}
updateField('postalCode', e.target.value)} className={cn( inputClass, errors.postalCode ? 'border-destructive' : 'border-border' )} /> {errors.postalCode && (

{errors.postalCode}

)}
{/* Phone */}
updateField('phone', e.target.value)} className={cn(inputClass, 'border-border')} placeholder={t('phonePlaceholder')} />
)} {/* Order notes (optional) */}