Files
pn-new-crm/src/components/shared/phone-input.tsx

145 lines
4.8 KiB
TypeScript
Raw Normal View History

feat(i18n): country/phone/timezone/subdivision primitives + form wiring Cross-cutting i18n polish for forms across the marina + residential + company domains. Introduces a single source of truth for country/phone/timezone/ subdivision data and replaces every nationality-as-free-text and timezone- as-string Input with a dedicated combobox. PR1 Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames for localized labels, detectDefaultCountry() with navigator-region fallback to US, CountryCombobox with regional-indicator flag glyphs + compact mode for inline use. PR2 Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType / callingCodeFor), PhoneInput with flag dropdown + national-format AsYouType + paste-detect that flips the country dropdown for pasted international strings. PR3 Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/ ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"), TimezoneCombobox with Suggested/All grouping driven by countryHint. PR4 Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2 codes for every country), per-country cache, SubdivisionCombobox with "Pick a country first" / "No regions available" empty states. PR5 Schema deltas (migration 0015) — clients.nationality_iso, clientContacts {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso}, residentialClients {phone_e164, phone_country, nationality_iso, timezone, place_of_residence_country_iso, subdivision_iso}, companies {incorporation_ country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso, subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used by every entity validator + route handler. PR6 ClientForm + ClientDetail — CountryCombobox replaces nationality Input, TimezoneCombobox replaces timezone Input (driven by nationalityIso hint), PhoneInput conditionally rendered for phone/whatsapp contacts. Inline editors (InlineCountryField / InlineTimezoneField / InlinePhoneField) for the detail-page overview rows + ContactsEditor. PR7 Residential client form + detail — phone -> PhoneInput, nationality/ timezone/place-of-residence-country/subdivision rows in both create sheet and inline-editable detail view. Subdivision wipes when country flips since codes are country-scoped. PR8 Company form + detail — incorporation country -> CountryCombobox, incorporation region -> SubdivisionCombobox in both modes. PR9 Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry and i18n fields from newer website builds, server-side parsePhone() fallback for legacy raw-international submissions. Old Nuxt builds keep working unchanged. Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for the public phone-normalization path (3 tests), 1 smoke spec asserting the combobox triggers render in all three create sheets. Test totals: vitest 713 -> 741 (+28). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
'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<CountryCode>(
() => 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 (
<div
className={cn(
'flex items-stretch gap-1.5',
invalid && '[&_input]:border-destructive [&_button[role=combobox]]:border-destructive',
)}
>
<CountryCombobox
value={country}
onChange={handleCountryChange}
compact
clearable={false}
disabled={disabled}
data-testid={testId ? `${testId}-country` : undefined}
/>
<div className="flex flex-1 items-stretch overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
<span className="flex select-none items-center bg-muted/40 px-2 text-sm tabular-nums text-muted-foreground">
{callingCodeFor(country)}
</span>
<Input
id={id}
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder={placeholder}
value={display}
disabled={disabled}
required={required}
onChange={(e) => 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}
/>
</div>
</div>
);
}