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:
Matt Ciaccio
2026-04-28 18:13:08 +02:00
parent f52d21df83
commit 16d98d630e
44 changed files with 12768 additions and 67 deletions

View File

@@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialInterestSummary {
id: string;
@@ -29,7 +34,13 @@ interface ResidentialClientDetail {
fullName: string;
email: string | null;
phone: string | null;
phoneE164: string | null;
phoneCountry: string | null;
nationalityIso: string | null;
timezone: string | null;
placeOfResidence: string | null;
placeOfResidenceCountryIso: string | null;
subdivisionIso: string | null;
preferredContactMethod: string | null;
status: string;
source: string | null;
@@ -130,7 +141,17 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
<InlineEditableField value={client.email} onSave={save('email')} />
</Row>
<Row label="Phone">
<InlineEditableField value={client.phone} onSave={save('phone')} />
<InlinePhoneField
e164={client.phoneE164}
country={client.phoneCountry}
onSave={async ({ e164, country }) => {
await update.mutateAsync({
phone: e164,
phoneE164: e164,
phoneCountry: country,
});
}}
/>
</Row>
<Row label="Preferred contact">
<InlineEditableField
@@ -140,12 +161,50 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
onSave={save('preferredContactMethod')}
/>
</Row>
<Row label="Nationality">
<InlineCountryField
value={client.nationalityIso}
onSave={async (iso) => {
await update.mutateAsync({ nationalityIso: iso });
}}
/>
</Row>
<Row label="Timezone">
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await update.mutateAsync({ timezone: tz });
}}
/>
</Row>
<Row label="Place of residence">
<InlineEditableField
value={client.placeOfResidence}
onSave={save('placeOfResidence')}
/>
</Row>
<Row label="Country of residence">
<InlineCountryField
value={client.placeOfResidenceCountryIso}
onSave={async (iso) => {
// When country flips, clear the subdivision — codes are country-scoped.
await update.mutateAsync({
placeOfResidenceCountryIso: iso,
subdivisionIso: null,
});
}}
/>
</Row>
<Row label="Region">
<SubdivisionCombobox
value={client.subdivisionIso}
onChange={(code) => {
void update.mutateAsync({ subdivisionIso: code });
}}
country={(client.placeOfResidenceCountryIso as CountryCode | null) ?? null}
/>
</Row>
</div>
<div className="space-y-1">

View File

@@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { PageHeader } from '@/components/shared/page-header';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialClientRow {
id: string;
@@ -147,10 +152,26 @@ function NewResidentialClientSheet({
const qc = useQueryClient();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
const [nationalityIso, setNationalityIso] = useState<CountryCode | null>(null);
const [timezone, setTimezone] = useState<string | null>(null);
const [placeOfResidence, setPlaceOfResidence] = useState('');
const [residenceCountry, setResidenceCountry] = useState<CountryCode | null>(null);
const [residenceSubdivision, setResidenceSubdivision] = useState<string | null>(null);
const [notes, setNotes] = useState('');
function reset() {
setFullName('');
setEmail('');
setPhone(null);
setNationalityIso(null);
setTimezone(null);
setPlaceOfResidence('');
setResidenceCountry(null);
setResidenceSubdivision(null);
setNotes('');
}
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/clients', {
@@ -158,8 +179,14 @@ function NewResidentialClientSheet({
body: {
fullName,
email: email || undefined,
phone: phone || undefined,
phone: phone?.e164 ?? undefined,
phoneE164: phone?.e164 ?? undefined,
phoneCountry: phone?.country ?? undefined,
nationalityIso: nationalityIso ?? undefined,
timezone: timezone ?? undefined,
placeOfResidence: placeOfResidence || undefined,
placeOfResidenceCountryIso: residenceCountry ?? undefined,
subdivisionIso: residenceSubdivision ?? undefined,
notes: notes || undefined,
source: 'manual',
},
@@ -167,11 +194,7 @@ function NewResidentialClientSheet({
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-clients'] });
onOpenChange(false);
setFullName('');
setEmail('');
setPhone('');
setPlaceOfResidence('');
setNotes('');
reset();
toast.success('Residential client added');
},
onError: (err) => {
@@ -212,7 +235,28 @@ function NewResidentialClientSheet({
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-phone">Phone</Label>
<Input id="rc-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
<PhoneInput id="rc-phone" value={phone} onChange={setPhone} data-testid="rc-phone" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="rc-nationality">Nationality</Label>
<CountryCombobox
id="rc-nationality"
value={nationalityIso}
onChange={setNationalityIso}
data-testid="rc-nationality"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-timezone">Timezone</Label>
<TimezoneCombobox
id="rc-timezone"
value={timezone}
onChange={setTimezone}
countryHint={nationalityIso ?? undefined}
data-testid="rc-timezone"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence">Place of residence</Label>
@@ -220,8 +264,34 @@ function NewResidentialClientSheet({
id="rc-residence"
value={placeOfResidence}
onChange={(e) => setPlaceOfResidence(e.target.value)}
placeholder="City or area"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="rc-residence-country">Country</Label>
<CountryCombobox
id="rc-residence-country"
value={residenceCountry}
onChange={(iso) => {
setResidenceCountry(iso);
// Wipe subdivision when country flips — codes are scoped per country.
setResidenceSubdivision(null);
}}
data-testid="rc-residence-country"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence-subdivision">Region</Label>
<SubdivisionCombobox
id="rc-residence-subdivision"
value={residenceSubdivision}
onChange={setResidenceSubdivision}
country={residenceCountry}
data-testid="rc-residence-subdivision"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-notes">Notes</Label>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />