mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -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>;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: 'ሙሉ ስም',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
@@ -534,6 +535,13 @@ export function validateSections(
|
||||
}
|
||||
if (empty) continue;
|
||||
|
||||
// PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
|
||||
// non-empty string that still has to be caught here.
|
||||
if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
|
||||
errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
|
||||
continue;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
if (field.min !== undefined && numeric < field.min) {
|
||||
|
||||
@@ -23,11 +23,25 @@ import { z } from 'zod';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
// Same email-or-phone rule as LoginPage: a phone-looking value normalizes to
|
||||
// E.164 (bare Ethiopian national numbers default to +251) so the backend
|
||||
// always gets a value it can look the account up by, under the `email` key.
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
if (emailRegex.test(value)) return value;
|
||||
return parsePhoneNumberFromString(value, 'ET')?.number ?? value;
|
||||
})
|
||||
.refine((value) => emailRegex.test(value) || isValidPhoneNumber(value), {
|
||||
message: 'Enter a valid email or phone number',
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
@@ -156,8 +170,8 @@ export function ForgotPasswordPage() {
|
||||
Forgot your password?
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Enter the email linked to your account and we'll send you a link
|
||||
to reset your password.
|
||||
Enter the email or phone number linked to your account and
|
||||
we'll send you a link to reset your password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +184,7 @@ export function ForgotPasswordPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email address"
|
||||
label="Email or phone"
|
||||
placeholder="you@example.com"
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useApiMutation } from "@ema-platform/api";
|
||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
|
||||
import { AuthShell } from "../components/AuthShell";
|
||||
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
|
||||
import type {
|
||||
@@ -53,11 +54,7 @@ export function LoginPage() {
|
||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
// Built inside the component (not module scope) so validation messages
|
||||
@@ -67,24 +64,23 @@ export function LoginPage() {
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => {
|
||||
// Convert 09xxxxxxxx -> +2519xxxxxxxx
|
||||
if (/^09\d{8}$/.test(value)) {
|
||||
return `+251${value.substring(1)}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
// A phone-looking value normalizes to E.164 (bare Ethiopian
|
||||
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
|
||||
// international check below can validate it; anything else
|
||||
// (an email) passes through untouched.
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (emailRegex.test(value)) return value;
|
||||
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRegex = /^\+2519\d{8}$/;
|
||||
|
||||
return emailRegex.test(value) || phoneRegex.test(value);
|
||||
return emailRegex.test(value) || isValidPhoneNumber(value);
|
||||
},
|
||||
{
|
||||
message: t(
|
||||
"login.emailOrPhoneInvalid",
|
||||
"Enter a valid email or phone number (+2519xxxxxxxx)",
|
||||
"Enter a valid email or phone number",
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconAt,
|
||||
IconDeviceMobile,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconUser,
|
||||
@@ -29,7 +28,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
@@ -88,7 +87,7 @@ export function SignupPage() {
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
|
||||
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
|
||||
phoneNumber,
|
||||
userType: z.literal('individual'),
|
||||
nameEn: z
|
||||
.string()
|
||||
@@ -111,6 +110,8 @@ export function SignupPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -247,12 +248,13 @@ export function SignupPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
<PhoneInput
|
||||
label={t('signup.phoneLabel', 'Phone number')}
|
||||
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
|
||||
value={watch('phoneNumber') || ''}
|
||||
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
|
||||
onBlur={() => trigger('phoneNumber')}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...register('phoneNumber')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
|
||||
@@ -87,6 +87,11 @@ export interface CurrentProfile {
|
||||
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
||||
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
||||
seafarerStatusReason?: string | null;
|
||||
/** Identifying particulars for the Seaman Book. Left blank by choice. */
|
||||
bloodType?: string | null;
|
||||
hairColor?: string | null;
|
||||
eyeColor?: string | null;
|
||||
heightCm?: number | null;
|
||||
user: AuthUser;
|
||||
address: CurrentProfileAddress;
|
||||
profession: CurrentProfileProfession;
|
||||
|
||||
39
libs/auth/src/lib/utils/jwt.spec.ts
Normal file
39
libs/auth/src/lib/utils/jwt.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { currentSessionId } from './jwt';
|
||||
|
||||
/** Builds a JWT-shaped string whose payload is `claims`, base64url encoded. */
|
||||
function token(claims: Record<string, unknown>): string {
|
||||
const payload = btoa(JSON.stringify(claims))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
describe('currentSessionId', () => {
|
||||
it('reads the sessionId claim', () => {
|
||||
expect(currentSessionId(token({ sessionId: 'abc' }))).toBe('abc');
|
||||
});
|
||||
|
||||
it('falls back to sid, then jti', () => {
|
||||
expect(currentSessionId(token({ sid: 'from-sid' }))).toBe('from-sid');
|
||||
expect(currentSessionId(token({ jti: 'from-jti' }))).toBe('from-jti');
|
||||
});
|
||||
|
||||
it('decodes payloads containing base64url characters', () => {
|
||||
// '>' and '?' are what force '+' and '/' in standard base64.
|
||||
const id = 'a>b?c>d?e>f?';
|
||||
expect(currentSessionId(token({ sessionId: id }))).toBe(id);
|
||||
});
|
||||
|
||||
it('returns undefined for a token with no session claim', () => {
|
||||
expect(currentSessionId(token({ sub: 'user-1' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined rather than throwing on junk', () => {
|
||||
expect(currentSessionId(undefined)).toBeUndefined();
|
||||
expect(currentSessionId('')).toBeUndefined();
|
||||
expect(currentSessionId('opaque-token')).toBeUndefined();
|
||||
expect(currentSessionId('header.not-base64!!.sig')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ export * from "./lib/layout/LanguageSwitcher";
|
||||
export * from "./lib/layout/PageHeader";
|
||||
export * from "./lib/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/PhoneInput";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
|
||||
@@ -16,9 +16,9 @@ registerLocale(en);
|
||||
registerLocale(am);
|
||||
registerNationalityLocale(nationalityEn);
|
||||
|
||||
type CountryLang = 'en' | 'am';
|
||||
export type CountryLang = 'en' | 'am';
|
||||
|
||||
function resolveLang(lng: string): CountryLang {
|
||||
export function resolveLang(lng: string): CountryLang {
|
||||
return lng === 'am' ? 'am' : 'en';
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
|
||||
return getCountryName(code, lang);
|
||||
}
|
||||
|
||||
function CountryFlag({ code }: { code: string }) {
|
||||
export function CountryFlag({ code }: { code: string }) {
|
||||
const Flag = Flags[code as keyof typeof Flags];
|
||||
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
|
||||
}
|
||||
|
||||
219
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
219
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
|
||||
import {
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
parsePhoneNumberFromString,
|
||||
type CountryCode,
|
||||
} from 'libphonenumber-js';
|
||||
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
|
||||
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone';
|
||||
|
||||
// Static list, computed once at module load — same as CountrySelect's dataset.
|
||||
const COUNTRY_CODES = getCountries();
|
||||
|
||||
type CountryOption = ComboboxItem & { name: string };
|
||||
|
||||
// Search by country name ("united"), dial code ("+1") or ISO prefix ("us") —
|
||||
// the closed control's label alone ("+1") isn't enough to find a country.
|
||||
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return (options as CountryOption[]).filter(
|
||||
(o) =>
|
||||
o.name.toLowerCase().includes(q) ||
|
||||
o.label.toLowerCase().includes(q) ||
|
||||
o.value.toLowerCase().startsWith(q),
|
||||
);
|
||||
};
|
||||
|
||||
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => {
|
||||
const o = option as CountryOption;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" flex={1}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CountryFlag code={o.value} />
|
||||
<Text fz="sm">{o.name}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{o.label}</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PhoneInputProps {
|
||||
/** E.164 (`+14155552671`), or '' when empty. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* Fired when focus leaves a field that has digits in it — wire to the
|
||||
* form's `trigger`. A blank field is left to submit-time validation, like
|
||||
* the form's other inputs, so tabbing past it doesn't raise an error.
|
||||
*/
|
||||
onBlur?: () => void;
|
||||
label?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
description?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
required?: boolean;
|
||||
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
|
||||
withAsterisk?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* International phone entry: a searchable country/dial-code select beside a
|
||||
* national-number text box, WhatsApp/Telegram style. Controlled —
|
||||
* `value`/`onChange` carry the E.164 string.
|
||||
*
|
||||
* The typed digits live in local state rather than being re-derived from
|
||||
* `value` on every render: an incomplete number doesn't parse, so deriving
|
||||
* would blank the box between keystrokes.
|
||||
*/
|
||||
export function PhoneInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
error,
|
||||
required,
|
||||
withAsterisk,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: PhoneInputProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = resolveLang(i18n.language);
|
||||
|
||||
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
|
||||
const [country, setCountry] = useState<CountryCode>(initialCountry);
|
||||
const [national, setNational] = useState(() =>
|
||||
formatNational(toNationalDigits(value, initialCountry), initialCountry),
|
||||
);
|
||||
// Mantine keeps the selected label ("+251") as the search text, so typing
|
||||
// would search for "+251u". Cleared while the dropdown is open instead.
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// What this field last pushed upward, so an echo of our own value isn't
|
||||
// mistaken for the parent resetting the form.
|
||||
const emitted = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === emitted.current) return;
|
||||
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
|
||||
const next = parsedCountry ?? country;
|
||||
if (parsedCountry) setCountry(parsedCountry);
|
||||
setNational(formatNational(toNationalDigits(value, next), next));
|
||||
emitted.current = value;
|
||||
// Adopting an outside change only — `country` is state written here, so
|
||||
// re-running on it would fight the user's typing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const countryOptions = useMemo<CountryOption[]>(
|
||||
() =>
|
||||
COUNTRY_CODES.map((code) => ({
|
||||
value: code,
|
||||
label: `+${getCountryCallingCode(code)}`,
|
||||
name: getCountryName(code, lang),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name, lang)),
|
||||
[lang],
|
||||
);
|
||||
|
||||
function push(next: string) {
|
||||
emitted.current = next;
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function applyDigits(digits: string, forCountry: CountryCode) {
|
||||
// Keystroke past the longest possible number is ignored, not stored.
|
||||
if (exceedsMaxLength(digits, forCountry)) return;
|
||||
// Once the number is valid, show the true national part: someone who
|
||||
// types a trunk prefix (0911111111) or the country code (251911111111)
|
||||
// shouldn't end up with it doubled beside the "+251" selector. Not
|
||||
// before: a partial number can parse too, and rewriting it mid-typing
|
||||
// makes digits vanish under the caret.
|
||||
const parsed = parsePhoneNumberFromString(digits, forCountry);
|
||||
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
|
||||
push(toE164(digits, forCountry));
|
||||
}
|
||||
|
||||
function handleText(text: string) {
|
||||
// A full `+<code><number>` arriving at once (paste, autofill, or a test
|
||||
// driver's `.fill()`) is parsed standalone and switches the country,
|
||||
// rather than being read under whatever country was already selected.
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith('+')) {
|
||||
const parsed = parsePhoneNumberFromString(trimmed);
|
||||
if (parsed?.country) {
|
||||
setCountry(parsed.country);
|
||||
setNational(formatNational(parsed.nationalNumber, parsed.country));
|
||||
push(parsed.number);
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyDigits(nextNationalDigits(text, national), country);
|
||||
}
|
||||
|
||||
function handleCountryChange(next: string | null) {
|
||||
if (!next) return;
|
||||
const code = next as CountryCode;
|
||||
setCountry(code);
|
||||
// A number carried over from a longer plan is cut to fit the new one.
|
||||
applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
required={required}
|
||||
withAsterisk={withAsterisk}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={national}
|
||||
onChange={(e) => handleText(e.currentTarget.value)}
|
||||
// Blur of the whole field, not of the number box: moving into the
|
||||
// country select mustn't validate a half-typed number.
|
||||
wrapperProps={{
|
||||
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.();
|
||||
},
|
||||
}}
|
||||
leftSectionWidth={92}
|
||||
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
|
||||
leftSection={
|
||||
<Select
|
||||
aria-label={t('phone.countryCode', 'Country code')}
|
||||
data={countryOptions}
|
||||
value={country}
|
||||
onChange={handleCountryChange}
|
||||
renderOption={renderCountryOption}
|
||||
filter={filterCountries}
|
||||
searchable
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
onDropdownOpen={() => setSearch('')}
|
||||
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
|
||||
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
|
||||
allowDeselect={false}
|
||||
disabled={disabled || readOnly}
|
||||
variant="unstyled"
|
||||
size="xs"
|
||||
w={92}
|
||||
maxDropdownHeight={320}
|
||||
comboboxProps={{ width: 260, position: 'bottom-start' }}
|
||||
leftSection={<CountryFlag code={country} />}
|
||||
leftSectionWidth={30}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
131
libs/ui/src/lib/input/phone.spec.ts
Normal file
131
libs/ui/src/lib/input/phone.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
|
||||
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
|
||||
describe('phoneNumber', () => {
|
||||
it('normalizes a legacy Ethiopian national number to E.164', () => {
|
||||
expect(phoneNumber.parse('0911223344')).toBe('+251911223344');
|
||||
});
|
||||
|
||||
it('passes an Ethiopian E.164 number through unchanged', () => {
|
||||
expect(phoneNumber.parse('+251911223344')).toBe('+251911223344');
|
||||
});
|
||||
|
||||
it('accepts a valid international number', () => {
|
||||
expect(phoneNumber.parse('+14155552671')).toBe('+14155552671');
|
||||
});
|
||||
|
||||
it('rejects a too-short number', () => {
|
||||
expect(() => phoneNumber.parse('+251911')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-numeric input', () => {
|
||||
expect(() => phoneNumber.parse('abc')).toThrow('Enter a valid phone number');
|
||||
});
|
||||
|
||||
it('reports a blank value as missing, not invalid', () => {
|
||||
expect(() => phoneNumber.parse('')).toThrow('Phone number is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionalPhoneNumber', () => {
|
||||
it('allows a blank value', () => {
|
||||
expect(optionalPhoneNumber.parse('')).toBe('');
|
||||
});
|
||||
|
||||
it('still validates a non-blank value', () => {
|
||||
expect(() => optionalPhoneNumber.parse('abc')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('typing helpers', () => {
|
||||
// Regression: an incomplete number doesn't parse, and an earlier version
|
||||
// collapsed it to '' — the box emptied on every keystroke.
|
||||
it('keeps partial digits as the number is typed one character at a time', () => {
|
||||
let display = '';
|
||||
let value = '';
|
||||
for (const ch of '911223344') {
|
||||
const digits = nextNationalDigits(display + ch, display);
|
||||
display = new AsYouType('ET').input(digits);
|
||||
value = toE164(digits, 'ET');
|
||||
}
|
||||
expect(display).toBe('911223344');
|
||||
expect(value).toBe('+251911223344');
|
||||
});
|
||||
|
||||
it('drops a digit when a keystroke only removed a formatting character', () => {
|
||||
// "(415)" backspaced to "(415" leaves the digits unchanged.
|
||||
expect(nextNationalDigits('(415', '(415)')).toBe('41');
|
||||
});
|
||||
|
||||
it('keeps the deleted digit count when a real digit is removed', () => {
|
||||
expect(nextNationalDigits('91122334', '911223344')).toBe('91122334');
|
||||
});
|
||||
|
||||
it('reads the national part back out of a stored E.164 value', () => {
|
||||
expect(toNationalDigits('+251911223344', 'ET')).toBe('911223344');
|
||||
expect(toNationalDigits('+14155552671', 'US')).toBe('4155552671');
|
||||
expect(toNationalDigits('', 'ET')).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to a dial-code concatenation while the number is incomplete', () => {
|
||||
expect(toE164('9', 'ET')).toBe('+2519');
|
||||
expect(toE164('', 'ET')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trunk prefix and country code entered into the number box', () => {
|
||||
// The box holds the national part next to a "+251" selector, so a trunk 0
|
||||
// or a typed country code must be absorbed, not shown (and doubled) there.
|
||||
const cases: Array<[string, string]> = [
|
||||
['0911111111', '911111111'],
|
||||
['251911111111', '911111111'],
|
||||
['911111111', '911111111'],
|
||||
];
|
||||
it.each(cases)('normalizes %s to the national number %s', (typed, national) => {
|
||||
const parsed = parsePhoneNumberFromString(typed.replace(/\D/g, ''), 'ET');
|
||||
expect(parsed?.nationalNumber).toBe(national);
|
||||
});
|
||||
|
||||
it.each(cases)('yields a valid E.164 value for %s', (typed) => {
|
||||
expect(phoneNumber.parse(typed)).toBe('+251911111111');
|
||||
});
|
||||
|
||||
it('accepts a pasted +251 number', () => {
|
||||
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNational', () => {
|
||||
it('groups digits without the dial code or trunk prefix', () => {
|
||||
expect(formatNational('911223344', 'ET')).toBe('91 122 3344');
|
||||
expect(formatNational('4155552671', 'US')).toBe('415 555 2671');
|
||||
expect(formatNational('91', 'ET')).toBe('91');
|
||||
expect(formatNational('', 'ET')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps digits intact when the number does not format', () => {
|
||||
expect(formatNational('0911223344', 'ET').replace(/\D/g, '')).toBe('0911223344');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy stored values', () => {
|
||||
it('shows a national record without the trunk prefix', () => {
|
||||
expect(toNationalDigits('0911223344', 'ET')).toBe('911223344');
|
||||
});
|
||||
});
|
||||
|
||||
describe('length limit', () => {
|
||||
it('reports the longest national number per country', () => {
|
||||
expect(maxNationalLength('ET')).toBe(9);
|
||||
expect(maxNationalLength('US')).toBe(10);
|
||||
});
|
||||
|
||||
it('flags digits past the limit, counting the national part only', () => {
|
||||
expect(exceedsMaxLength('911223344', 'ET')).toBe(false);
|
||||
expect(exceedsMaxLength('9112233445', 'ET')).toBe(true);
|
||||
expect(exceedsMaxLength('0911223344', 'ET')).toBe(false);
|
||||
expect(exceedsMaxLength('251911223344', 'ET')).toBe(false);
|
||||
expect(exceedsMaxLength('09112233445', 'ET')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,88 @@
|
||||
import { z } from 'zod';
|
||||
import { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
|
||||
|
||||
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
|
||||
export const ethiopianPhone = z
|
||||
/**
|
||||
* Accepts any international number in E.164 (`+<country><number>`) or a
|
||||
* bare national number, which is assumed Ethiopian (`0911223344` ->
|
||||
* `+251911223344`) for backward compatibility with existing records.
|
||||
*/
|
||||
export const phoneNumber = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
|
||||
.refine((v) => /^\+2519\d{8}$/.test(v), {
|
||||
message: 'Enter a valid phone number (+2519xxxxxxxx)',
|
||||
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
|
||||
.superRefine((v, ctx) => {
|
||||
if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' });
|
||||
else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' });
|
||||
});
|
||||
|
||||
/** Same rules, but blank is allowed. */
|
||||
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
|
||||
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
|
||||
|
||||
/**
|
||||
* Digits the field should hold after an edit. A keystroke that only removed
|
||||
* a formatting character (backspacing the ')' out of "(415)") leaves the
|
||||
* digits unchanged, which would otherwise make the caret stick — drop a real
|
||||
* digit in that case.
|
||||
*/
|
||||
export function nextNationalDigits(text: string, prevDisplay: string): string {
|
||||
const digits = text.replace(/\D/g, '');
|
||||
const deleting = text.length < prevDisplay.length;
|
||||
if (deleting && digits === prevDisplay.replace(/\D/g, '')) return digits.slice(0, -1);
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** National digits of a stored value, for display in the number box. */
|
||||
export function toNationalDigits(value: string, country: CountryCode): string {
|
||||
if (!value) return '';
|
||||
// `country` also resolves legacy national records ("0911223344").
|
||||
const parsed = parsePhoneNumberFromString(value, country);
|
||||
if (parsed) return parsed.nationalNumber;
|
||||
if (value.startsWith('+')) {
|
||||
const prefix = `+${getCountryCallingCode(country)}`;
|
||||
if (value.startsWith(prefix)) return value.slice(prefix.length).replace(/\D/g, '');
|
||||
}
|
||||
return value.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* E.164 for the digits typed so far. Incomplete numbers don't parse, so they
|
||||
* fall back to a plain dial-code concatenation rather than collapsing to ''
|
||||
* — the value has to survive mid-typing for the field to be usable.
|
||||
*/
|
||||
export function toE164(digits: string, country: CountryCode): string {
|
||||
if (!digits) return '';
|
||||
return (
|
||||
parsePhoneNumberFromString(digits, country)?.number ??
|
||||
`+${getCountryCallingCode(country)}${digits}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Digits grouped for display ("91 122 3344"). Formatted as an international
|
||||
* number with the dial code cut off: AsYouType's national mode leaves the
|
||||
* number ungrouped unless the trunk prefix was typed.
|
||||
*/
|
||||
export function formatNational(digits: string, country: CountryCode): string {
|
||||
if (!digits) return '';
|
||||
const prefix = `+${getCountryCallingCode(country)}`;
|
||||
const formatted = new AsYouType().input(prefix + digits);
|
||||
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
|
||||
}
|
||||
|
||||
const metadata = new Metadata();
|
||||
|
||||
/** Longest national number the country's numbering plan allows. */
|
||||
export function maxNationalLength(country: CountryCode): number {
|
||||
metadata.selectNumberingPlan(country);
|
||||
return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15]));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the typed digits exceed the country's longest number. Judged on
|
||||
* the national part once it parses, so a trunk prefix (0911223344) or typed
|
||||
* country code (251911223344) isn't counted against the limit.
|
||||
*/
|
||||
export function exceedsMaxLength(digits: string, country: CountryCode): boolean {
|
||||
const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits;
|
||||
return national.length > maxNationalLength(country);
|
||||
}
|
||||
|
||||
11
libs/ui/vite.config.mts
Normal file
11
libs/ui/vite.config.mts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
reporters: ['default'],
|
||||
},
|
||||
});
|
||||
@@ -36,6 +36,7 @@
|
||||
"i18n-nationality": "^1.4.0",
|
||||
"i18next": "^25.6.0",
|
||||
"js-cookie": "^3.0.8",
|
||||
"libphonenumber-js": "^1.13.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -77,6 +77,9 @@ importers:
|
||||
js-cookie:
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8
|
||||
libphonenumber-js:
|
||||
specifier: ^1.13.11
|
||||
version: 1.13.11
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.8
|
||||
@@ -4837,6 +4840,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libphonenumber-js@1.13.11:
|
||||
resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -12006,6 +12012,8 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libphonenumber-js@1.13.11: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
allowBuilds:
|
||||
canvas: set this to true or false
|
||||
core-js: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
nx: set this to true or false
|
||||
|
||||
Reference in New Issue
Block a user