localization fixes

This commit is contained in:
estifanos
2026-08-17 08:10:53 +00:00
parent 73f7127ac8
commit aa3b6e834b
6 changed files with 157 additions and 50 deletions

View File

@@ -1,21 +1,23 @@
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core'; import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form'; import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AmharicDatePicker } from '@ema-platform/ui'; import { AmharicDatePicker } from '@ema-platform/ui';
export const profileSchema = z.object({ export const profileSchema = (t: TFunction) =>
professionId: z.string().min(1, 'Select your profession'), z.object({
firstName: z.string().min(3, 'First name must be at least 3 characters'), professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
middleName: z.string().min(3, 'Middle name must be at least 3 characters'), firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
lastName: z.string().min(3, 'Last name must be at least 3 characters'), middleName: z.string().min(3, t('profileForm.validation.middleNameMin')),
gender: z.string().min(1, 'Select your gender'), lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
dob: z.string().min(1, 'Select your date of birth'), gender: z.string().min(1, t('profileForm.validation.genderRequired')),
pob: z.string().optional(), dob: z.string().min(1, t('profileForm.validation.dobRequired')),
maritalStatus: z.string().min(1, 'Select your marital status'), pob: z.string().optional(),
}); maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
});
export type ProfileValues = z.infer<typeof profileSchema>; export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
export const GENDERS = ['MALE', 'FEMALE'] as const; export const GENDERS = ['MALE', 'FEMALE'] as const;
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const; export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
@@ -45,8 +47,8 @@ export function ProfileFormContent({
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select <Select
label="Profession" label={t('profileFields.professionId')}
placeholder={professionsLoading ? t('common.loading', 'Loading…') : 'Select'} placeholder={professionsLoading ? t('common.loading') : t('common.select')}
required required
data={professionOptions} data={professionOptions}
error={errors.professionId?.message} error={errors.professionId?.message}
@@ -59,31 +61,31 @@ export function ProfileFormContent({
rightSection={professionsLoading ? <Loader size="xs" type="oval" /> : undefined} rightSection={professionsLoading ? <Loader size="xs" type="oval" /> : undefined}
/> />
<TextInput <TextInput
label="First Name" label={t('profileFields.firstName')}
placeholder="Enter first name" placeholder={t('profileForm.placeholders.firstName')}
required required
{...register('firstName')} {...register('firstName')}
error={errors.firstName?.message} error={errors.firstName?.message}
/> />
<TextInput <TextInput
label="Middle Name" label={t('profileFields.middleName')}
placeholder="Enter middle name" placeholder={t('profileForm.placeholders.middleName')}
required required
{...register('middleName')} {...register('middleName')}
error={errors.middleName?.message} error={errors.middleName?.message}
/> />
<TextInput <TextInput
label="Last Name" label={t('profileFields.lastName')}
placeholder="Enter last name" placeholder={t('profileForm.placeholders.lastName')}
required required
{...register('lastName')} {...register('lastName')}
error={errors.lastName?.message} error={errors.lastName?.message}
/> />
<Select <Select
label="Gender" label={t('profileFields.gender')}
placeholder="Select" placeholder={t('common.select')}
required required
data={[...GENDERS]} data={GENDERS.map((g) => ({ value: g, label: t(`profileForm.genders.${g}`) }))}
error={errors.gender?.message} error={errors.gender?.message}
value={watch('gender')} value={watch('gender')}
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })} onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
@@ -91,7 +93,7 @@ export function ProfileFormContent({
name="gender" name="gender"
/> />
<AmharicDatePicker <AmharicDatePicker
label="Date of Birth" label={t('profileFields.dob')}
required required
value={watch('dob')} value={watch('dob')}
onChange={(val) => setValue('dob', val, { shouldValidate: true })} onChange={(val) => setValue('dob', val, { shouldValidate: true })}
@@ -100,16 +102,16 @@ export function ProfileFormContent({
error={errors.dob?.message} error={errors.dob?.message}
/> />
<TextInput <TextInput
label="Place of Birth" label={t('profileFields.pob')}
placeholder="City, Region" placeholder={t('profileForm.placeholders.pob')}
{...register('pob')} {...register('pob')}
error={errors.pob?.message} error={errors.pob?.message}
/> />
<Select <Select
label="Marital Status" label={t('profileFields.maritalStatus')}
placeholder="Select" placeholder={t('common.select')}
required required
data={[...MARITAL_STATUSES]} data={MARITAL_STATUSES.map((m) => ({ value: m, label: t(`profileForm.maritalStatuses.${m}`) }))}
error={errors.maritalStatus?.message} error={errors.maritalStatus?.message}
value={watch('maritalStatus')} value={watch('maritalStatus')}
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })} onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}

View File

@@ -59,7 +59,7 @@ export function ProfileRequirementGate({
})} })}
> >
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">{requirement.reason}</Text> <Text size="sm">{t(requirement.reason)}</Text>
<List size="sm" spacing={2}> <List size="sm" spacing={2}>
{gaps.map((field) => ( {gaps.map((field) => (
<List.Item key={field}> <List.Item key={field}>

View File

@@ -1,5 +1,4 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Center, Loader } from '@mantine/core';
import { Navigate, useLocation, useParams } from 'react-router-dom'; import { Navigate, useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PageLoader, notify } from '@ema-platform/ui'; import { PageLoader, notify } from '@ema-platform/ui';
@@ -35,8 +34,12 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
'primaryPhoneNumber', 'primaryPhoneNumber',
'email', 'email',
], ],
reason: // Translation key, not literal text — `ProfileRequirementGate` runs it
'Seafarer registration is built from your profile — these details fill it in for you.', // through `t()` at render time (it can't be translated here: this object
// is built at module scope, before any component or `useTranslation` call
// exists). Reuses `profileGate.seafarerReason`, the same sentence already
// shown by the seafarer-redirect toast below.
reason: 'profileGate.seafarerReason',
}; };
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION'; const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
@@ -75,7 +78,7 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
if (!gated) return <>{children}</>; if (!gated) return <>{children}</>;
if (isLoading) { if (isLoading) {
return <PageLoader label="Checking seafarer profile…" height={350} />; return <PageLoader label={t('profileGate.checkingProfile')} height={350} />;
} }
// A failed lookup must not lock anyone out — the server still refuses the // A failed lookup must not lock anyone out — the server still refuses the
@@ -89,7 +92,7 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
// headed back here in the same tick — deciding on the pre-save cache would // headed back here in the same tick — deciding on the pre-save cache would
// bounce them off the screen they just finished. // bounce them off the screen they just finished.
if (isFetching) { if (isFetching) {
return <PageLoader label="Checking seafarer profile…" height={350} />; return <PageLoader label={t('profileGate.checkingProfile')} height={350} />;
} }
const target = PROFILE_FIELD_SECTION[gaps[0]]; const target = PROFILE_FIELD_SECTION[gaps[0]];

View File

@@ -241,7 +241,7 @@ export function ProfilePage() {
.string() .string()
.refine( .refine(
(name) => Object.values(splitProfileName(name)).every(Boolean), (name) => Object.values(splitProfileName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' }, { message: t('profileForm.validation.nameParts') },
), ),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }), username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
@@ -341,7 +341,7 @@ export function ProfilePage() {
trigger: profileTriggerValidation, trigger: profileTriggerValidation,
formState: { errors: profileErrors }, formState: { errors: profileErrors },
} = useForm<ProfileValues>({ } = useForm<ProfileValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema(t)),
values: loadedProfile ?? undefined, values: loadedProfile ?? undefined,
}); });
@@ -350,7 +350,7 @@ export function ProfilePage() {
const fullName = formatProfileName(values); const fullName = formatProfileName(values);
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) { if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
notify.error('Profile name must match the name in the Personal tab.'); notify.error(t('profile.nameMismatch'));
return; return;
} }
@@ -367,7 +367,7 @@ export function ProfilePage() {
// the address save below) — without this, `missing` stays stale until // the address save below) — without this, `missing` stays stale until
// a reload. // a reload.
refetchProfile(); refetchProfile();
notify.success('Profile updated'); notify.success(t('profile.profileUpdated'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -395,7 +395,7 @@ export function ProfilePage() {
profileId, profileId,
body: toAddressPayload(values), body: toAddressPayload(values),
}).unwrap(); }).unwrap();
notify.success('Address saved'); notify.success(t('profile.addressSaved'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -569,19 +569,19 @@ export function ProfilePage() {
> >
<Tabs.List> <Tabs.List>
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}> <Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
Personal {t('profile.tabs.personal')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}> <Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
Profile {t('profile.tabs.profile')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}> <Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address {t('profile.tabs.address')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="operations" value="operations"
leftSection={<IconBuildingWarehouse size={18} />} leftSection={<IconBuildingWarehouse size={18} />}
> >
Operations {t('profile.tabs.operations')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}> <Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')} {t('profile.tabs.security')}
@@ -676,15 +676,15 @@ export function ProfilePage() {
<Center py="xl"><Loader /></Center> <Center py="xl"><Loader /></Center>
) : !loadedProfile ? ( ) : !loadedProfile ? (
<Text c="dimmed" ta="center" py="xl"> <Text c="dimmed" ta="center" py="xl">
No profile found. Complete your profile setup first. {t('profile.maritimeSection.noProfile')}
</Text> </Text>
) : ( ) : (
<form onSubmit={handleProfileSubmit(onSaveProfile)}> <form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>Maritime Profile</Title> <Title order={5}>{t('profile.maritimeSection.title')}</Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
Your professional maritime details {t('profile.maritimeSection.subtitle')}
</Text> </Text>
<ProfileFormContent <ProfileFormContent
register={registerProfile} register={registerProfile}
@@ -703,7 +703,7 @@ export function ProfilePage() {
loading={isSavingMaritime} loading={isSavingMaritime}
leftSection={<IconDeviceFloppy size={18} />} leftSection={<IconDeviceFloppy size={18} />}
> >
Save Profile {t('profile.maritimeSection.save')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -721,9 +721,9 @@ export function ProfilePage() {
<form onSubmit={handleAddressSubmit(onSaveAddress)}> <form onSubmit={handleAddressSubmit(onSaveAddress)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>Address & Contact</Title> <Title order={5}>{t('profile.addressSection.title')}</Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
Your identity documents, contact details and emergency contact {t('profile.addressSection.subtitle')}
</Text> </Text>
<AddressFormContent <AddressFormContent
register={registerAddress} register={registerAddress}
@@ -877,7 +877,7 @@ export function ProfilePage() {
{t(`language.${lng}`)} {t(`language.${lng}`)}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'} {t(`profile.languageFull.${lng}`)}
</Text> </Text>
</div> </div>
{active ? ( {active ? (

View File

@@ -71,6 +71,7 @@ export const am: Translations = {
}, },
common: { common: {
select: 'ይምረጡ',
back: 'ተመለስ', back: 'ተመለስ',
continue: 'ቀጥል', continue: 'ቀጥል',
submit: 'አስገባ', submit: 'አስገባ',
@@ -283,6 +284,7 @@ export const am: Translations = {
viewProfile: 'ሙሉ መገለጫ ይመልከቱ', viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።', seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}', seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
}, },
profileSections: { profileSections: {
@@ -311,10 +313,30 @@ export const am: Translations = {
verified: 'ተረጋግጧል', verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም', unverified: 'አልተረጋገጠም',
tabs: { tabs: {
personal: 'የግል መረጃ',
profile: 'መገለጫ', profile: 'መገለጫ',
address: 'አድራሻ',
operations: 'የስራ ዘርፍ',
security: 'ደህንነት', security: 'ደህንነት',
preferences: 'ምርጫዎች', preferences: 'ምርጫዎች',
}, },
maritimeSection: {
title: 'የባህር ሙያ መገለጫ',
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
noProfile: 'ምንም መገለጫ አልተገኘም። መጀመሪያ መገለጫዎን ያጠናቅቁ።',
save: 'መገለጫ አስቀምጥ',
},
addressSection: {
title: 'አድራሻ እና መገናኛ',
subtitle: 'የመታወቂያ ሰነዶችዎ፣ የመገናኛ ዝርዝሮችዎ እና የአደጋ ጊዜ ተጠሪ',
save: 'አድራሻ አስቀምጥ',
},
addressSaved: 'አድራሻ ተቀምጧል',
nameMismatch: 'የመገለጫ ስም ከግል መረጃ ትር ውስጥ ካለው ስም ጋር መዛመድ አለበት።',
languageFull: {
en: 'እንግሊዝኛ (አሜሪካ)',
am: 'አማርኛ',
},
personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።', personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።',
languageTitle: 'ቋንቋ', languageTitle: 'ቋንቋ',
languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።', languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።',
@@ -369,6 +391,35 @@ export const am: Translations = {
}, },
}, },
profileForm: {
placeholders: {
firstName: 'የመጀመሪያ ስም ያስገቡ',
middleName: 'የአባት ስም ያስገቡ',
lastName: 'የአያት ስም ያስገቡ',
pob: 'ከተማ፣ ክልል',
},
genders: {
MALE: 'ወንድ',
FEMALE: 'ሴት',
},
maritalStatuses: {
SINGLE: 'ያላገባ/ች',
MARRIED: 'ያገባ/ች',
DIVORCED: 'የፈታ/ች',
WIDOWED: 'የሞተበት/ባት',
},
validation: {
professionRequired: 'ሙያዎን ይምረጡ',
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
middleNameMin: 'የአባት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
genderRequired: 'ጾታዎን ይምረጡ',
dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
},
},
country: { country: {
select: 'አገር ይምረጡ', select: 'አገር ይምረጡ',
notFound: 'ምንም አገር አልተገኘም', notFound: 'ምንም አገር አልተገኘም',

View File

@@ -70,6 +70,7 @@ export const en = {
}, },
common: { common: {
select: 'Select',
back: 'Back', back: 'Back',
continue: 'Continue', continue: 'Continue',
submit: 'Submit', submit: 'Submit',
@@ -283,6 +284,7 @@ export const en = {
seafarerReason: seafarerReason:
'Seafarer registration is built from your profile — these details fill it in for you.', 'Seafarer registration is built from your profile — these details fill it in for you.',
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}', seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
checkingProfile: 'Checking seafarer profile…',
}, },
profileSections: { profileSections: {
@@ -311,10 +313,30 @@ export const en = {
verified: 'Verified', verified: 'Verified',
unverified: 'Unverified', unverified: 'Unverified',
tabs: { tabs: {
personal: 'Personal',
profile: 'Profile', profile: 'Profile',
address: 'Address',
operations: 'Operations',
security: 'Security', security: 'Security',
preferences: 'Preferences', preferences: 'Preferences',
}, },
maritimeSection: {
title: 'Maritime Profile',
subtitle: 'Your professional maritime details',
noProfile: 'No profile found. Complete your profile setup first.',
save: 'Save Profile',
},
addressSection: {
title: 'Address & Contact',
subtitle: 'Your identity documents, contact details and emergency contact',
save: 'Save Address',
},
addressSaved: 'Address saved',
nameMismatch: 'Profile name must match the name in the Personal tab.',
languageFull: {
en: 'English (United States)',
am: 'Amharic',
},
personalHint: 'Your name as it appears on official EMA documents.', personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language', languageTitle: 'Language',
languageHint: 'Choose the language used across the EMA portal.', languageHint: 'Choose the language used across the EMA portal.',
@@ -369,6 +391,35 @@ export const en = {
}, },
}, },
profileForm: {
placeholders: {
firstName: 'Enter first name',
middleName: 'Enter middle name',
lastName: 'Enter last name',
pob: 'City, Region',
},
genders: {
MALE: 'Male',
FEMALE: 'Female',
},
maritalStatuses: {
SINGLE: 'Single',
MARRIED: 'Married',
DIVORCED: 'Divorced',
WIDOWED: 'Widowed',
},
validation: {
professionRequired: 'Select your profession',
firstNameMin: 'First name must be at least 3 characters',
middleNameMin: 'Middle name must be at least 3 characters',
lastNameMin: 'Last name must be at least 3 characters',
genderRequired: 'Select your gender',
dobRequired: 'Select your date of birth',
maritalStatusRequired: 'Select your marital status',
nameParts: 'Enter your first, middle, and last name',
},
},
country: { country: {
select: 'Select a country', select: 'Select a country',
notFound: 'No countries found', notFound: 'No countries found',