Files
tm_panel/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx
T
AmirReza Jamali d9a945904a 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.
2026-05-16 10:28:03 +03:30

187 lines
5.6 KiB
TypeScript

"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>
);
}