eb41f9b7b2
- Deleted outdated documentation files for BP panel list sorting and Filestore frontend guide. - Created new documentation files for BP panel list sorting and Filestore frontend guide in the client directory. - Updated the structure and content to enhance clarity and accessibility for frontend integration with file upload and profile image APIs. - Ensured consistency in documentation style and improved examples for better developer experience.
193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { Select } from "@/components/FormElements/select";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
normalizeToE164,
|
|
PHONE_UI_MAX_LENGTH,
|
|
splitE164ForEditing,
|
|
toBestEffortE164,
|
|
} 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) => {
|
|
// Preserve the user's input as best-effort E.164 instead of clearing it
|
|
// to "" whenever it is not yet strictly valid — otherwise a typed number
|
|
// can silently vanish from the submitted payload.
|
|
const next = toBestEffortE164(nationalInput, countryCode);
|
|
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 {
|
|
// Keep what the user typed (as best-effort E.164) so an invalid number is
|
|
// surfaced by validation rather than silently discarded on blur.
|
|
const next = toBestEffortE164(national, country);
|
|
lastEmitted.current = next;
|
|
onChange(next);
|
|
}
|
|
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>
|
|
);
|
|
}
|