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

View File

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

View File

@@ -1,5 +1,4 @@
import { useEffect } from 'react';
import { Center, Loader } from '@mantine/core';
import { Navigate, useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageLoader, notify } from '@ema-platform/ui';
@@ -35,8 +34,12 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
'primaryPhoneNumber',
'email',
],
reason:
'Seafarer registration is built from your profile — these details fill it in for you.',
// Translation key, not literal text — `ProfileRequirementGate` runs it
// 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';
@@ -75,7 +78,7 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
if (!gated) return <>{children}</>;
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
@@ -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
// bounce them off the screen they just finished.
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]];

View File

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

View File

@@ -71,6 +71,7 @@ export const am: Translations = {
},
common: {
select: 'ይምረጡ',
back: 'ተመለስ',
continue: 'ቀጥል',
submit: 'አስገባ',
@@ -283,6 +284,7 @@ export const am: Translations = {
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
},
profileSections: {
@@ -311,10 +313,30 @@ export const am: Translations = {
verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም',
tabs: {
personal: 'የግል መረጃ',
profile: 'መገለጫ',
address: 'አድራሻ',
operations: 'የስራ ዘርፍ',
security: 'ደህንነት',
preferences: 'ምርጫዎች',
},
maritimeSection: {
title: 'የባህር ሙያ መገለጫ',
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
noProfile: 'ምንም መገለጫ አልተገኘም። መጀመሪያ መገለጫዎን ያጠናቅቁ።',
save: 'መገለጫ አስቀምጥ',
},
addressSection: {
title: 'አድራሻ እና መገናኛ',
subtitle: 'የመታወቂያ ሰነዶችዎ፣ የመገናኛ ዝርዝሮችዎ እና የአደጋ ጊዜ ተጠሪ',
save: 'አድራሻ አስቀምጥ',
},
addressSaved: 'አድራሻ ተቀምጧል',
nameMismatch: 'የመገለጫ ስም ከግል መረጃ ትር ውስጥ ካለው ስም ጋር መዛመድ አለበት።',
languageFull: {
en: 'እንግሊዝኛ (አሜሪካ)',
am: 'አማርኛ',
},
personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።',
languageTitle: 'ቋንቋ',
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: {
select: 'አገር ይምረጡ',
notFound: 'ምንም አገር አልተገኘም',

View File

@@ -70,6 +70,7 @@ export const en = {
},
common: {
select: 'Select',
back: 'Back',
continue: 'Continue',
submit: 'Submit',
@@ -283,6 +284,7 @@ export const en = {
seafarerReason:
'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}}',
checkingProfile: 'Checking seafarer profile…',
},
profileSections: {
@@ -311,10 +313,30 @@ export const en = {
verified: 'Verified',
unverified: 'Unverified',
tabs: {
personal: 'Personal',
profile: 'Profile',
address: 'Address',
operations: 'Operations',
security: 'Security',
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.',
languageTitle: 'Language',
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: {
select: 'Select a country',
notFound: 'No countries found',