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

@@ -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",
),
},
),

View File

@@ -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">

View File

@@ -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";

View File

@@ -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;
}

View 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}
/>
}
/>
);
}

View File

@@ -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
View 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'],
},
});