mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement reusable PhoneInput component with validation support and integrate across auth and portal modules.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: 'ሙሉ ስም',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
@@ -67,24 +68,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,7 @@ export function SignupPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -247,12 +247,12 @@ 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: true })}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...register('phoneNumber')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
175
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
175
libs/ui/src/lib/input/PhoneInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
|
||||
import {
|
||||
AsYouType,
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
parsePhoneNumberFromString,
|
||||
type CountryCode,
|
||||
} from 'libphonenumber-js';
|
||||
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
|
||||
|
||||
// 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;
|
||||
label?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
description?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
required?: 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; everything else is display.
|
||||
*/
|
||||
export function PhoneInput({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: PhoneInputProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = resolveLang(i18n.language);
|
||||
|
||||
// The picked country. Re-derived from `value` when it carries a
|
||||
// recognizable dial code (e.g. the form was reset from outside); otherwise
|
||||
// left alone so picking a country before typing digits isn't clobbered.
|
||||
const [country, setCountry] = useState<CountryCode>(
|
||||
() => parsePhoneNumberFromString(value || '')?.country ?? 'ET',
|
||||
);
|
||||
useEffect(() => {
|
||||
const parsed = value ? parsePhoneNumberFromString(value)?.country : undefined;
|
||||
if (parsed) setCountry(parsed);
|
||||
}, [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],
|
||||
);
|
||||
|
||||
// Digits currently typed, formatted for display under the selected
|
||||
// country's convention.
|
||||
const national = useMemo(() => {
|
||||
const digits = value.startsWith('+')
|
||||
? (parsePhoneNumberFromString(value)?.nationalNumber ?? '')
|
||||
: value;
|
||||
return new AsYouType(country).input(digits);
|
||||
}, [value, country]);
|
||||
|
||||
function emit(text: string, forCountry: CountryCode) {
|
||||
const trimmed = text.trim();
|
||||
// A full `+<code><number>` landing in the box directly (paste,
|
||||
// autofill, or a test driver's `.fill()`) is parsed standalone and
|
||||
// switches the country, rather than being digit-stripped and read
|
||||
// under whatever country was already selected.
|
||||
if (trimmed.startsWith('+')) {
|
||||
const parsed = parsePhoneNumberFromString(trimmed);
|
||||
if (parsed?.country) {
|
||||
setCountry(parsed.country);
|
||||
onChange(parsed.number);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
onChange('');
|
||||
return;
|
||||
}
|
||||
onChange(
|
||||
parsePhoneNumberFromString(digits, forCountry)?.number ??
|
||||
`+${getCountryCallingCode(forCountry)}${digits}`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleCountryChange(next: string | null) {
|
||||
if (!next) return;
|
||||
const code = next as CountryCode;
|
||||
setCountry(code);
|
||||
emit(national, code);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={national}
|
||||
onChange={(e) => emit(e.currentTarget.value, country)}
|
||||
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
|
||||
disabled={disabled || readOnly}
|
||||
variant="unstyled"
|
||||
size="xs"
|
||||
w={92}
|
||||
maxDropdownHeight={320}
|
||||
comboboxProps={{ width: 260, position: 'bottom-start' }}
|
||||
leftSection={<CountryFlag code={country} />}
|
||||
leftSectionWidth={30}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } 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)
|
||||
.refine((v) => isValidPhoneNumber(v), {
|
||||
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();
|
||||
|
||||
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",
|
||||
|
||||
1630
pnpm-lock.yaml
generated
1630
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user