Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-21 06:56:07 +00:00
27 changed files with 809 additions and 93 deletions

View File

@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
@@ -126,9 +126,9 @@ export function ProfilePage() {
.string()
.min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
// Shared international rule: bare 09xxxxxxxx normalizes to +251, any
// other E.164 number is accepted as typed.
phoneNumber,
});
type ProfileValues = z.infer<typeof profileSchema>;

View File

@@ -14,7 +14,7 @@ import {
type FormSectionConfig,
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import { LocationPicker } from '../../location/components/LocationPicker';
@@ -223,6 +223,12 @@ export function ConfigDrivenSection({
value={(value as string) ?? ''}
onChange={(v) => onChange(field.key, v)}
/>
) : field.type === 'PHONE' ? (
<PhoneInput
{...common}
value={(value as string) ?? ''}
onChange={(v) => onChange(field.key, v)}
/>
) : field.type === 'TEXTAREA' ? (
<Textarea
{...common}

View File

@@ -270,13 +270,18 @@ export function LicenseApplicationPage() {
const nameFallback = accountName?.en
? splitPersonName(accountName.en)
: null;
const firstName = profile.firstName || nameFallback?.firstName || "";
const middleName = profile.middleName || nameFallback?.middleName || "";
const lastName = profile.lastName || nameFallback?.lastName || "";
const context = {
user: accountUser ?? profile.user,
profile: {
...profile,
firstName: profile.firstName || nameFallback?.firstName || "",
middleName: profile.middleName || nameFallback?.middleName || "",
lastName: profile.lastName || nameFallback?.lastName || "",
firstName,
middleName,
lastName,
// The profile has no single "full name" column — it's first/middle/last.
fullName: [firstName, middleName, lastName].filter(Boolean).join(" "),
},
};
@@ -291,7 +296,12 @@ export function LicenseApplicationPage() {
const untouched =
current === undefined || current === null || current === "";
if (!field.readOnly && !untouched) continue;
const value = readSourcePath(context, source);
const raw = readSourcePath(context, source);
// Profile dates arrive as ISO datetimes; a DATE field's picker wants
// yyyy-MM-dd. Seafarer registration's `profile.dob` source hits the
// same mismatch today — fixed once here rather than per config.
const value =
field.type === "DATE" && typeof raw === "string" ? raw.slice(0, 10) : raw;
if (value === undefined || value === null || value === "") continue;
if (current === value) continue;
next[section.key] = { ...next[section.key], [field.key]: value };

View File

@@ -4,7 +4,7 @@ import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFo
import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
import { phoneNumber, optionalPhoneNumber, CountrySelect, PhoneInput } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location, LocationType } from '../../location/types/location';
@@ -17,8 +17,8 @@ export function addressSchema(t: TFunction) {
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
primaryPhoneNumber: ethiopianPhone,
secondaryPhoneNumber: optionalEthiopianPhone,
primaryPhoneNumber: phoneNumber,
secondaryPhoneNumber: optionalPhoneNumber,
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
@@ -30,7 +30,7 @@ export function addressSchema(t: TFunction) {
// Emergency contact is collected but never required — leaving it blank must
// not stop an applicant moving on.
emergencyContactName: z.string().trim().optional(),
emergencyContactPhone: optionalEthiopianPhone,
emergencyContactPhone: optionalPhoneNumber,
emergencyContactRelation: z.string().trim().optional(),
});
}
@@ -147,18 +147,22 @@ export function AddressFormContent({
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
error={errors.nationality?.message}
/>
<TextInput
<PhoneInput
label={t('profileFields.primaryPhoneNumber')}
description={t('profileAddress.accountManagedHint')}
required
readOnly
{...register('primaryPhoneNumber')}
value={watch('primaryPhoneNumber') || ''}
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })}
onBlur={() => trigger('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message}
/>
<TextInput
<PhoneInput
label={t('profileAddress.secondaryPhoneNumber')}
placeholder={t('profileAddress.phonePlaceholder')}
{...register('secondaryPhoneNumber')}
value={watch('secondaryPhoneNumber') || ''}
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })}
onBlur={() => trigger('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
@@ -206,10 +210,12 @@ export function AddressFormContent({
{...register('emergencyContactName')}
error={errors.emergencyContactName?.message}
/>
<TextInput
<PhoneInput
label={t('profileAddress.contactPhone')}
placeholder={t('profileAddress.phonePlaceholder')}
{...register('emergencyContactPhone')}
value={watch('emergencyContactPhone') || ''}
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })}
onBlur={() => trigger('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message}
/>
<TextInput

View File

@@ -15,7 +15,12 @@ function isAtLeast18(dob: string): boolean {
return birth <= cutoff;
}
export const profileSchema = (t: TFunction) =>
// Printed on the Seaman Book, so a seafarer account can't leave them blank —
// every other account type may. Blood type offers UNKNOWN, so requiring an
// answer never forces a claim. Mirrors seafarer-registration.seed-data.ts's
// `required: true` on the same fields, and the backend's own check in
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
export const profileSchema = (t: TFunction, isSeafarer: boolean) =>
z.object({
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
@@ -28,8 +33,32 @@ export const profileSchema = (t: TFunction) =>
.refine((value) => isAtLeast18(value), {
message: t('profileForm.validation.dobMinAge'),
}),
pob: z.string().optional(),
pob: isSeafarer
? z.string().min(1, t('profileForm.validation.pobRequired'))
: z.string().optional(),
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
bloodType: isSeafarer
? z.string().min(1, t('profileForm.validation.bloodTypeRequired'))
: z.string().optional(),
hairColor: isSeafarer
? z.string().min(1, t('profileForm.validation.hairColorRequired'))
: z.string().optional(),
eyeColor: isSeafarer
? z.string().min(1, t('profileForm.validation.eyeColorRequired'))
: z.string().optional(),
heightCm: isSeafarer
? z
.string()
.min(1, t('profileForm.validation.heightRequired'))
.refine((v) => Number(v) >= 100 && Number(v) <= 250, {
message: t('profileForm.validation.heightRange'),
})
: z
.string()
.optional()
.refine((v) => !v || (Number(v) >= 100 && Number(v) <= 250), {
message: t('profileForm.validation.heightRange'),
}),
});
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
@@ -37,6 +66,13 @@ 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;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
// Must match EBloodType/EHairColor/EEyeColor on the backend (common/enums/user.enum.ts).
export const BLOOD_TYPES = [
'A_POSITIVE', 'A_NEGATIVE', 'B_POSITIVE', 'B_NEGATIVE',
'AB_POSITIVE', 'AB_NEGATIVE', 'O_POSITIVE', 'O_NEGATIVE', 'UNKNOWN',
] as const;
export const HAIR_COLORS = ['BLACK', 'BROWN', 'BLONDE', 'RED', 'GREY', 'WHITE', 'BALD', 'OTHER'] as const;
export const EYE_COLORS = ['BROWN', 'BLACK', 'BLUE', 'GREEN', 'HAZEL', 'GREY', 'OTHER'] as const;
interface ProfileFormContentProps {
register: UseFormRegister<ProfileValues>;
@@ -46,6 +82,8 @@ interface ProfileFormContentProps {
trigger: UseFormTrigger<ProfileValues>;
professionsLoading: boolean;
professionOptions: Array<{ value: string; label: string }>;
/** Place of birth, hair/eye colour and height become required for these accounts. */
isSeafarer: boolean;
}
export function ProfileFormContent({
@@ -56,6 +94,7 @@ export function ProfileFormContent({
trigger,
professionsLoading,
professionOptions,
isSeafarer,
}: ProfileFormContentProps) {
const { t } = useTranslation();
@@ -119,6 +158,7 @@ export function ProfileFormContent({
<TextInput
label={t('profileFields.pob')}
placeholder={t('profileForm.placeholders.pob')}
required={isSeafarer}
{...register('pob')}
error={errors.pob?.message}
/>
@@ -133,6 +173,50 @@ export function ProfileFormContent({
onBlur={() => trigger('maritalStatus')}
name="maritalStatus"
/>
<Select
label={t('profileFields.bloodType')}
placeholder={t('common.select')}
required={isSeafarer}
clearable={!isSeafarer}
data={BLOOD_TYPES.map((b) => ({ value: b, label: t(`profileForm.bloodTypes.${b}`) }))}
error={errors.bloodType?.message}
value={watch('bloodType') || null}
onChange={(val) => setValue('bloodType', val || '', { shouldValidate: true })}
onBlur={() => trigger('bloodType')}
name="bloodType"
/>
<TextInput
label={t('profileFields.heightCm')}
placeholder={t('profileForm.placeholders.heightCm')}
type="number"
required={isSeafarer}
{...register('heightCm')}
error={errors.heightCm?.message}
/>
<Select
label={t('profileFields.hairColor')}
placeholder={t('common.select')}
required={isSeafarer}
clearable={!isSeafarer}
data={HAIR_COLORS.map((h) => ({ value: h, label: t(`profileForm.hairColors.${h}`) }))}
error={errors.hairColor?.message}
value={watch('hairColor') || null}
onChange={(val) => setValue('hairColor', val || '', { shouldValidate: true })}
onBlur={() => trigger('hairColor')}
name="hairColor"
/>
<Select
label={t('profileFields.eyeColor')}
placeholder={t('common.select')}
required={isSeafarer}
clearable={!isSeafarer}
data={EYE_COLORS.map((e) => ({ value: e, label: t(`profileForm.eyeColors.${e}`) }))}
error={errors.eyeColor?.message}
value={watch('eyeColor') || null}
onChange={(val) => setValue('eyeColor', val || '', { shouldValidate: true })}
onBlur={() => trigger('eyeColor')}
name="eyeColor"
/>
</SimpleGrid>
);
}

View File

@@ -37,7 +37,6 @@ import {
IconBuildingWarehouse,
IconMapPin,
IconMoon,
IconPhone,
IconSettings,
IconShieldLock,
IconSun,
@@ -49,7 +48,7 @@ import { useForm } from 'react-hook-form';
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 { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { useApiMutation, useLocalized } from '@ema-platform/api';
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
@@ -187,6 +186,12 @@ export function ProfilePage() {
const showSeafarerBanner =
can([PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]) &&
!isReadyFor(SEAFARER_PROFILE_REQUIREMENT);
// Place of birth, blood type, hair/eye colour and height print on the
// Seaman Book, so a seafarer account can't leave them blank — every other
// account type may. Mirrors the seafarer-registration wizard's
// `required: true` on the same fields and the backend check in
// ProfileService.assertPhysicalCharacteristicsForSeafarer.
const isSeafarer = (resolvedProfile ?? storedProfile)?.type === 'SEAFARER';
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
@@ -224,6 +229,10 @@ export function ProfilePage() {
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
pob: currentProfile.pob || '',
maritalStatus: currentProfile.maritalStatus || '',
bloodType: currentProfile.bloodType || '',
hairColor: currentProfile.hairColor || '',
eyeColor: currentProfile.eyeColor || '',
heightCm: currentProfile.heightCm != null ? String(currentProfile.heightCm) : '',
});
// Primary phone and email are the account's contact details (same
@@ -270,7 +279,7 @@ export function ProfilePage() {
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
phoneNumber,
});
type PersonalValues = z.infer<typeof personalSchema>;
@@ -278,6 +287,9 @@ export function ProfilePage() {
register: registerPersonal,
handleSubmit: handlePersonalSubmit,
reset: resetPersonal,
watch: watchPersonal,
setValue: setValuePersonal,
trigger: triggerPersonal,
formState: { errors: personalErrors },
} = useForm<PersonalValues>({
resolver: zodResolver(personalSchema),
@@ -365,7 +377,7 @@ export function ProfilePage() {
trigger: profileTriggerValidation,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema(t)),
resolver: zodResolver(profileSchema(t, isSeafarer)),
values: loadedProfile ?? undefined,
});
@@ -383,7 +395,15 @@ export function ProfilePage() {
await updateProfile({
url: `/profiles/${profileId}`,
method: 'PUT',
body: values,
// Empty string is not a valid enum value on the backend — an
// untouched Select must clear the column, not fail validation.
body: {
...values,
heightCm: values.heightCm ? Number(values.heightCm) : null,
bloodType: values.bloodType || null,
hairColor: values.hairColor || null,
eyeColor: values.eyeColor || null,
},
}).unwrap();
setLoadedProfile({ ...values, pob: values.pob ?? '' });
@@ -669,11 +689,12 @@ export function ProfilePage() {
error={personalErrors.email?.message}
{...registerPersonal('email')}
/>
<TextInput
<PhoneInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
value={watchPersonal('phoneNumber') || ''}
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })}
onBlur={() => triggerPersonal('phoneNumber')}
error={personalErrors.phoneNumber?.message}
{...registerPersonal('phoneNumber')}
/>
</SimpleGrid>
</div>
@@ -724,6 +745,7 @@ export function ProfilePage() {
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
isSeafarer={isSeafarer}
/>
</div>

View File

@@ -22,12 +22,12 @@ import {
IconCheck,
IconLock,
IconMail,
IconPhone,
IconShip,
IconUser,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
const OWNER_TYPES = [
'Individual (Private Owner)',
@@ -51,7 +51,7 @@ export function VesselOwnerRegisterPage() {
const [success, setSuccess] = useState(false);
const [registerTrigger] = useApiMutation<{ id: string }>();
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const canSubmit = !!fullName.trim() && !!email.trim() && isValidPhoneNumber(phone) && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const handleRegister = async () => {
if (!canSubmit) {
@@ -143,13 +143,11 @@ export function VesselOwnerRegisterPage() {
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
<PhoneInput
label="Phone Number"
placeholder="+251 9XX XXX XXX"
leftSection={<IconPhone size={16} />}
required
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
onChange={setPhone}
/>
<TextInput
label="National ID / TIN"

View File

@@ -31,7 +31,8 @@ import {
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// Minimal vessel type for the approved vessel list
interface ApprovedVessel {
@@ -208,7 +209,7 @@ export function OwnershipTransferPage() {
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
isValidPhoneNumber(newOwnerPhone) && !!transferReason && !!billOfSale;
const resetForm = () => {
setSelectedVesselId(null);
@@ -393,7 +394,7 @@ export function OwnershipTransferPage() {
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
<PhoneInput label="Phone Number" required value={newOwnerPhone} onChange={setNewOwnerPhone} />
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />

View File

@@ -35,7 +35,8 @@ import {
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// ---------------------------------------------------------------------------
// Constants
@@ -311,7 +312,7 @@ export function VesselRegistrationApplicationPage() {
if (active === 2) return (
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && isValidPhoneNumber(ownerPhone)
);
if (active === 3) return category === 'Inland Waterway Vessel'
? !!files.vesselPhotos
@@ -551,12 +552,11 @@ export function VesselRegistrationApplicationPage() {
value={ownerNationalIdOrTin}
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
/>
<TextInput
<PhoneInput
label="Owner Phone"
placeholder="+251 9XX XXX XXX"
required
value={ownerPhone}
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
onChange={setOwnerPhone}
/>
<TextInput
label="Owner Address"

View File

@@ -258,6 +258,10 @@ export const am: Translations = {
dob: 'የትውልድ ቀን',
pob: 'የትውልድ ቦታ',
maritalStatus: 'የጋብቻ ሁኔታ',
bloodType: 'የደም አይነት',
hairColor: 'የፀጉር ቀለም',
eyeColor: 'የአይን ቀለም',
heightCm: 'ቁመት (ሴ.ሜ)',
professionId: 'ሙያ',
idType: 'የመታወቂያ ዓይነት',
idNumber: 'የመታወቂያ ቁጥር',
@@ -436,6 +440,7 @@ export const am: Translations = {
middleName: 'የአባት ስም ያስገቡ',
lastName: 'የአያት ስም ያስገቡ',
pob: 'ከተማ፣ ክልል',
heightCm: 'ለምሳሌ 175',
},
genders: {
MALE: 'ወንድ',
@@ -447,6 +452,36 @@ export const am: Translations = {
DIVORCED: 'የፈታ/ች',
WIDOWED: 'የሞተበት/ባት',
},
bloodTypes: {
A_POSITIVE: 'A+',
A_NEGATIVE: 'A-',
B_POSITIVE: 'B+',
B_NEGATIVE: 'B-',
AB_POSITIVE: 'AB+',
AB_NEGATIVE: 'AB-',
O_POSITIVE: 'O+',
O_NEGATIVE: 'O-',
UNKNOWN: 'የማይታወቅ',
},
hairColors: {
BLACK: 'ጥቁር',
BROWN: 'ቡናማ',
BLONDE: 'ወርቃማ',
RED: 'ቀይ',
GREY: 'ግራጫ',
WHITE: 'ነጭ',
BALD: 'ራሰ በራ',
OTHER: 'ሌላ',
},
eyeColors: {
BROWN: 'ቡናማ',
BLACK: 'ጥቁር',
BLUE: 'ሰማያዊ',
GREEN: 'አረንጓዴ',
HAZEL: 'ኮክ ቡናማ',
GREY: 'ግራጫ',
OTHER: 'ሌላ',
},
validation: {
professionRequired: 'ሙያዎን ይምረጡ',
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
@@ -457,6 +492,12 @@ export const am: Translations = {
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
heightRange: 'ቁመት ከ100 እስከ 250 ሴ.ሜ መሆን አለበት',
pobRequired: 'የትውልድ ቦታዎን ያስገቡ',
bloodTypeRequired: 'የደም አይነትዎን ይምረጡ — ካልተመረመሩ የማይታወቅ ይምረጡ',
hairColorRequired: 'የፀጉር ቀለምዎን ይምረጡ',
eyeColorRequired: 'የአይን ቀለምዎን ይምረጡ',
heightRequired: 'ቁመትዎን ያስገቡ',
},
},
@@ -542,7 +583,7 @@ export const am: Translations = {
},
login: {
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ",
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
subtitle: "መለያዎን ለመድረስ ይግቡ።",
@@ -577,7 +618,7 @@ export const am: Translations = {
usernameLabel: "የተጠቃሚ ስም",
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
phoneLabel: "ስልክ ቁጥር",
phonePlaceholder: "+251 911 234 567",
phonePlaceholder: "9XX XXX XXX",
passwordLabel: "የይለፍ ቃል",
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
@@ -692,7 +733,7 @@ export const am: Translations = {
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
idTypePlaceholder: 'ይምረጡ',
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
phonePlaceholder: '+251 9XX XXX XXX',
phonePlaceholder: '9XX XXX XXX',
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
postalAddressPlaceholder: 'ፖስታ ሳጥን',
contactNamePlaceholder: 'ሙሉ ስም',

View File

@@ -257,6 +257,10 @@ export const en = {
dob: 'Date of birth',
pob: 'Place of birth',
maritalStatus: 'Marital status',
bloodType: 'Blood type',
hairColor: 'Hair color',
eyeColor: 'Eye color',
heightCm: 'Height (cm)',
professionId: 'Profession',
idType: 'ID type',
idNumber: 'ID number',
@@ -435,6 +439,7 @@ export const en = {
middleName: 'Enter middle name',
lastName: 'Enter last name',
pob: 'City, Region',
heightCm: 'e.g. 175',
},
genders: {
MALE: 'Male',
@@ -446,6 +451,36 @@ export const en = {
DIVORCED: 'Divorced',
WIDOWED: 'Widowed',
},
bloodTypes: {
A_POSITIVE: 'A+',
A_NEGATIVE: 'A-',
B_POSITIVE: 'B+',
B_NEGATIVE: 'B-',
AB_POSITIVE: 'AB+',
AB_NEGATIVE: 'AB-',
O_POSITIVE: 'O+',
O_NEGATIVE: 'O-',
UNKNOWN: 'Unknown',
},
hairColors: {
BLACK: 'Black',
BROWN: 'Brown',
BLONDE: 'Blonde',
RED: 'Red',
GREY: 'Grey',
WHITE: 'White',
BALD: 'Bald',
OTHER: 'Other',
},
eyeColors: {
BROWN: 'Brown',
BLACK: 'Black',
BLUE: 'Blue',
GREEN: 'Green',
HAZEL: 'Hazel',
GREY: 'Grey',
OTHER: 'Other',
},
validation: {
professionRequired: 'Select your profession',
firstNameMin: 'First name must be at least 3 characters',
@@ -456,6 +491,12 @@ export const en = {
dobMinAge: 'You must be at least 18 years old',
maritalStatusRequired: 'Select your marital status',
nameParts: 'Enter your first, middle, and last name',
heightRange: 'Height must be between 100 and 250 cm',
pobRequired: 'Enter your place of birth',
bloodTypeRequired: 'Select your blood type — choose Unknown if untested',
hairColorRequired: 'Select your hair color',
eyeColorRequired: 'Select your eye color',
heightRequired: 'Enter your height',
},
},
@@ -541,7 +582,7 @@ export const en = {
},
login: {
emailOrPhoneInvalid: 'Enter a valid email or phone number (+2519xxxxxxxx)',
emailOrPhoneInvalid: 'Enter a valid email or phone number',
passwordMinLength: 'Password must be at least 8 characters',
welcome: 'Welcome to {{appName}}',
subtitle: 'Sign in to access your account.',
@@ -576,7 +617,7 @@ export const en = {
usernameLabel: 'Username',
usernamePlaceholder: 'Choose a username',
phoneLabel: 'Phone number',
phonePlaceholder: '+251 911 234 567',
phonePlaceholder: '9XX XXX XXX',
passwordLabel: 'Password',
passwordPlaceholder: 'At least 8 characters',
confirmPasswordLabel: 'Confirm password',
@@ -691,7 +732,7 @@ export const en = {
accountManagedHint: 'From your account, edit it in the Personal tab',
idTypePlaceholder: 'Select',
idNumberPlaceholder: 'Enter ID number',
phonePlaceholder: '+251 9XX XXX XXX',
phonePlaceholder: '9XX XXX XXX',
streetAddressPlaceholder: 'Street name, house number',
postalAddressPlaceholder: 'P.O. Box',
contactNamePlaceholder: 'Full name',

View File

@@ -67,6 +67,10 @@ export const router = createBrowserRouter([
{ path: "/set-password", element: <SetPasswordPage /> },
{ path: "/reset-password", element: <SetPasswordPage /> },
// Reached from the login page by a logged-out user, so it must stay
// public — ProtectedRoute would bounce them straight to the landing page.
{ path: "/forgot-password", element: <ForgotPasswordPage /> },
// Protected auth pages
{
element: (
@@ -76,14 +80,6 @@ export const router = createBrowserRouter([
),
path: "/otp-verify",
},
{
element: (
<ProtectedRoute>
<ForgotPasswordPage />
</ProtectedRoute>
),
path: "/forgot-password",
},
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
// profile details are collected where they are actually needed: on /profile,
// via the dashboard nudge, or inline in an application flow. The path stays