feat(phone-input): integrate phone number input component and validation

- Added `libphonenumber-js` dependency for improved phone number handling.
- Refactored `CreateAdmin` form to utilize a new `PhoneNumberInput` component with validation rules.
- Updated `useCreateAdminPresenter` to normalize phone number input before submission.
- Enhanced error messages for phone validation in `Texts` constant.
- Modified `User` schema to validate phone numbers using E.164 format.
This commit is contained in:
AmirReza Jamali
2026-05-16 10:28:03 +03:30
parent f79348312b
commit d9a945904a
11 changed files with 378 additions and 25 deletions
+8 -1
View File
@@ -1,3 +1,4 @@
import { isValidE164Phone } from "@/lib/phone/e164";
import { z } from "zod";
import { createApiResponseSchema } from "./Factory";
@@ -25,7 +26,13 @@ export const UserSchema = z.object({
export const CreateAdminSchema = z.object({
full_name: z.string().min(1, "Full name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number"),
phone: z
.string()
.optional()
.transform((val) => (val?.trim() ? val.trim() : ""))
.refine((val) => !val || isValidE164Phone(val), {
message: "Invalid phone number",
}),
password: z
.string()
.min(8, "Password must be at least 8 characters long")
+81
View File
@@ -0,0 +1,81 @@
import {
parsePhoneNumberFromString,
isValidPhoneNumber,
type CountryCode,
type PhoneNumber,
} from "libphonenumber-js";
/** E.164: optional leading `+`, up to 15 digits (ITU-T). */
export const E164_MAX_DIGITS = 15;
export const E164_MAX_LENGTH = E164_MAX_DIGITS + 1;
/** Comfortable formatted typing limit in the UI (not storage). */
export const PHONE_UI_MAX_LENGTH = 25;
const regionDisplay = new Intl.DisplayNames(["en"], { type: "region" });
export function getCountryLabel(code: CountryCode): string {
try {
return regionDisplay.of(code) ?? code;
} catch {
return code;
}
}
/** Strip decorative characters; keep leading `+` and digits only. */
export function sanitizePhoneInput(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
const hasPlus = trimmed.startsWith("+");
const digits = trimmed.replace(/\D/g, "");
if (!digits) return hasPlus ? "+" : "";
return hasPlus ? `+${digits}` : digits;
}
export function parsePhone(
value: string,
defaultCountry?: CountryCode,
): PhoneNumber | undefined {
const sanitized = sanitizePhoneInput(value);
if (!sanitized) return undefined;
return parsePhoneNumberFromString(sanitized, defaultCountry);
}
/** Returns `+{country}{national}` or `undefined` if not a valid number. */
export function normalizeToE164(
value: string,
defaultCountry?: CountryCode,
): string | undefined {
const parsed = parsePhone(value, defaultCountry);
if (!parsed?.isValid()) return undefined;
return parsed.format("E.164");
}
export function isValidE164Phone(value: string): boolean {
const sanitized = sanitizePhoneInput(value);
if (!sanitized) return false;
return isValidPhoneNumber(sanitized);
}
export function formatPhoneForDisplay(
e164: string,
defaultCountry?: CountryCode,
): { country: CountryCode; national: string } | null {
const parsed = parsePhone(e164, defaultCountry);
if (!parsed) return null;
return {
country: parsed.country ?? defaultCountry ?? "US",
national: parsed.formatNational(),
};
}
export function splitE164ForEditing(
e164: string | undefined,
fallbackCountry: CountryCode = "US",
): { country: CountryCode; national: string } {
if (!e164?.trim()) {
return { country: fallbackCountry, national: "" };
}
const display = formatPhoneForDisplay(e164, fallbackCountry);
if (display) return display;
return { country: fallbackCountry, national: e164.replace(/^\+\d{1,3}\s*/, "") };
}