diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx index bab67f514..b6d4d613d 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx @@ -13,7 +13,13 @@ import { } from '@mantine/core'; import { IconPlus, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { BilingualInput, ModalFooter } from '@ema-platform/ui'; +import { + BilingualInput, + CountrySelect, + countryCodeToValue, + countryValueToCode, + ModalFooter, +} from '@ema-platform/ui'; import type { FormFieldConfig, FormSchemaPalette } from '@ema-platform/api'; import { ConditionBuilder, type ConditionValue } from './ConditionBuilder'; import type { ConditionTarget } from '../config/schema-paths'; @@ -74,6 +80,8 @@ export function FieldEditorDrawer({ const typeInfo = fieldTypes.find((f) => f.type === draft.type); const isNew = !field; + const isCountryType = draft.type === 'NATIONALITY' || draft.type === 'NATION'; + const isDemonym = draft.type === 'NATIONALITY'; function save() { if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) { @@ -170,11 +178,28 @@ export function FieldEditorDrawer({ }} /> - setDraft((d) => ({ ...d, placeholder: v }))} - /> + {isCountryType ? ( + // The picker has its own placeholder, so this slot chooses what is + // pre-selected instead. It travels in `placeholder.en` — the one + // optional text slot the schema already has — as the English name, + // the same form the applicant's answer takes (countryCodeToValue). + { + const name = countryCodeToValue(code, isDemonym); + setDraft((d) => ({ ...d, placeholder: name ? { en: name, am: '' } : undefined })); + }} + /> + ) : ( + setDraft((d) => ({ ...d, placeholder: v }))} + /> + )} - onChange(field.key, countryCodeToStored(v, field.type === 'NATIONALITY')) + onChange(field.key, countryCodeToValue(v, field.type === 'NATIONALITY')) } /> ) : isLegacyNationality ? ( diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 426b9ae54..cb9437c5b 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -319,11 +319,28 @@ export function LicenseApplicationPage() { const next = { ...prev }; for (const section of config.licenseType.formSchema.sections) { for (const field of section.fields) { - const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key]; - if (!source) continue; const current = next[section.key]?.[field.key]; const untouched = current === undefined || current === null || current === ""; + const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key]; + if (!source) { + // A NATIONALITY/NATION field's `placeholder.en` is the default the + // builder chose ("Ethiopian"). It only ever fills a blank — the + // profile prefills above ran first, and an answer the applicant + // cleared on purpose is not re-filled until the draft is next + // seeded. + const isCountryPicker = + field.type === "NATIONALITY" || field.type === "NATION"; + const preset = field.placeholder?.en?.trim(); + if (untouched && isCountryPicker && preset) { + next[section.key] = { + ...next[section.key], + [field.key]: preset, + }; + changed = true; + } + continue; + } if (!field.readOnly && !untouched) continue; const raw = readSourcePath(context, source); // Profile dates arrive as ISO datetimes; a DATE field's picker wants diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index f2f85ae1d..1e9627dd5 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -104,6 +104,12 @@ export interface FormFieldConfig { label: Bilingual; type: FormFieldType; required?: boolean; + /** + * Hint text — except on NATIONALITY/NATION fields, where the builder puts + * the pre-selected answer here (`en`, as the English demonym or country + * name) and the wizard seeds it into the draft. The server stores it + * unchanged either way, so this stays frontend-only. + */ placeholder?: Bilingual; helpText?: Bilingual; options?: { value: string; label: Bilingual }[]; diff --git a/libs/ui/src/lib/input/CountrySelect.tsx b/libs/ui/src/lib/input/CountrySelect.tsx index 1ea08e301..e2f51c2c6 100644 --- a/libs/ui/src/lib/input/CountrySelect.tsx +++ b/libs/ui/src/lib/input/CountrySelect.tsx @@ -56,6 +56,28 @@ export function getNationalityCode(name: string | null | undefined, lang: Countr return demonymCode || getCountryCode(name, lang); } +/** + * Stored form of a NATIONALITY/NATION answer — the English demonym or country + * name, the way the profile's Address tab and seafarer registration store it, + * so a certificate prints the word rather than "ET" — resolved back to the + * alpha-2 code the picker works in. Both lookups run either way so an answer + * saved in the other form (or as a bare code) still resolves. + */ +export function countryValueToCode(stored: unknown, demonym: boolean): string | null { + const raw = typeof stored === 'string' ? stored.trim() : ''; + if (!raw) return null; + const code = demonym + ? getNationalityCode(raw) ?? getCountryCode(raw) + : getCountryCode(raw) ?? getNationalityCode(raw); + return code ?? raw; +} + +/** The reverse: English-pinned deliberately, so the stored answer never depends on the UI language. */ +export function countryCodeToValue(code: string | null, demonym: boolean): string | null { + if (!code) return null; + return (demonym ? getNationalityName(code) : getCountryName(code)) || code; +} + export function CountryFlag({ code }: { code: string }) { const Flag = Flags[code as keyof typeof Flags]; return Flag ? : null;