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

@@ -20,8 +20,12 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { PhoneInput } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
import type { CountryCode } from '@/lib/i18n/countries';
interface ClientFormProps {
open: boolean;
@@ -31,6 +35,7 @@ interface ClientFormProps {
id: string;
fullName: string;
nationality?: string | null;
nationalityIso?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
@@ -39,6 +44,8 @@ interface ClientFormProps {
contacts?: Array<{
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string | null;
isPrimary?: boolean;
notes?: string | null;
@@ -77,6 +84,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
reset({
fullName: client.fullName,
nationality: client.nationality ?? undefined,
nationalityIso: client.nationalityIso ?? undefined,
preferredContactMethod:
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
undefined,
@@ -89,6 +97,8 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
? client.contacts.map((c) => ({
channel: c.channel as 'email' | 'phone' | 'whatsapp' | 'other',
value: c.value,
valueE164: c.valueE164 ?? undefined,
valueCountry: c.valueCountry ?? undefined,
label: c.label ?? undefined,
isPrimary: c.isPrimary ?? false,
notes: c.notes ?? undefined,
@@ -152,7 +162,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
<div className="space-y-1">
<Label>Nationality</Label>
<Input {...register('nationality')} placeholder="British" />
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
</div>
</div>
@@ -211,11 +225,40 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
<div className="col-span-5 space-y-1">
<Label className="text-xs">Value</Label>
<Input
{...register(`contacts.${index}.value`)}
className="h-8"
placeholder="email@example.com"
/>
{(() => {
const channel = watch(`contacts.${index}.channel`);
if (channel === 'phone' || channel === 'whatsapp') {
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
const country =
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
undefined;
return (
<PhoneInput
value={
e164 || country
? {
e164: e164 ?? null,
country: country ?? 'US',
}
: null
}
onChange={(v) => {
setValue(`contacts.${index}.value`, v.e164 ?? '');
setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined);
setValue(`contacts.${index}.valueCountry`, v.country);
}}
data-testid={`contact-${index}-phone`}
/>
);
}
return (
<Input
{...register(`contacts.${index}.value`)}
className="h-8"
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
/>
);
})()}
</div>
<div className="col-span-2 space-y-1">
@@ -304,7 +347,12 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<Input {...register('timezone')} placeholder="UTC+0" />
<TimezoneCombobox
value={watch('timezone')}
onChange={(tz) => setValue('timezone', tz ?? undefined)}
countryHint={(watch('nationalityIso') as CountryCode | undefined) ?? undefined}
data-testid="client-timezone"
/>
</div>
<div className="col-span-2 space-y-1">
<Label>Source Details</Label>