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>
This commit is contained in:
144
src/components/shared/phone-input.tsx
Normal file
144
src/components/shared/phone-input.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user