diff --git a/package.json b/package.json index ca297b0..4dd6fd5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "gsap": "^3.15.0", "js-cookie": "^3.0.5", "jsvectormap": "^1.6.0", + "libphonenumber-js": "^1.13.2", "lottie-react": "^2.4.1", "moment": "^2.30.1", "next": "15.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af34f45..4147d95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: jsvectormap: specifier: ^1.6.0 version: 1.7.0 + libphonenumber-js: + specifier: ^1.13.2 + version: 1.13.2 lottie-react: specifier: ^2.4.1 version: 2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2070,6 +2073,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.2: + resolution: {integrity: sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -5263,6 +5269,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.2: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} diff --git a/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx b/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx new file mode 100644 index 0000000..fc0d2e3 --- /dev/null +++ b/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { Select } from "@/components/FormElements/select"; +import { cn } from "@/lib/utils"; +import { + normalizeToE164, + PHONE_UI_MAX_LENGTH, + splitE164ForEditing, +} from "@/lib/phone/e164"; +import { + DEFAULT_PHONE_COUNTRY, + PHONE_COUNTRIES, +} from "@/constants/phone-countries"; +import { AsYouType, type CountryCode } from "libphonenumber-js"; +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ChangeEvent, +} from "react"; +import type { ComponentProps } from "react"; +import type { FieldValues } from "react-hook-form"; + +type CountrySelectOnChange = NonNullable< + ComponentProps>["onChange"] +>; + +export type PhoneNumberInputProps = { + label: string; + /** Stored value in E.164 (e.g. `+14155552671`). */ + value?: string; + onChange: (e164: string) => void; + onBlur?: () => void; + error?: string; + required?: boolean; + disabled?: boolean; + dataCy?: string; + defaultCountry?: CountryCode; + className?: string; +}; + +export function PhoneNumberInput({ + label, + value = "", + onChange, + onBlur, + error, + required = false, + disabled = false, + dataCy, + defaultCountry = DEFAULT_PHONE_COUNTRY, + className, +}: PhoneNumberInputProps) { + const inputId = useId(); + const lastEmitted = useRef(value); + + const [country, setCountry] = useState(() => + splitE164ForEditing(value, defaultCountry).country, + ); + const [national, setNational] = useState(() => + splitE164ForEditing(value, defaultCountry).national, + ); + + const syncFromE164 = useCallback( + (e164: string | undefined) => { + const next = splitE164ForEditing(e164, defaultCountry); + setCountry(next.country); + setNational(next.national); + }, + [defaultCountry], + ); + + useEffect(() => { + if (value === lastEmitted.current) return; + lastEmitted.current = value; + syncFromE164(value); + }, [value, syncFromE164]); + + const emitE164 = useCallback( + (nationalInput: string, countryCode: CountryCode) => { + const e164 = normalizeToE164(nationalInput, countryCode); + const next = e164 ?? ""; + lastEmitted.current = next; + onChange(next); + }, + [onChange], + ); + + const handleNationalChange = (e: ChangeEvent) => { + const raw = e.target.value.slice(0, PHONE_UI_MAX_LENGTH); + const formatted = new AsYouType(country).input(raw); + setNational(formatted); + emitE164(formatted, country); + }; + + const handleCountryChange = (e: ChangeEvent) => { + const nextCountry = e.target.value as CountryCode; + setCountry(nextCountry); + const formatted = new AsYouType(nextCountry).input(national); + setNational(formatted); + emitE164(formatted, nextCountry); + }; + + const handleBlur = () => { + const e164 = normalizeToE164(national, country); + if (e164) { + const display = splitE164ForEditing(e164, country); + setNational(display.national); + lastEmitted.current = e164; + onChange(e164); + } else if (!required) { + lastEmitted.current = ""; + onChange(""); + } + onBlur?.(); + }; + + return ( +
+ + +
+ + name={"phoneCountry" as never} + label="Country code" + items={PHONE_COUNTRIES} + searchable + searchPlaceholder="Search country or code…" + value={country} + onChange={handleCountryChange as CountrySelectOnChange} + disabled={disabled} + className={cn( + "w-full shrink-0 space-y-0 sm:w-[min(100%,13.5rem)]", + "[&_label]:sr-only", + )} + dataCy={dataCy ? `${dataCy}-country` : undefined} + /> + +
+ + +
+
+ + {error ? ( + + ) : null} +
+ ); +} diff --git a/src/components/FormElements/PhoneNumberInput/index.ts b/src/components/FormElements/PhoneNumberInput/index.ts new file mode 100644 index 0000000..afd51d5 --- /dev/null +++ b/src/components/FormElements/PhoneNumberInput/index.ts @@ -0,0 +1,5 @@ +export { PhoneNumberInput, type PhoneNumberInputProps } from "./PhoneNumberInput"; +export { + createPhoneFieldRules, + normalizePhoneFieldValue, +} from "./phone-field-rules"; diff --git a/src/components/FormElements/PhoneNumberInput/phone-field-rules.ts b/src/components/FormElements/PhoneNumberInput/phone-field-rules.ts new file mode 100644 index 0000000..f4bb5a5 --- /dev/null +++ b/src/components/FormElements/PhoneNumberInput/phone-field-rules.ts @@ -0,0 +1,41 @@ +import { FormErrorMessages } from "@/constants/Texts"; +import { isValidE164Phone, normalizeToE164 } from "@/lib/phone/e164"; +import type { CountryCode } from "libphonenumber-js"; + +type PhoneRulesOptions = { + required?: boolean; + defaultCountry?: CountryCode; +}; + +/** React Hook Form rules for an E.164 phone field. */ +export function createPhoneFieldRules(options: PhoneRulesOptions = {}) { + const { required = false, defaultCountry } = options; + + return { + validate: (value: string | undefined) => { + const trimmed = value?.trim() ?? ""; + if (!trimmed) { + return required ? FormErrorMessages.required : true; + } + if (!isValidE164Phone(trimmed)) { + return FormErrorMessages.invalidPhone; + } + return true; + }, + setValueAs: (value: string | undefined) => { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return ""; + return normalizeToE164(trimmed, defaultCountry) ?? trimmed; + }, + }; +} + +/** Normalize before API submit (defensive). */ +export function normalizePhoneFieldValue( + value: string | undefined, + defaultCountry?: CountryCode, +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return normalizeToE164(trimmed, defaultCountry) ?? undefined; +} diff --git a/src/components/forms/admins/CreateAdmin.tsx b/src/components/forms/admins/CreateAdmin.tsx index bff5d3f..5791ae2 100644 --- a/src/components/forms/admins/CreateAdmin.tsx +++ b/src/components/forms/admins/CreateAdmin.tsx @@ -1,13 +1,17 @@ "use client"; import { PasswordIcon } from "@/assets/icons"; import InputGroup from "@/components/FormElements/InputGroup"; +import { + createPhoneFieldRules, + PhoneNumberInput, +} from "@/components/FormElements/PhoneNumberInput"; import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import FileImageUploadCard from "@/components/ui/FileImageUploadCard"; import FormFooter from "@/components/ui/FormFooter"; -import { REGEX } from "@/constants/regex"; import { FormErrorMessages } from "@/constants/Texts"; import { ICreateAdminCredentials } from "@/lib/api"; import { FC } from "react"; +import { Controller } from "react-hook-form"; import useCreateAdminPresenter from "./useCreateAdminPresenter"; interface IProps { editMode?: boolean; @@ -19,6 +23,7 @@ const CreateAdminForm: FC = ({ defaultValues, editMode, id }) => { errors, handleSubmit, register, + control, onSubmit, isUploadingImage, uploadProgress, @@ -190,31 +195,21 @@ const CreateAdminForm: FC = ({ defaultValues, editMode, id }) => { )}
- ( + + )} /> - {errors.phone && ( -

- {errors.phone.message} -

- )}
diff --git a/src/components/forms/admins/useCreateAdminPresenter.ts b/src/components/forms/admins/useCreateAdminPresenter.ts index 61c8216..d4d6cc5 100644 --- a/src/components/forms/admins/useCreateAdminPresenter.ts +++ b/src/components/forms/admins/useCreateAdminPresenter.ts @@ -1,5 +1,6 @@ import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries"; import { ICreateAdminCredentials, userService } from "@/lib/api"; +import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput"; import { validateImageFile } from "@/utils/shared"; import { useIsMutating } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -38,6 +39,7 @@ const useCreateAdminPresenter = ({ ); const { register, + control, formState: { errors }, handleSubmit, watch, @@ -160,8 +162,11 @@ const useCreateAdminPresenter = ({ return; } + const normalizedPhone = normalizePhoneFieldValue(data.phone); + const payload: ICreateAdminCredentials = { ...data, + phone: normalizedPhone ?? "", profile_image: profileImageUrl, }; @@ -175,6 +180,7 @@ const useCreateAdminPresenter = ({ return { errors, register, + control, handleSubmit, router, isMutating, diff --git a/src/constants/Texts.ts b/src/constants/Texts.ts index 56ab881..2c4936f 100644 --- a/src/constants/Texts.ts +++ b/src/constants/Texts.ts @@ -9,4 +9,5 @@ export const FormErrorMessages = { invalidEmail: "Email address is invalid", alphaNumeric: "This field can only contain letters and numbers", invalidPattern: "This field does not match the required pattern", + invalidPhone: "Enter a valid international phone number", }; \ No newline at end of file diff --git a/src/constants/phone-countries.ts b/src/constants/phone-countries.ts new file mode 100644 index 0000000..347ac0b --- /dev/null +++ b/src/constants/phone-countries.ts @@ -0,0 +1,22 @@ +import { getCountryLabel } from "@/lib/phone/e164"; +import { getCountries, getCountryCallingCode } from "libphonenumber-js"; +import type { CountryCode } from "libphonenumber-js"; +import type { ILabelValue } from "@/types/shared"; + +export type PhoneCountryOption = ILabelValue & { + callingCode: string; +}; + +export const PHONE_COUNTRIES: PhoneCountryOption[] = getCountries() + .map((code) => { + const callingCode = getCountryCallingCode(code); + const name = getCountryLabel(code); + return { + value: code, + label: `${name} (+${callingCode})`, + callingCode, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); + +export const DEFAULT_PHONE_COUNTRY: CountryCode = "US"; diff --git a/src/lib/api/types/User.ts b/src/lib/api/types/User.ts index fc8953c..06cff96 100644 --- a/src/lib/api/types/User.ts +++ b/src/lib/api/types/User.ts @@ -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") diff --git a/src/lib/phone/e164.ts b/src/lib/phone/e164.ts new file mode 100644 index 0000000..aa9ceb4 --- /dev/null +++ b/src/lib/phone/e164.ts @@ -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*/, "") }; +}