feat: implement reusable PhoneInput component with validation support and integrate across auth and portal modules.

This commit is contained in:
estifanos
2026-08-20 07:31:51 +00:00
parent 524131fe42
commit 481ff57ad1
16 changed files with 941 additions and 1006 deletions

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(),
});
}
@@ -155,10 +155,11 @@ export function AddressFormContent({
{...register('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: true })}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
@@ -206,10 +207,11 @@ 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: true })}
error={errors.emergencyContactPhone?.message}
/>
<TextInput

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';
@@ -270,7 +269,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 +277,8 @@ export function ProfilePage() {
register: registerPersonal,
handleSubmit: handlePersonalSubmit,
reset: resetPersonal,
watch: watchPersonal,
setValue: setValuePersonal,
formState: { errors: personalErrors },
} = useForm<PersonalValues>({
resolver: zodResolver(personalSchema),
@@ -669,11 +670,11 @@ 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: true })}
error={personalErrors.phoneNumber?.message}
{...registerPersonal('phoneNumber')}
/>
</SimpleGrid>
</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

@@ -540,7 +540,7 @@ export const am: Translations = {
},
login: {
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ (+2519xxxxxxxx)",
emailOrPhoneInvalid: "ትክክለኛ ኢሜይል ወይም ስልክ ቁጥር ያስገቡ",
passwordMinLength: "የይለፍ ቃል ቢያንስ 8 ቁምፊዎች ሊኖረው ይገባል",
welcome: "እንኳን ወደ {{appName}} በደህና መጡ",
subtitle: "መለያዎን ለመድረስ ይግቡ።",
@@ -575,7 +575,7 @@ export const am: Translations = {
usernameLabel: "የተጠቃሚ ስም",
usernamePlaceholder: "የተጠቃሚ ስም ይምረጡ",
phoneLabel: "ስልክ ቁጥር",
phonePlaceholder: "+251 911 234 567",
phonePlaceholder: "9XX XXX XXX",
passwordLabel: "የይለፍ ቃል",
passwordPlaceholder: "ቢያንስ 8 ቁምፊዎች",
confirmPasswordLabel: "የይለፍ ቃል ያረጋግጡ",
@@ -690,7 +690,7 @@ export const am: Translations = {
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
idTypePlaceholder: 'ይምረጡ',
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
phonePlaceholder: '+251 9XX XXX XXX',
phonePlaceholder: '9XX XXX XXX',
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
postalAddressPlaceholder: 'ፖስታ ሳጥን',
contactNamePlaceholder: 'ሙሉ ስም',

View File

@@ -539,7 +539,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.',
@@ -574,7 +574,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',
@@ -689,7 +689,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',