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:
@@ -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">
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
Reference in New Issue
Block a user