'use client'; import { useState, useMemo, useEffect } from 'react'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { getPasswordError } from '@/core/lib/validation'; import { useStoreInfo } from '@/core/providers/store-provider'; import { toBirthdayNumber } from '@/core/lib/birthday'; import { BirthdayPicker } from '@/components/shared/birthday-picker'; import { readReferralCookie } from '@/core/lib/referral'; import { useStoreCapabilities } from '@/core/providers/store-provider'; interface RegisterData { firstName: string; lastName: string; email: string; password: string; acceptsMarketing: boolean; /** * Birthday month and day, never a year. Sent together or left out entirely: * one without the other is rejected with HTTP 400. Omitted rather than * nulled when the shopper leaves the field empty, because `registerCustomer()` * takes numbers only. */ birthMonth?: number; birthDay?: number; /** * Loyalty referral share code, captured from a `?ref=` link that may have * been clicked days ago. Omitted when nothing was captured; never invented. */ referralCode?: string; } interface RegisterFormProps { onSubmit: (data: RegisterData) => Promise; error?: string | null; className?: string; } function getPasswordStrength(password: string): { label: string; color: string; width: string } { if (password.length === 0) return { label: '', color: '', width: 'w-0' }; if (password.length < 8) return { label: 'tooShort', color: 'bg-destructive', width: 'w-1/4' }; let score = 0; if (password.length >= 8) score++; if (/[A-Z]/.test(password)) score++; if (/[0-9]/.test(password)) score++; if (/[^A-Za-z0-9]/.test(password)) score++; if (score <= 1) return { label: 'weak', color: 'bg-orange-500', width: 'w-1/3' }; if (score <= 2) return { label: 'fair', color: 'bg-yellow-500', width: 'w-1/2' }; if (score <= 3) return { label: 'good', color: 'bg-primary', width: 'w-3/4' }; return { label: 'strong', color: 'bg-green-500', width: 'w-full' }; } export function RegisterForm({ onSubmit, error, className }: RegisterFormProps) { const t = useTranslations('auth'); const tf = useTranslations('checkoutForm'); const tc = useTranslations('common'); const { storeInfo } = useStoreInfo(); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [privacyAccepted, setPrivacyAccepted] = useState(false); const [privacyError, setPrivacyError] = useState(false); const [passwordError, setPasswordError] = useState(null); const [acceptsMarketing, setAcceptsMarketing] = useState(false); const { capabilities } = useStoreCapabilities(); /** * Whether to promise a birthday gift beside the picker. * * The gate is inverted relative to `ReferralGreeting`, and deliberately so. * There, an undecided capability must not suppress a real referral, so only * an explicit `false` hides anything. Here the text makes a PROMISE, and a * promise may only be printed on an explicit `true`: a store with birthday * rewards switched off — which is the default — would otherwise tell every * customer a gift is coming and send nothing, and `requireBirthday` can make * handing over the date compulsory on top of that. */ const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true; const [birthMonth, setBirthMonth] = useState(''); const [birthDay, setBirthDay] = useState(''); const [birthdayError, setBirthdayError] = useState(null); const [loading, setLoading] = useState(false); const [referralCode, setReferralCode] = useState(null); /** * A referral code captured from a `?ref=` link, possibly on a different day. * Read in an effect rather than during render because it comes from * `document.cookie`, which does not exist during SSR: reading it inline would * render one thing on the server and another on the client and trip a * hydration mismatch. */ useEffect(() => { setReferralCode(readReferralCookie()); }, []); /** * The merchant can make the birthday mandatory at registration on this sales * channel; the backend then rejects a register call without it. The flag is a * rendering hint only, and absent means optional, so the fields stay optional * unless it is explicitly true. */ const birthdayRequired = storeInfo?.requireBirthday === true; const strength = useMemo(() => getPasswordStrength(password), [password]); /** * The picker hands back both halves at once, or two nulls when the shopper * clears the field. State stays as strings so the submit path below is * unchanged. */ function selectBirthday(month: number | null, day: number | null) { setBirthMonth(month ? String(month) : ''); setBirthDay(day ? String(day) : ''); setBirthdayError(null); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (loading) return; const pwCode = getPasswordError(password); if (pwCode) { setPasswordError(t(pwCode)); return; } setPasswordError(null); const month = toBirthdayNumber(birthMonth); const day = toBirthdayNumber(birthDay); if (birthdayRequired && (month === null || day === null)) { setBirthdayError(tc('birthdayRequired')); return; } // Half a birthday is an HTTP 400 at the API, so catch it here rather than // letting the shopper find out from a failed registration. if ((month === null) !== (day === null)) { setBirthdayError(tc('birthdayIncomplete')); return; } setBirthdayError(null); if (!privacyAccepted) { setPrivacyError(true); return; } try { setLoading(true); await onSubmit({ firstName, lastName, email, password, acceptsMarketing, // Omitted, never nulled, when the shopper left the field empty: // `registerCustomer()` takes numbers only. ...(month !== null && day !== null ? { birthMonth: month, birthDay: day } : {}), // Same rule for the referral: send it only when one was captured. The // API validates it after the fact, so a stale code cannot cost this // shopper their registration. ...(referralCode ? { referralCode } : {}), }); } finally { setLoading(false); } } return (
{error && (
{error}
)} {/* A referral captured earlier is about to be applied. Worth saying out loud: the shopper was promised a welcome gift on a page they may have left days ago, and silence here reads as the offer having expired. Renders nothing when no code was captured. */} {referralCode && (

{t('referralApplied')}

)}
setFirstName(e.target.value)} placeholder={t('firstNamePlaceholder')} autoComplete="given-name" className="border-border 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" />
setLastName(e.target.value)} placeholder={t('lastNamePlaceholder')} autoComplete="family-name" className="border-border 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" />
setEmail(e.target.value)} placeholder={t('emailPlaceholder')} autoComplete="email" className="border-border 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" />
{ setPassword(e.target.value); if (passwordError) setPasswordError(null); }} placeholder={t('atLeastChars')} autoComplete="new-password" className="border-border 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" /> {password.length > 0 && (

{strength.label ? t(strength.label as 'tooShort' | 'weak' | 'fair' | 'good' | 'strong') : ''}

)} {passwordError &&

{passwordError}

}
{/* Birthday: month and day only, never a year. Optional unless the merchant made it mandatory on this sales channel. */}
{birthdayGiftOn && (

{tc('birthdayGiftNote')}

)} {birthdayError &&

{birthdayError}

}
{/* Privacy Policy (required) */}
{privacyError &&

{t('privacyRequired')}

}
{/* Marketing consent (optional) */} ); }