feat: introduce useLocalized hook for bilingual value retrieval.(localization round 2)

- Added useLocalized hook to provide a stable function for retrieving bilingual values based on the current language.
- Updated various components across the portal and backoffice to utilize the new useLocalized hook for consistent bilingual label rendering.
- Refactored localized function in licensing.helpers to handle empty Amharic strings correctly.
- Enhanced localization handling in LicenseApplicationPage, ProfilePage, and other components to ensure proper language switching.
This commit is contained in:
estifanos
2026-08-12 11:31:20 +00:00
parent faf58850f0
commit 31c52c02e0
26 changed files with 154 additions and 64 deletions

View File

@@ -39,6 +39,7 @@ import {
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetTemplateVariablesQuery,
useLocalized,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
@@ -70,6 +71,7 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
*/
export function CertificateDesignerPage() {
const { t } = useTranslation();
const localized = useLocalized();
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
@@ -226,7 +228,7 @@ export function CertificateDesignerPage() {
label={t('designer.licenceType', 'Licence type')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name?.en ?? type.key,
label: localized(type.name) || type.key,
}))}
value={typeId}
onChange={(value) => {

View File

@@ -22,7 +22,7 @@ import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
localized,
useLocalized,
useGetLicensesQuery,
useReinstateLicenseMutation,
useRevokeLicenseMutation,
@@ -168,6 +168,7 @@ export function LicenseRegisterPage() {
const items = data?.items ?? [];
const showDate = useDateDisplayer();
const localized = useLocalized();
return (
<Container size="xl" py="md">

View File

@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
import {
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useLocalized,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
@@ -62,6 +63,7 @@ export function DocumentsTab({
onFlagRemark,
}: DocumentsTabProps) {
const { t } = useTranslation();
const localized = useLocalized();
const [preview, setPreview] = useState<Attachment | null>(null);
const [rejecting, setRejecting] = useState<Record<string, string>>({});
@@ -148,7 +150,7 @@ export function DocumentsTab({
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => r.name.en ?? r.key).join(', ')}
{missing.map((r) => localized(r.name) || r.key).join(', ')}
</Text>
</Alert>
)}

View File

@@ -1,4 +1,4 @@
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
import { STATUS_LABELS, localized, type LicenseApplication } from '@ema-platform/api';
import { dateDisplayer } from '@ema-platform/shared';
import { computeSla } from './sla';
@@ -22,7 +22,7 @@ const COLUMNS: Array<{
{ header: 'Company', value: (a) => a.companyName },
{ header: 'Trade name', value: (a) => a.tradeName },
{ header: 'TIN', value: (a) => a.tinNumber },
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
{ header: 'Licence type', value: (a, locale) => localized(a.licenseType?.name, locale) || a.licenseTypeId },
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
{ header: 'Kind', value: (a) => a.kind },
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },

View File

@@ -39,6 +39,7 @@ import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
useCompleteReviewMutation,
@@ -111,6 +112,7 @@ function buildChecklist(
export function LicenseReviewPage() {
const { t, i18n } = useTranslation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const { id = '' } = useParams();
const { can } = usePermissions();
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
@@ -485,6 +487,12 @@ export function LicenseReviewPage() {
const sections = presentation.detailSections;
const formSections = Object.entries(app.formData ?? {});
// The bilingual section/field labels the applicant's wizard renders — this
// page already fetches them (`requirements` above) but used to fall back to
// the raw formData keys, so an officer saw `vesselId` instead of a label in
// either language.
const configSections = requirements?.licenseType.formSchema.sections ?? [];
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
return (
<Container size="xl" py="md">
@@ -533,7 +541,7 @@ export function LicenseReviewPage() {
{t('review.summary', 'Summary')}
</Text>
<Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
<SummaryRow
@@ -639,11 +647,18 @@ export function LicenseReviewPage() {
<Tabs.Panel value="overview">
<Stack>
{formSections.map(([sectionKey, values]) => (
{formSections.map(([sectionKey, values]) => {
const sectionConfig = sectionsByKey.get(sectionKey);
const fieldsByKey = new Map(
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
);
return (
<Card withBorder key={sectionKey} padding="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm" tt="capitalize">
{sectionKey.replace(/([A-Z])/g, ' $1')}
{sectionConfig
? localized(sectionConfig.title)
: sectionKey.replace(/([A-Z])/g, ' $1')}
</Text>
<Checkbox
size="xs"
@@ -654,18 +669,21 @@ export function LicenseReviewPage() {
</Group>
<Table withTableBorder>
<Table.Tbody>
{Object.entries(values ?? {}).map(([k, v]) => (
{Object.entries(values ?? {}).map(([k, v]) => {
const fieldConfig = fieldsByKey.get(k);
return (
<Table.Tr key={k}>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{k}
{fieldConfig ? localized(fieldConfig.label) : k}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{v === null ? '—' : String(v)}</Text>
</Table.Td>
</Table.Tr>
))}
);
})}
</Table.Tbody>
</Table>
{flags[sectionKey] && (
@@ -702,7 +720,8 @@ export function LicenseReviewPage() {
/>
)}
</Card>
))}
);
})}
</Stack>
</Tabs.Panel>

View File

@@ -22,6 +22,7 @@ import {
import { useGetLocationsQuery } from '../api/location-api';
import type { Location } from '../types/location';
import { useTranslation } from 'react-i18next';
import { useLocalized } from '@ema-platform/api';
interface LocationTreeProps {
selectedId: string | null;
@@ -51,6 +52,7 @@ function TreeNode({
onSelect: (location: Location) => void;
depth: number;
}) {
const localized = useLocalized();
const [opened, setOpened] = useState(depth < 1);
const isSelected = selectedId === location.id;
const hasChildren =
@@ -134,7 +136,7 @@ function TreeNode({
style={{ flexShrink: 0, opacity: 0.6 }}
/>
<Text size="sm" truncate style={{ flex: 1 }}>
{location.names.en}
{localized(location.names)}
</Text>
</UnstyledButton>
{hasChildren && (

View File

@@ -34,7 +34,7 @@ import {
} from '@ema-platform/ui';
import {
extractErrorMessage,
localized,
useLocalized,
useGetLicenseTypesQuery,
useGetPaymentCapabilitiesQuery,
useUpdateLicenseFeesMutation,
@@ -61,6 +61,7 @@ function feeText(amount: string | number | null, currency: string): string {
export function PaymentConfigPage() {
const { t } = useTranslation();
const localized = useLocalized();
const { data, isLoading, isFetching, error, refetch } =
useGetLicenseTypesQuery();
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
@@ -292,6 +293,7 @@ function FeeEditModal({
onClose: () => void;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
const [newFee, setNewFee] = useState<number | ''>('');
const [renewalFee, setRenewalFee] = useState<number | ''>('');

View File

@@ -40,7 +40,7 @@ import {
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
import {
useGetResultsQuery,
useLazyGetResultQuery,
@@ -117,6 +117,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
export function ResultPage() {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const showDate = useDateDisplayer();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
@@ -152,7 +153,7 @@ export function ResultPage() {
const [qcRemark, setQcRemark] = useState('');
const [qcAdjustment, setQcAdjustment] = useState(0);
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
const examOptions = exams.map((e) => ({ value: e.id, label: `${localized(e.title)} (${e.date})` }));
const startRecord = () => {
const ex = exams.find((e) => e.id === pickerExamId);

View File

@@ -25,6 +25,7 @@ import {
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
@@ -74,6 +75,7 @@ export function CertificatesPage() {
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
@@ -189,7 +191,7 @@ export function CertificatesPage() {
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Text size="xs" c="dimmed">
{app.licenseType?.name?.en}
{localized(app.licenseType?.name)}
</Text>
</div>
<Group>
@@ -245,7 +247,7 @@ export function CertificatesPage() {
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
<Table.Td>

View File

@@ -37,7 +37,7 @@ import {
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
localized,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
@@ -457,6 +457,7 @@ function ApplicationTable({
applications: LicenseApplication[];
navigate: (path: string) => void;
}) {
const localized = useLocalized();
return (
<Card withBorder radius="md" padding={0}>
<Table.ScrollContainer minWidth={640}>

View File

@@ -25,7 +25,7 @@ import {
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
localized,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
@@ -68,6 +68,7 @@ export function EndorsementPage() {
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const registered =

View File

@@ -20,6 +20,7 @@ import {
useApiMutation,
extractErrorMessage,
openAuthedDocument,
useLocalized,
} from '@ema-platform/api';
interface OpenExam {
@@ -83,6 +84,7 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
*/
export function ExamsPage() {
const showDate = useDateDisplayer();
const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
const [appealReason, setAppealReason] = useState('');
@@ -197,9 +199,9 @@ export function ExamsPage() {
<Card key={exam.id} withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{exam.title?.en}</Text>
<Text fw={600}>{localized(exam.title)}</Text>
<Text size="xs" c="dimmed">
{exam.certification?.name?.en ?? ''} ·{' '}
{localized(exam.certification?.name)} ·{' '}
{showDate(exam.date)}
{exam.venue ? ` · ${exam.venue}` : ''}
</Text>
@@ -254,7 +256,7 @@ export function ExamsPage() {
{registration.admissionNumber}
</Text>
</Table.Td>
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
<Table.Td>{localized(registration.exam?.title) || '—'}</Table.Td>
<Table.Td>{showDate(registration.exam?.date)}</Table.Td>
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
<Table.Td>
@@ -326,7 +328,7 @@ export function ExamsPage() {
);
return (
<Table.Tr key={result.id}>
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
<Table.Td>{localized(result.exam?.title) || '—'}</Table.Td>
<Table.Td>{showDate(result.publishedAt)}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
@@ -376,7 +378,7 @@ export function ExamsPage() {
<Stack>
<Text size="sm" c="dimmed">
Explain what you believe went wrong with the marking or the
administration of {appealFor?.exam?.title?.en ?? 'this examination'}.
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
Appeals must be lodged within 14 days of publication.
</Text>
<Textarea

View File

@@ -8,7 +8,7 @@ import {
} from '@mantine/core';
import {
conditionHolds,
localized,
useLocalized,
type FormFieldConfig,
type FormSectionConfig,
type Vessel,
@@ -72,7 +72,8 @@ export function fillFromVessel(
onChange: (key: string, value: unknown) => void,
) {
for (const f of fields) {
const label = localized(f.label).toLowerCase();
// English-pinned: matched against the English strings in VESSEL_FIELD_FILLERS.
const label = (f.label.en ?? '').toLowerCase();
const filler = VESSEL_FIELD_FILLERS.find((m) => m.matches(label, f.key));
if (filler) onChange(f.key, filler.value(vessel) ?? '');
}
@@ -95,6 +96,7 @@ export function ConfigDrivenSection({
vessels = [],
onVesselSelected,
}: Props) {
const localized = useLocalized();
const fields = [...(section.fields ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
@@ -105,6 +107,8 @@ export function ConfigDrivenSection({
if (!conditionHolds(field.showWhen, formData)) return null;
const label = localized(field.label);
// English-pinned: the picker overrides below match English labels.
const labelEn = (field.label.en ?? '').toLowerCase();
const value = values?.[field.key];
const error = errors[`${section.key}.${field.key}`];
const common = {
@@ -118,11 +122,11 @@ export function ConfigDrivenSection({
const span = field.type === 'TEXTAREA' ? 12 : 6;
// Same field the profile Address tab collects — give it the same
// searchable, flag-labeled picker instead of a plain option list.
const isNationality = field.key === 'nationality' || label.toLowerCase().includes('nationality');
const isNationality = field.key === 'nationality' || labelEn.includes('nationality');
// Options here can't be seeded statically — they're the applicant's
// own vessel register, so this overrides whatever type the backend
// configured, the same way nationality overrides SELECT above.
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(label.trim());
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
return (
<Grid.Col span={{ base: 12, md: span }} key={field.key}>

View File

@@ -19,7 +19,7 @@ import {
} from '@tabler/icons-react';
import {
conditionHolds,
localized,
useLocalized,
uploadDocument,
type Attachment,
type DocumentRequirement,
@@ -58,6 +58,7 @@ export function DocumentSlots({
onUploaded,
readOnly,
}: Props) {
const localized = useLocalized();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});

View File

@@ -13,7 +13,7 @@ import {
import { IconDownload, IconRefresh } from '@tabler/icons-react';
import {
extractErrorMessage,
localized,
useLocalized,
useCreateApplicationMutation,
type IssuedLicense,
} from '@ema-platform/api';
@@ -76,6 +76,7 @@ export function LicenseCard({
const expired = license.status === 'EXPIRED' || days < 0;
const renewable = license.renewable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
return (
<Card withBorder radius="md" padding="md" className="ema-hover-lift">

View File

@@ -23,7 +23,7 @@ import {
IconTrendingUp,
} from '@tabler/icons-react';
import {
localized,
useLocalized,
useGetLicenseCategoriesQuery,
useGetLicenseTypesQuery,
useGetMyOperatorTypesQuery,
@@ -55,6 +55,7 @@ function formatFee(amount: string | number | null, currency: string): string {
export function LicenseCatalogue() {
const navigate = useNavigate();
const localized = useLocalized();
const { data: types } = useGetLicenseTypesQuery();
const { data: categories } = useGetLicenseCategoriesQuery();
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
@@ -253,6 +254,7 @@ function LicenseTypeCard({
canApply: boolean;
onSelect: (type: LicenseType) => void;
}) {
const localized = useLocalized();
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
return (

View File

@@ -3,7 +3,7 @@ import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import {
localized,
useLocalized,
uploadDocument,
useGetAttachmentsQuery,
type StaffEvidenceRequirement,
@@ -30,6 +30,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
ownerId: staffId,
});
const [busy, setBusy] = useState<string | null>(null);
const localized = useLocalized();
if (!evidence?.length) return null;

View File

@@ -29,12 +29,13 @@ import {
IconTrash,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
buildWizardSteps,
conditionHolds,
extractErrorMessage,
extractValidationIssues,
localized,
useLocalized,
validateSections,
useAddStaffMutation,
useCreateApplicationMutation,
@@ -66,6 +67,8 @@ import { StaffEvidence } from '../components/StaffEvidence';
export function LicenseApplicationPage() {
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
const navigate = useNavigate();
const { i18n } = useTranslation();
const localized = useLocalized();
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
@@ -136,7 +139,8 @@ export function LicenseApplicationPage() {
toFieldValue: (field: FormFieldConfig) => unknown,
) => {
for (const section of config.licenseType.formSchema.sections) {
const field = section.fields.find((f) => matchField(localized(f.label).toLowerCase(), f.key));
// English-pinned: matched against English substrings below ('nationality', 'fayda').
const field = section.fields.find((f) => matchField((f.label.en ?? '').toLowerCase(), f.key));
if (!field) continue;
if (next[section.key]?.[field.key]) return; // already set — leave it
next = { ...next, [section.key]: { ...next[section.key], [field.key]: toFieldValue(field) } };
@@ -193,8 +197,9 @@ export function LicenseApplicationPage() {
() =>
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
language: i18n.language,
}),
[config, draft],
[config, draft, i18n.language],
);
const sections = useMemo(
() => steps.flatMap((step) => step.sections),
@@ -234,7 +239,8 @@ export function LicenseApplicationPage() {
// 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'));
// English-pinned: same reasoning as the fill() matcher above.
?.fields.find((f) => f.key === 'nationality' || (f.label.en ?? '').toLowerCase().includes('nationality'));
if (nationalityField && values[nationalityField.key]) {
values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key];
}
@@ -256,7 +262,7 @@ export function LicenseApplicationPage() {
async function handleSubmit() {
setIssues([]);
if (!readOnly && currentStep?.sections?.length) {
const errors = validateSections(currentStep.sections, draft);
const errors = validateSections(currentStep.sections, draft, i18n.language);
setFieldErrors(errors);
if (Object.keys(errors).length) {
notifications.show({
@@ -316,7 +322,7 @@ export function LicenseApplicationPage() {
if (!currentStep || !config) return true;
if (currentStep.kind === 'sections') {
const errors = validateSections(currentStep.sections, draft);
const errors = validateSections(currentStep.sections, draft, i18n.language);
setFieldErrors(errors);
const count = Object.keys(errors).length;
if (count > 0) {

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core';
import { ErrorState } from '@ema-platform/ui';
import { useLocalized } from '@ema-platform/api';
import { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api';
import type { Location, LocationType } from '../types/location';
import { useTranslation } from 'react-i18next';
@@ -16,6 +17,7 @@ interface LocationPickerProps {
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
const { t } = useTranslation();
const localized = useLocalized();
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
const { data: locsRes, isLoading: locsLoading, isError: locsError, refetch: refetchLocs } = useGetLocationsQuery({ take: 10000 });
@@ -74,10 +76,10 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
if (currentLevelChildren.length === 0) return '';
const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))];
const names = typeIds
.map((id) => typeMap.get(id)?.names.en)
.filter(Boolean) as string[];
.map((id) => localized(typeMap.get(id)?.names))
.filter(Boolean);
return names.join(' / ');
}, [currentLevelChildren, typeMap]);
}, [currentLevelChildren, typeMap, localized]);
const depth = selectedChain.length;
@@ -116,7 +118,7 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
if (levelIdx === 0) {
const roots = childrenByParentId.get('__root__') ?? [];
return roots
.map((loc) => ({ value: loc.id, label: loc.names.en }))
.map((loc) => ({ value: loc.id, label: localized(loc.names) }))
.sort((a, b) => a.label.localeCompare(b.label));
}
@@ -124,7 +126,7 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
if (!parent) return [];
const children = childrenByParentId.get(parent.id) ?? [];
return children
.map((loc) => ({ value: loc.id, label: loc.names.en }))
.map((loc) => ({ value: loc.id, label: localized(loc.names) }))
.sort((a, b) => a.label.localeCompare(b.label));
};
@@ -134,8 +136,8 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
if (roots.length === 0) return '';
const typeIds = [...new Set(roots.map((r) => r.locationTypeId))];
const names = typeIds
.map((id) => typeMap.get(id)?.names.en)
.filter(Boolean) as string[];
.map((id) => localized(typeMap.get(id)?.names))
.filter(Boolean);
return names.join(' / ');
}
@@ -144,16 +146,16 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
const children = childrenByParentId.get(parent.id) ?? [];
const typeIds = [...new Set(children.map((c) => c.locationTypeId))];
const names = typeIds
.map((id) => typeMap.get(id)?.names.en)
.filter(Boolean) as string[];
.map((id) => localized(typeMap.get(id)?.names))
.filter(Boolean);
return names.join(' / ');
};
const selectedPath = useMemo(() => {
return selectedChain
.map((loc) => loc.names.en)
.map((loc) => localized(loc.names))
.join(' → ');
}, [selectedChain]);
}, [selectedChain, localized]);
if (typesLoading || locsLoading) {
return (
@@ -236,8 +238,8 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
color="blue"
style={{ textTransform: 'none' }}
>
{typeInfo ? `${typeInfo.names.en}: ` : ''}
{loc.names.en}
{typeInfo ? `${localized(typeInfo.names)}: ` : ''}
{localized(loc.names)}
</Badge>
);
})}

View File

@@ -15,7 +15,7 @@ import {
} from '@mantine/core';
import { IconBellOff, IconCheck } from '@tabler/icons-react';
import {
localized,
useLocalized,
useGetNotificationsQuery,
useGetUnseenNotificationsQuery,
useMarkNotificationReadMutation,
@@ -39,6 +39,7 @@ const EMPTY_COPY: Record<Tab, string> = {
export function NotificationsPage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [tab, setTab] = useState<Tab>('all');
const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });

View File

@@ -14,7 +14,7 @@ import {
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
import {
extractErrorMessage,
localized,
useLocalized,
useGetLicenseTypesQuery,
useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation,
@@ -41,6 +41,7 @@ export function OperationsFormContent({
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
const localized = useLocalized();
const declaredIds = useMemo(
() => (mine?.items ?? []).map((o) => o.licenseTypeId),

View File

@@ -48,7 +48,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { useApiMutation, useLocalized } from '@ema-platform/api';
import { setUser, useCurrentProfile } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -119,7 +119,8 @@ export function ProfilePage() {
const [updateTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const localized = useLocalized();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string; am?: string } }> }>();
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
@@ -129,7 +130,7 @@ export function ProfilePage() {
const [emailNotifications, setEmailNotifications] = useState(true);
// ---- Profession list (for Profile tab) ----
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string; am?: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const professionsFetched = useRef(false);
@@ -144,8 +145,8 @@ export function ProfilePage() {
}, [fetchProfessions]);
const professionOptions = useMemo(
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
[professions],
() => professions.map((p) => ({ value: p.id, label: localized(p.name) })),
[professions, localized],
);
// ---- Profile data ----

View File

@@ -23,7 +23,7 @@ import {
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
localized,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
@@ -48,6 +48,7 @@ export function WaiverPage() {
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const waiverApplications = (applications?.items ?? []).filter((app) =>
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),

View File

@@ -1,3 +1,4 @@
export * from './licensing.types';
export * from './licensing-api';
export * from './licensing.helpers';
export * from './use-localized';

View File

@@ -155,7 +155,9 @@ export function applicantOrCompanyName(app: LicenseApplication): string | undefi
/** Reads a bilingual value for the active language, falling back to English. */
export function localized(value: Bilingual | undefined, language = 'en'): string {
if (!value) return '';
return (language === 'am' ? value.am : value.en) ?? value.en ?? value.am ?? '';
// `||` not `??`: an empty Amharic string is "not translated", not a value —
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
return (language === 'am' ? value.am : value.en) || value.en || value.am || '';
}
/**
@@ -246,6 +248,9 @@ export function buildWizardSteps(
* instead of showing an empty page.
*/
hasStaff?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
},
): WizardStep[] {
const visible = [...sections]
@@ -260,7 +265,7 @@ export function buildWizardSteps(
if (!group) {
steps.push({
key: `section:${section.key}`,
label: localized(section.title),
label: localized(section.title, options?.language),
kind: 'sections',
sections: [section],
});
@@ -315,6 +320,7 @@ export type FieldErrors = Record<string, string>;
export function validateSections(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
language = 'en',
): FieldErrors {
const errors: FieldErrors = {};
@@ -338,8 +344,8 @@ export function validateSections(
if (field.required && empty) {
errors[`${section.key}.${field.key}`] =
field.type === 'BOOLEAN'
? `${localized(field.label)} must be accepted`
: `${localized(field.label)} is required`;
? `${localized(field.label, language)} must be accepted`
: `${localized(field.label, language)} is required`;
continue;
}
if (empty) continue;

View File

@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { localized } from './licensing.helpers';
import type { Bilingual } from './licensing.types';
/**
* The component-facing bilingual reader — the twin of `useDateDisplayer()`.
*
* A hook rather than a bare `localized` import so the calling component is
* subscribed to i18next: switching language re-renders it and every
* backend-configured label flips with the rest of the UI. `useTranslation()`
* with no instance argument resolves to the app's own <I18nextProvider>
* (portal router.tsx, backoffice AppProviders.tsx), which is what makes this
* work across two separate i18n instances.
*
* DISPLAY ONLY. Code that *matches* on a label — `.includes('nationality')`,
* the vessel-picker regex in ConfigDrivenSection, the SUBCITY/WOREDA test in
* AddressFormContent — must keep reading `value.en`, or the match breaks the
* moment the user switches language.
*
* The returned function is stable per language, so it is safe — and required —
* as a useMemo/useCallback dependency.
*/
export function useLocalized(): (value: Bilingual | undefined) => string {
const { i18n } = useTranslation();
return useCallback((value) => localized(value, i18n.language), [i18n.language]);
}