feat: phone number validation

This commit is contained in:
2025-07-19 10:04:00 +04:00
parent 964641a58d
commit 84cc04ea67
7 changed files with 148 additions and 69 deletions

15
app/verify-phone/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import Logo from "@/components/Logo/Logo";
import PageItem from "@/components/PageBlock/PageItem";
import PageContainer from "@/components/PageContainer/PageContainer";
import VerifyPhoneForm from "@/components/VerifyPhoneForm/VerifyPhoneForm";
export default function CreateIdPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Введите код, отправленный на номер"} />
<VerifyPhoneForm />
</PageItem>
</PageContainer>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { FC } from "react"; import { FC, useState } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Button, Stack } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
@ -15,14 +15,23 @@ type Props = {
}; };
const LoginForm: FC<Props> = ({ isCreatingId = false }) => { const LoginForm: FC<Props> = ({ isCreatingId = false }) => {
const [phoneMask, setPhoneMask] = useState<string>("");
const form = useForm<LoginForm>({ const form = useForm<LoginForm>({
initialValues: { initialValues: {
phoneNumber: "", phoneNumber: "",
}, },
validate: {
phoneNumber: phoneNumber =>
phoneNumber.length !== phoneMask.length &&
"Введите корректный номер",
},
}); });
const handleSubmit = (values: LoginForm) => { const handleSubmit = (values: LoginForm) => {
console.log(values); console.log(values);
console.log(phoneMask);
redirect("/verify-phone");
}; };
const navigateToCreateId = () => redirect("/create-id"); const navigateToCreateId = () => redirect("/create-id");
@ -32,7 +41,10 @@ const LoginForm: FC<Props> = ({ isCreatingId = false }) => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<PhoneInput {...form.getInputProps("phoneNumber")} /> <PhoneInput
{...form.getInputProps("phoneNumber")}
setPhoneMask={setPhoneMask}
/>
{isCreatingId ? ( {isCreatingId ? (
<> <>
<Button <Button

View File

@ -7,67 +7,63 @@ import {
type InputBaseProps, type InputBaseProps,
type PolymorphicComponentProps, type PolymorphicComponentProps,
} from "@mantine/core"; } from "@mantine/core";
import { useUncontrolled } from "@mantine/hooks";
import CountrySelect from "@/components/PhoneInput/components/CountrySelect"; import CountrySelect from "@/components/PhoneInput/components/CountrySelect";
import getFormat from "@/components/PhoneInput/utils/getFormat"; import { Country } from "@/components/PhoneInput/types";
import getInitialDataFromValue from "@/components/PhoneInput/utils/getInitialDataFromValue"; import getInitialDataFromValue from "@/components/PhoneInput/utils/getInitialDataFromValue";
import getPhoneMask from "@/components/PhoneInput/utils/getPhoneMask";
export type Props = { type AdditionalProps = {
onChange: (value: string | null) => void;
setPhoneMask: (mask: string) => void;
initialCountryCode?: string; initialCountryCode?: string;
defaultValue?: string; };
} & Omit<
type InputProps = Omit<
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>, PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>,
"onChange" | "defaultValue" "onChange" | "defaultValue"
> & { onChange: (value: string | null) => void }; >;
export type Props = AdditionalProps & InputProps;
const PhoneInput = ({ const PhoneInput = ({
initialCountryCode = "RU", initialCountryCode = "RU",
value: _value, value: _value,
onChange: _onChange, onChange: _onChange,
defaultValue, setPhoneMask: _setPhoneMask,
...props ...props
}: Props) => { }: Props) => {
const [value, onChange] = useUncontrolled({ const [mask, setMask] = useState<string>("");
value: _value, const initialData = useRef(getInitialDataFromValue(initialCountryCode));
defaultValue, const [country, setCountry] = useState<Country>(
onChange: _onChange, initialData.current.country
});
const initialData = useRef(
getInitialDataFromValue(value, initialCountryCode)
);
const [country, setCountry] = useState(initialData.current.country);
const [format, setFormat] = useState(initialData.current.format);
const [localValue, setLocalValue] = useState(
initialData.current.localValue
); );
const [value, setValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [dropdownWidth, setDropdownWidth] = useState<number>(300); const [dropdownWidth, setDropdownWidth] = useState<number>(300);
const lastNotifiedValue = useRef<string | null>(value ?? ""); const lastNotifiedValue = useRef<string | null>(value ?? "");
useEffect(() => { const onChange = (numberWithoutCode: string) => {
const value = localValue.trim(); setValue(numberWithoutCode);
if (value !== lastNotifiedValue.current) { _onChange(`+${country.callingCode} ${numberWithoutCode}`);
lastNotifiedValue.current = value; };
onChange(value);
} const setPhoneMask = (phoneMask: string, country: Country) => {
}, [country.code, localValue]); setMask(phoneMask);
_setPhoneMask(`+${country.callingCode} ${phoneMask}`);
};
useEffect(() => { useEffect(() => {
if ( setPhoneMask(initialData.current.format, country);
typeof value !== "undefined" && }, [initialData.current.format]);
value !== lastNotifiedValue.current
) { useEffect(() => {
const initialData = getInitialDataFromValue( const localValue = value.trim();
value, if (localValue !== lastNotifiedValue.current) {
initialCountryCode lastNotifiedValue.current = localValue;
); onChange(localValue);
lastNotifiedValue.current = value;
setCountry(initialData.country);
setFormat(initialData.format);
setLocalValue(initialData.localValue);
} }
}, [value]); }, [country.code, value]);
const { readOnly, disabled } = props; const { readOnly, disabled } = props;
const leftSectionWidth = 90; const leftSectionWidth = 90;
@ -88,8 +84,8 @@ const PhoneInput = ({
country={country} country={country}
setCountry={country => { setCountry={country => {
setCountry(country); setCountry(country);
setFormat(getFormat(country.code)); setPhoneMask(getPhoneMask(country.code), country);
setLocalValue(""); setValue("");
if (inputRef.current) { if (inputRef.current) {
inputRef.current.focus(); inputRef.current.focus();
} }
@ -110,9 +106,9 @@ const PhoneInput = ({
}, },
}} }}
inputMode={"numeric"} inputMode={"numeric"}
mask={format.mask} mask={mask}
value={localValue} value={value}
onAccept={value => setLocalValue(value)} onAccept={value => setValue(value)}
/> />
); );
}; };

View File

@ -1,33 +1,20 @@
import { CountryCode } from "libphonenumber-js";
import { Country } from "@/components/PhoneInput/types"; import { Country } from "@/components/PhoneInput/types";
import getFormat from "@/components/PhoneInput/utils/getFormat";
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap"; import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
import { CountryCode, parsePhoneNumberFromString } from "libphonenumber-js"; import getPhoneMask from "@/components/PhoneInput/utils/getPhoneMask";
type InitialDataFromValue = { type InitialDataFromValue = {
country: Country; country: Country;
format: ReturnType<typeof getFormat>; format: ReturnType<typeof getPhoneMask>;
localValue: string;
}; };
const getInitialDataFromValue = ( const getInitialDataFromValue = (
value: string | undefined,
initialCountryCode: string initialCountryCode: string
): InitialDataFromValue => { ): InitialDataFromValue => {
const defaultValue = {
country: countryOptionsDataMap[initialCountryCode],
format: getFormat(initialCountryCode as CountryCode),
localValue: "",
};
if (!value) return defaultValue;
const phoneNumber = parsePhoneNumberFromString(value);
if (!phoneNumber) return defaultValue;
if (!phoneNumber.country) return defaultValue;
return { return {
country: countryOptionsDataMap[phoneNumber.country], country: countryOptionsDataMap[initialCountryCode],
localValue: phoneNumber.formatNational(), format: getPhoneMask(initialCountryCode as CountryCode),
format: getFormat(phoneNumber.country), };
}; };
}
export default getInitialDataFromValue; export default getInitialDataFromValue;

View File

@ -2,14 +2,13 @@ import { CountryCode, getExampleNumber } from "libphonenumber-js";
import examples from "libphonenumber-js/examples.mobile.json"; import examples from "libphonenumber-js/examples.mobile.json";
import { getCountryCallingCode } from "libphonenumber-js/max"; import { getCountryCallingCode } from "libphonenumber-js/max";
export default function getFormat(countryCode: CountryCode) { export default function getPhoneMask(countryCode: CountryCode) {
let example = getExampleNumber( let example = getExampleNumber(
countryCode, countryCode,
examples examples
)!.formatInternational(); )!.formatInternational();
const callingCode = getCountryCallingCode(countryCode); const callingCode = getCountryCallingCode(countryCode);
const callingCodeLen = callingCode.length + 1; const callingCodeLen = callingCode.length + 1;
example = example.slice(callingCodeLen); example = example.slice(callingCodeLen).trim();
const mask = example.replace(/\d/g, "0"); return example.replace(/\d/g, "0");
return { example, mask };
} }

View File

@ -0,0 +1,11 @@
.pin-input-root {
width: 100%;
justify-content: space-between;
margin-top: 10px;
margin-bottom: 15px;
}
.pin-input {
font-size: 17px;
border-radius: 10px;
}

View File

@ -0,0 +1,59 @@
"use client";
import { FC } from "react";
import { redirect } from "next/navigation";
import { Button, PinInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import style from "@/components/VerifyPhoneForm/VerifyPhone.module.css";
type VerifyNumberForm = {
code: string;
};
const VerifyPhoneForm: FC = () => {
const form = useForm<VerifyNumberForm>({
initialValues: {
code: "",
},
validate: {
code: code => code.length !== 6 && "Введите весь код",
},
});
const handleSubmit = (values: VerifyNumberForm) => {
console.log(values);
};
const navigateToLogin = () => redirect("/");
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<PinInput
length={6}
placeholder="_"
oneTimeCode
size={"md"}
classNames={{
root: style["pin-input-root"],
input: style["pin-input"],
}}
{...form.getInputProps("code")}
/>
<Button
variant={"filled"}
type={"submit"}
disabled={form.values.code.length !== 6}>
Подтвердить
</Button>
<Button
variant={"outline"}
onClick={navigateToLogin}>
Назад
</Button>
</Stack>
</form>
);
};
export default VerifyPhoneForm;