feat: add nationality support with demonym in CountrySelect and related components

This commit is contained in:
estifanos
2026-08-10 12:28:31 +00:00
parent 849915d5db
commit 19afcc3403
6 changed files with 58 additions and 5 deletions

View File

@@ -68,6 +68,7 @@ export function ConfigDrivenSection({
{isNationality ? (
<CountrySelect
{...common}
demonym
value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)}
/>

View File

@@ -50,7 +50,7 @@ import {
type FormFieldConfig,
type ValidationIssue,
} from '@ema-platform/api';
import { getCountryCode, ModalFooter } from '@ema-platform/ui';
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui';
import { useCurrentProfile } from '@ema-platform/auth';
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
import { DocumentSlots } from '../components/DocumentSlots';
@@ -210,11 +210,20 @@ export function LicenseApplicationPage() {
// During an adjustment round only flagged sections are editable, so don't
// even attempt a write the server would reject.
if (isAdjusting && !flaggedSections[sectionKey]) return;
const values = { ...(draft[sectionKey] ?? {}) };
// The picker works in alpha-2 codes (CountrySelect); the backend, like
// the profile Address endpoint, stores the full country name.
const nationalityField = config?.licenseType.formSchema.sections
.find((s) => s.key === sectionKey)
?.fields.find((f) => f.key === 'nationality' || localized(f.label).toLowerCase().includes('nationality'));
if (nationalityField && values[nationalityField.key]) {
values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key];
}
try {
await patchSection({
id: appId as string,
sectionKey,
values: draft[sectionKey] ?? {},
values,
}).unwrap();
} catch (err) {
notifications.show({

View File

@@ -132,6 +132,7 @@ export function AddressFormContent({
/>
<CountrySelect
label="Nationality"
demonym
required
value={watch('nationality') || null}
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}

View File

@@ -2,13 +2,19 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Text, type ComboboxItem, type SelectProps } from '@mantine/core';
import { registerLocale, getNames, getName, getAlpha2Code } from 'i18n-iso-countries';
import {
registerLocale as registerNationalityLocale,
getName as getNationalityNameRaw,
} from 'i18n-nationality';
import * as Flags from 'country-flag-icons/react/3x2';
import en from 'i18n-iso-countries/langs/en.json';
import am from 'i18n-iso-countries/langs/am.json';
import nationalityEn from 'i18n-nationality/langs/en.json';
// Registered once at module load — locale data is static.
registerLocale(en);
registerLocale(am);
registerNationalityLocale(nationalityEn);
type CountryLang = 'en' | 'am';
@@ -26,6 +32,18 @@ export function getCountryCode(name: string | null | undefined, lang: CountryLan
return name ? getAlpha2Code(name, lang) : undefined;
}
/**
* Localized demonym for an alpha-2 code (e.g. "ET" -> "Ethiopian"), for
* nationality fields as opposed to plain country fields.
* ponytail: i18n-nationality only ships an English locale — Amharic falls
* back to the country name until an Amharic demonym dataset shows up.
*/
export function getNationalityName(code: string | null | undefined, lang: CountryLang = 'en'): string {
if (!code) return '';
if (lang === 'en') return getNationalityNameRaw(code, 'en') ?? getCountryName(code, lang);
return getCountryName(code, lang);
}
function CountryFlag({ code }: { code: string }) {
const Flag = Flags[code as keyof typeof Flags];
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
@@ -57,12 +75,15 @@ export interface CountrySelectProps {
error?: React.ReactNode;
required?: boolean;
disabled?: boolean;
/** Show nationality labels ("Ethiopian") instead of country names ("Ethiopia"). */
demonym?: boolean;
}
export function CountrySelect({
value,
onChange,
placeholder,
demonym,
...rest
}: CountrySelectProps) {
const { t, i18n } = useTranslation();
@@ -70,10 +91,10 @@ export function CountrySelect({
const countries = useMemo(
() =>
Object.entries(getNames(lang))
.map(([code, label]) => ({ value: code, label }))
Object.keys(getNames(lang))
.map((code) => ({ value: code, label: demonym ? getNationalityName(code, lang) : getCountryName(code, lang) }))
.sort((a, b) => a.label.localeCompare(b.label, lang)),
[lang],
[lang, demonym],
);
return (

20
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"dayjs": "^1.11.20",
"ethiopian-calendar-date-converter": "^2.1.6",
"i18n-iso-countries": "^7.14.0",
"i18n-nationality": "^1.4.0",
"i18next": "^25.6.0",
"js-cookie": "^3.0.8",
"react": "^19.0.0",
@@ -8426,6 +8427,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/diacritics": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@types/diacritics/-/diacritics-1.3.3.tgz",
"integrity": "sha512-wt0tBItmBsOUVZ8+MCrkBMoVfH/EUZeTXwYSekVVYilZlGDYssREUR+sX72mHvl2IrbdCKgpYARXKh3awD2how==",
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
@@ -12814,6 +12821,19 @@
"node": ">= 12"
}
},
"node_modules/i18n-nationality": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/i18n-nationality/-/i18n-nationality-1.4.0.tgz",
"integrity": "sha512-/zZBGY8TbdL4xsIo5RMe1XTdMyHlHhLA6S2u7XsYJDh2c2ONVwxf0Pz5yKcLjKxtl8ma4jgtGnUaEgF/4ZOWbQ==",
"license": "MIT",
"dependencies": {
"@types/diacritics": "^1.3.1",
"diacritics": "^1.3.0"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/i18next": {
"version": "25.10.10",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",

View File

@@ -33,6 +33,7 @@
"dayjs": "^1.11.20",
"ethiopian-calendar-date-converter": "^2.1.6",
"i18n-iso-countries": "^7.14.0",
"i18n-nationality": "^1.4.0",
"i18next": "^25.6.0",
"js-cookie": "^3.0.8",
"react": "^19.0.0",