172 lines
11 KiB
Markdown
172 lines
11 KiB
Markdown
|
|
# Country / Phone / Timezone — i18n form polish
|
|||
|
|
|
|||
|
|
**Status:** Agenda — awaiting prioritization (likely Phase B or B.5)
|
|||
|
|
**Date:** 2026-04-28
|
|||
|
|
**Phase:** Cross-cutting; touches every form that captures contact data
|
|||
|
|
|
|||
|
|
## Why
|
|||
|
|
|
|||
|
|
Today every CRM form takes free-text strings for nationality, phone, and timezone. That's fine for a marina with one operator typing it in once, but it leaks operator inconsistencies into reports and breaks any later system that consumes these fields (Documenso prefill, public website inquiry, portal sync, exports). For a multi-port platform that's about to onboard non-Polish-speaking residential clients, the data quality matters.
|
|||
|
|
|
|||
|
|
Three coupled UX upgrades:
|
|||
|
|
|
|||
|
|
1. **Nationality → ISO-3166 country dropdown.** Searchable. Stores ISO alpha-2 code (`'GB'`), displays localized country name.
|
|||
|
|
2. **Phone → country-code dropdown + format-as-you-type.** E.164 storage on the wire, formatted display per country.
|
|||
|
|
3. **Timezone → autofilled from country with override dropdown.** Most countries are single-zone; the few that aren't (US, RU, AU, BR, CA, ID, KZ, MN, MX, CD) get a sub-select. Stores IANA TZ string (`'Europe/Warsaw'`).
|
|||
|
|
|
|||
|
|
## Scope
|
|||
|
|
|
|||
|
|
### In scope
|
|||
|
|
|
|||
|
|
- New shared primitives: `<CountryCombobox>`, `<PhoneInput>`, `<TimezoneCombobox>`
|
|||
|
|
- ISO-3166 country list bundled (no API call); names from `Intl.DisplayNames` with locale fallback to English
|
|||
|
|
- Country → primary IANA timezone map (~250 entries, JSON)
|
|||
|
|
- Phone parsing/validation/formatting via `libphonenumber-js` (server + client)
|
|||
|
|
- Wire into every form that captures contact data:
|
|||
|
|
- `<ClientForm>` (name, nationality, phone)
|
|||
|
|
- `<ResidentialClientDetail>` inline editor (nationality, phone, place_of_residence — country-aware)
|
|||
|
|
- `<CompanyForm>` (incorporation_country)
|
|||
|
|
- `<PortalActivateForm>` (phone)
|
|||
|
|
- public inquiry form (form-template renderer, when phone field present)
|
|||
|
|
- DB migration: store ISO codes (`countries`, `nationality_iso`), E.164 phone (`phone_e164`), IANA timezone (`timezone`)
|
|||
|
|
- Backfill: best-effort parse existing free-text into the new columns; keep originals as `_legacy` for one release cycle
|
|||
|
|
- Display: localized country name in tables/detail pages; phone formatted per country (e.g. `+44 20 7946 0958`); timezone shown as friendly `'London (UTC+1)'` when current
|
|||
|
|
- Tests: unit (parser edge cases), integration (form submit → E.164 storage), smoke (typing + selecting flows)
|
|||
|
|
|
|||
|
|
### Out of scope (deferred)
|
|||
|
|
|
|||
|
|
- Multilingual UI surface (only the country _names_ localize via `Intl.DisplayNames`; rest of the UI stays English for now)
|
|||
|
|
- Subdivision picker (states/provinces) — only top-level country
|
|||
|
|
- Phone number geocoding / carrier lookup
|
|||
|
|
- Address autocomplete (Google Places, etc.)
|
|||
|
|
- Currency localization
|
|||
|
|
- RTL layout
|
|||
|
|
|
|||
|
|
## Library choices
|
|||
|
|
|
|||
|
|
| Concern | Library | Why |
|
|||
|
|
| --------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|||
|
|
| Phone input + flag dropdown | `omeralpi/shadcn-phone-input` | Built on shadcn-ui's `Input` primitive (zero styling friction with our component library), wraps `libphonenumber-js`, ships with country dropdown + format-as-you-type. Small bundle. |
|
|||
|
|
| Phone parsing/validation | `libphonenumber-js` | Google's library, ~88 benchmark, used by every popular React phone input. Server-side validation in zod. |
|
|||
|
|
| Country list | Bundled JSON of ISO-3166 alpha-2 codes + 3-letter codes + display names (English baseline) | No need for the heavier `country-state-city` databases — we don't need cities or states yet. |
|
|||
|
|
| Country → timezone | Hand-curated `country-timezones.json` (250 entries, ~10kb) sourced from `country-tz` or moment-timezone's data | Static, no network call. For multi-zone countries, expose a sub-select. |
|
|||
|
|
| Timezone formatting | `Intl.DateTimeFormat` (built-in) | Browser API; renders `'Europe/Warsaw (UTC+1)'`-style labels. |
|
|||
|
|
| Timezone list | `Intl.supportedValuesOf('timeZone')` (built-in, ~600 entries) | Used as the override dropdown when a user wants a non-primary zone. |
|
|||
|
|
|
|||
|
|
Bundle impact: `libphonenumber-js` mobile build is ~80 KB gz; `shadcn-phone-input` is ~5 KB; country/timezone JSONs ~30 KB. All client-side, lazy-loaded on first form render via `next/dynamic`.
|
|||
|
|
|
|||
|
|
## Schema deltas
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- clients
|
|||
|
|
ALTER TABLE clients ADD COLUMN nationality_iso text; -- 'GB'
|
|||
|
|
ALTER TABLE clients ADD COLUMN timezone text; -- 'Europe/London'
|
|||
|
|
-- existing 'nationality' free-text column stays for a release; new code reads ISO
|
|||
|
|
|
|||
|
|
-- client_contacts (or wherever phone lives)
|
|||
|
|
ALTER TABLE client_contacts ADD COLUMN value_e164 text; -- '+442079460958'
|
|||
|
|
ALTER TABLE client_contacts ADD COLUMN value_country text; -- 'GB' (where the number was parsed against)
|
|||
|
|
-- existing 'value' stays as the human-displayable formatted form
|
|||
|
|
|
|||
|
|
-- residential_clients — same pattern
|
|||
|
|
ALTER TABLE residential_clients ADD COLUMN nationality_iso text;
|
|||
|
|
ALTER TABLE residential_clients ADD COLUMN timezone text;
|
|||
|
|
ALTER TABLE residential_clients ADD COLUMN phone_e164 text;
|
|||
|
|
ALTER TABLE residential_clients ADD COLUMN phone_country text;
|
|||
|
|
|
|||
|
|
-- companies
|
|||
|
|
ALTER TABLE companies ADD COLUMN incorporation_country_iso text;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Indexes: `idx_clients_nationality_iso`, `idx_clients_timezone` (cheap; powers analytics filters later).
|
|||
|
|
|
|||
|
|
## Component primitives
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
<CountryCombobox
|
|||
|
|
value={iso} // 'GB' | undefined
|
|||
|
|
onChange={(iso) => …}
|
|||
|
|
locale="en" // for name lookup; default to navigator.language
|
|||
|
|
variant="default" | "compact" // compact = icon-only flag, default = name
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<PhoneInput
|
|||
|
|
value={e164} // '+442079460958'
|
|||
|
|
onChange={({ e164, country }) => …}
|
|||
|
|
defaultCountry={'GB'} // pre-selects the dropdown
|
|||
|
|
required={false}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<TimezoneCombobox
|
|||
|
|
value={iana} // 'Europe/London'
|
|||
|
|
onChange={(iana) => …}
|
|||
|
|
countryHint={'GB'} // when set, narrows the dropdown to matching zones first
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
All three are shadcn-styled, keyboard-accessible, support form integration with react-hook-form + zod.
|
|||
|
|
|
|||
|
|
## Validators
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// src/lib/validators/contact.ts
|
|||
|
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
|||
|
|
|
|||
|
|
export const phoneE164Schema = z
|
|||
|
|
.string()
|
|||
|
|
.refine((v) => isValidPhoneNumber(v), 'Invalid phone number');
|
|||
|
|
|
|||
|
|
export const isoCountrySchema = z
|
|||
|
|
.string()
|
|||
|
|
.length(2)
|
|||
|
|
.toUpperCase()
|
|||
|
|
.refine((c) => ISO_COUNTRIES.has(c), 'Unknown country');
|
|||
|
|
|
|||
|
|
export const ianaTimezoneSchema = z
|
|||
|
|
.string()
|
|||
|
|
.refine((tz) => Intl.supportedValuesOf('timeZone').includes(tz), 'Unknown timezone');
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Backfill plan
|
|||
|
|
|
|||
|
|
A migration script (`scripts/backfill-iso-and-e164.ts`) that:
|
|||
|
|
|
|||
|
|
1. For each client/residential_client, attempt `libphonenumber-js` `parsePhoneNumber(rawPhone, { defaultCountry: 'PL' })` → if valid, write `phone_e164` + `phone_country`.
|
|||
|
|
2. For each free-text `nationality`, fuzzy-match against the country name list (exact match first, then Levenshtein ≤2). Write `nationality_iso` if confident.
|
|||
|
|
3. For each timezone, exact-match against IANA list. Otherwise leave null and let user fill it.
|
|||
|
|
4. Log unparseable rows to `backfill-iso-report.csv` for manual review.
|
|||
|
|
|
|||
|
|
Run on staging first; require dry-run flag.
|
|||
|
|
|
|||
|
|
## Build sequence
|
|||
|
|
|
|||
|
|
| # | PR | Effort | Depends on |
|
|||
|
|
| --- | ------------------------------------------------------------ | ------ | ---------- |
|
|||
|
|
| 1 | Country list JSON + ISO sets + `<CountryCombobox>` primitive | 0.5d | — |
|
|||
|
|
| 2 | `libphonenumber-js` integration + `<PhoneInput>` primitive | 1d | — |
|
|||
|
|
| 3 | Country → timezone JSON + `<TimezoneCombobox>` primitive | 0.5d | 1 |
|
|||
|
|
| 4 | Schema deltas + drizzle migrations + zod validators | 0.5d | — |
|
|||
|
|
| 5 | Wire into ClientForm + ClientDetail inline editors | 1d | 1, 2, 3, 4 |
|
|||
|
|
| 6 | Wire into ResidentialClientDetail | 0.5d | 5 |
|
|||
|
|
| 7 | Wire into CompanyForm | 0.5d | 1 |
|
|||
|
|
| 8 | Public inquiry form template renderer support | 0.5d | 2 |
|
|||
|
|
| 9 | Backfill script + dry-run runbook | 1d | 4 |
|
|||
|
|
| 10 | Smoke + integration tests | 1d | 5–9 |
|
|||
|
|
|
|||
|
|
Total: ~7 dev days. Self-contained; no external dependencies on Phase B (analytics/alerts).
|
|||
|
|
|
|||
|
|
## Risk register
|
|||
|
|
|
|||
|
|
| Risk | Mitigation |
|
|||
|
|
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|||
|
|
| Bundle bloat from libphonenumber data | Use the `mobile` metadata build, lazy-import via `next/dynamic` |
|
|||
|
|
| Existing free-text data is too messy to backfill | Keep the legacy column for one release; expose a "needs review" badge in admin |
|
|||
|
|
| Multi-zone country UX confusion | Sub-select only appears when country is multi-zone; otherwise zone is hidden behind "Override" |
|
|||
|
|
| Public inquiry form breaks if phone is required and user can't find their country | Default to PL, search by country name and dial code |
|
|||
|
|
|
|||
|
|
## Open questions for the user
|
|||
|
|
|
|||
|
|
- Which port's locale should drive the _default_ country in `<PhoneInput>` (Poland for now, or detect from browser)?
|
|||
|
|
- Should existing free-text `nationality` field be removed once backfilled, or kept indefinitely as a fallback?
|
|||
|
|
- Is there an appetite for adding the same treatment to subdivision (state/region/voivodship) selectors, or strictly country-level for now?
|