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:
@@ -29,6 +29,7 @@
|
|||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsvectormap": "^1.6.0",
|
"jsvectormap": "^1.6.0",
|
||||||
|
"libphonenumber-js": "^1.13.2",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.1.9",
|
"next": "15.1.9",
|
||||||
|
|||||||
Generated
+8
@@ -53,6 +53,9 @@ importers:
|
|||||||
jsvectormap:
|
jsvectormap:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
|
libphonenumber-js:
|
||||||
|
specifier: ^1.13.2
|
||||||
|
version: 1.13.2
|
||||||
lottie-react:
|
lottie-react:
|
||||||
specifier: ^2.4.1
|
specifier: ^2.4.1
|
||||||
version: 2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
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==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
libphonenumber-js@1.13.2:
|
||||||
|
resolution: {integrity: sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==}
|
||||||
|
|
||||||
lilconfig@3.1.3:
|
lilconfig@3.1.3:
|
||||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -5263,6 +5269,8 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
libphonenumber-js@1.13.2: {}
|
||||||
|
|
||||||
lilconfig@3.1.3: {}
|
lilconfig@3.1.3: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|||||||
@@ -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<typeof Select<FieldValues>>["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<string | undefined>(value);
|
||||||
|
|
||||||
|
const [country, setCountry] = useState<CountryCode>(() =>
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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<HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className={cn("space-y-3", className)}>
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="text-body-sm font-semibold capitalize text-dark dark:text-white"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 select-none text-error">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||||
|
<Select<FieldValues>
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label htmlFor={inputId} className="sr-only">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
autoComplete="tel-national"
|
||||||
|
disabled={disabled}
|
||||||
|
value={national}
|
||||||
|
onChange={handleNationalChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
maxLength={PHONE_UI_MAX_LENGTH}
|
||||||
|
data-cy={dataCy}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${inputId}-error` : undefined}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 text-sm shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 placeholder:text-dark-5 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] dark:border-dark-3/80 dark:bg-dark-2/85 dark:text-white dark:placeholder:text-dark-6 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)]",
|
||||||
|
error &&
|
||||||
|
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16)] dark:border-red/80",
|
||||||
|
disabled && "cursor-not-allowed opacity-60",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
id={`${inputId}-error`}
|
||||||
|
className="text-sm text-red-500"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { PhoneNumberInput, type PhoneNumberInputProps } from "./PhoneNumberInput";
|
||||||
|
export {
|
||||||
|
createPhoneFieldRules,
|
||||||
|
normalizePhoneFieldValue,
|
||||||
|
} from "./phone-field-rules";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { PasswordIcon } from "@/assets/icons";
|
import { PasswordIcon } from "@/assets/icons";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
|
import {
|
||||||
|
createPhoneFieldRules,
|
||||||
|
PhoneNumberInput,
|
||||||
|
} from "@/components/FormElements/PhoneNumberInput";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { REGEX } from "@/constants/regex";
|
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
import { ICreateAdminCredentials } from "@/lib/api";
|
import { ICreateAdminCredentials } from "@/lib/api";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
import useCreateAdminPresenter from "./useCreateAdminPresenter";
|
import useCreateAdminPresenter from "./useCreateAdminPresenter";
|
||||||
interface IProps {
|
interface IProps {
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
@@ -19,6 +23,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
|||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isUploadingImage,
|
isUploadingImage,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
@@ -190,31 +195,21 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<InputGroup
|
<Controller
|
||||||
label="Phone"
|
|
||||||
type="text"
|
|
||||||
{...register("phone", {
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: FormErrorMessages.minLength(10),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 20,
|
|
||||||
message: FormErrorMessages.maxLength(20),
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: REGEX.PHONE,
|
|
||||||
message: FormErrorMessages.invalidPattern,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="phone"
|
name="phone"
|
||||||
dataCy="admin-form-phone-input"
|
control={control}
|
||||||
|
rules={createPhoneFieldRules({ required: false })}
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneNumberInput
|
||||||
|
label="Phone"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={errors.phone?.message as string | undefined}
|
||||||
|
dataCy="admin-form-phone-input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.phone && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.phone.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries";
|
import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries";
|
||||||
import { ICreateAdminCredentials, userService } from "@/lib/api";
|
import { ICreateAdminCredentials, userService } from "@/lib/api";
|
||||||
|
import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput";
|
||||||
import { validateImageFile } from "@/utils/shared";
|
import { validateImageFile } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -38,6 +39,7 @@ const useCreateAdminPresenter = ({
|
|||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
@@ -160,8 +162,11 @@ const useCreateAdminPresenter = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedPhone = normalizePhoneFieldValue(data.phone);
|
||||||
|
|
||||||
const payload: ICreateAdminCredentials = {
|
const payload: ICreateAdminCredentials = {
|
||||||
...data,
|
...data,
|
||||||
|
phone: normalizedPhone ?? "",
|
||||||
profile_image: profileImageUrl,
|
profile_image: profileImageUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ const useCreateAdminPresenter = ({
|
|||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
router,
|
router,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export const FormErrorMessages = {
|
|||||||
invalidEmail: "Email address is invalid",
|
invalidEmail: "Email address is invalid",
|
||||||
alphaNumeric: "This field can only contain letters and numbers",
|
alphaNumeric: "This field can only contain letters and numbers",
|
||||||
invalidPattern: "This field does not match the required pattern",
|
invalidPattern: "This field does not match the required pattern",
|
||||||
|
invalidPhone: "Enter a valid international phone number",
|
||||||
};
|
};
|
||||||
@@ -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<CountryCode> & {
|
||||||
|
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";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isValidE164Phone } from "@/lib/phone/e164";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createApiResponseSchema } from "./Factory";
|
import { createApiResponseSchema } from "./Factory";
|
||||||
|
|
||||||
@@ -25,7 +26,13 @@ export const UserSchema = z.object({
|
|||||||
export const CreateAdminSchema = z.object({
|
export const CreateAdminSchema = z.object({
|
||||||
full_name: z.string().min(1, "Full name is required"),
|
full_name: z.string().min(1, "Full name is required"),
|
||||||
email: z.string().email("Invalid email address"),
|
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
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, "Password must be at least 8 characters long")
|
.min(8, "Password must be at least 8 characters long")
|
||||||
|
|||||||
@@ -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*/, "") };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user