'use client'; import { useEffect, useRef, useState } from 'react'; import { Input } from '@/components/ui/input'; import { CountryCombobox } from '@/components/shared/country-combobox'; import { cn } from '@/lib/utils'; import { callingCodeFor, formatAsYouType, parsePhone } from '@/lib/i18n/phone'; import { detectDefaultCountry, type CountryCode } from '@/lib/i18n/countries'; export interface PhoneInputValue { /** E.164 form ('+442079460958'). Null when empty or unparseable. */ e164: string | null; /** Country selected in the dropdown — drives the AsYouType formatter. */ country: CountryCode; } interface PhoneInputProps { value: PhoneInputValue | null | undefined; onChange: (next: PhoneInputValue) => void; /** Pre-selects the dropdown when `value.country` isn't supplied. */ defaultCountry?: CountryCode; placeholder?: string; disabled?: boolean; required?: boolean; invalid?: boolean; id?: string; 'data-testid'?: string; } /** * Phone input with a country flag dropdown + format-as-you-type. * * Wire shape: emits `{ e164, country }` on every change. E.164 is null * while the input is too short to parse — that's a form-validation * concern, not an input concern. Pasting an international number * (`+1 415…`) auto-switches the country dropdown to match. * * Implementation notes: * - The visible string is always the AsYouType national-format, * so users see e.g. "020 7946 0958" while typing GB digits. * - The `country` prop drives the AsYouType context; flipping * countries reformats the same digits against the new country * (matches what users expect when they fix a wrong country pick). */ export function PhoneInput({ value, onChange, defaultCountry, placeholder = 'Phone number', disabled, required, invalid, id, 'data-testid': testId, }: PhoneInputProps) { const [country, setCountry] = useState( () => value?.country ?? defaultCountry ?? detectDefaultCountry('US'), ); const [display, setDisplay] = useState(() => { if (!value?.e164) return ''; const parsed = parsePhone(value.e164, value.country); return parsed.national ?? ''; }); // Track whether the user has typed since mount — keeps a controlled-from-props // value sync on first render only. const initialized = useRef(false); useEffect(() => { if (initialized.current) return; initialized.current = true; }, []); function emit(rawDigits: string, currentCountry: CountryCode) { const parsed = parsePhone(rawDigits, currentCountry); onChange({ e164: parsed.e164, country: currentCountry }); } function handleInput(raw: string) { // Paste-detect: if user pasted an international format, parse it // and flip the country dropdown to match — better UX than asking // them to also click the dropdown. if (raw.startsWith('+')) { const parsed = parsePhone(raw); if (parsed.country) { setCountry(parsed.country); const reformatted = formatAsYouType(raw, parsed.country); setDisplay(reformatted); emit(raw, parsed.country); return; } } const formatted = formatAsYouType(raw, country); setDisplay(formatted); emit(formatted, country); } function handleCountryChange(next: CountryCode | null) { if (!next) return; setCountry(next); // Re-run the formatter against the new country so the visible // string stays consistent with the dropdown. const reformatted = formatAsYouType(display, next); setDisplay(reformatted); emit(reformatted, next); } return (
{callingCodeFor(country)} handleInput(e.target.value)} // Strip the inner Input's own border so it sits flush with the country prefix. className="flex-1 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" data-testid={testId} />
); }