feat: login form as a client component, theme toggle

This commit is contained in:
2025-07-17 16:49:46 +04:00
parent 0de352c323
commit 39b4d36a82
44 changed files with 14445 additions and 1 deletions

View File

@ -0,0 +1,9 @@
.country-select {
font-size: medium;
@mixin light {
color: black;
}
@mixin dark {
color: white;
}
}

View File

@ -0,0 +1,114 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IMaskInput } from "react-imask";
import {
InputBase,
type InputBaseProps,
type PolymorphicComponentProps,
} from "@mantine/core";
import { useUncontrolled } from "@mantine/hooks";
import CountrySelect from "@/components/PhoneInput/components/CountrySelect";
import getFormat from "@/components/PhoneInput/utils/getFormat";
import getInitialDataFromValue from "@/components/PhoneInput/utils/getInitialDataFromValue";
export type Props = {
initialCountryCode?: string;
defaultValue?: string;
} & Omit<
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>,
"onChange" | "defaultValue"
> & { onChange: (value: string | null) => void };
const PhoneInput = ({
initialCountryCode = "RU",
value: _value,
onChange: _onChange,
defaultValue,
...props
}: Props) => {
const [value, onChange] = useUncontrolled({
value: _value,
defaultValue,
onChange: _onChange,
});
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 inputRef = useRef<HTMLInputElement>(null);
const lastNotifiedValue = useRef<string | null>(value ?? "");
useEffect(() => {
const value = localValue.trim();
if (value !== lastNotifiedValue.current) {
lastNotifiedValue.current = value;
onChange(value);
}
}, [country.code, localValue]);
useEffect(() => {
if (
typeof value !== "undefined" &&
value !== lastNotifiedValue.current
) {
const initialData = getInitialDataFromValue(
value,
initialCountryCode
);
lastNotifiedValue.current = value;
setCountry(initialData.country);
setFormat(initialData.format);
setLocalValue(initialData.localValue);
}
}, [value]);
const { readOnly, disabled } = props;
const leftSectionWidth = 100;
return (
<InputBase
{...props}
component={IMaskInput}
inputRef={inputRef}
leftSection={
<CountrySelect
disabled={disabled || readOnly}
country={country}
setCountry={country => {
setCountry(country);
setFormat(getFormat(country.code));
setLocalValue("");
if (inputRef.current) {
inputRef.current.focus();
}
}}
leftSectionWidth={leftSectionWidth}
/>
}
leftSectionWidth={leftSectionWidth}
styles={{
input: {
fontSize: 17,
color: "primaryColor.1",
paddingLeft: `calc(${leftSectionWidth}px + var(--mantine-spacing-sm))`,
},
section: {
borderRight:
"1px solid var(--mantine-color-default-border)",
},
}}
inputMode={"numeric"}
mask={format.mask}
value={localValue}
onAccept={value => setLocalValue(value)}
/>
);
};
export default PhoneInput;

View File

@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Box,
CheckIcon,
Combobox,
Flex,
Group,
ScrollArea,
Text,
useCombobox,
} from "@mantine/core";
import style from "@/components/PhoneInput/PhoneInput.module.css";
import { Country } from "@/components/PhoneInput/types";
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
const countryOptionsData = Object.values(countryOptionsDataMap);
type Props = {
country: Country;
setCountry: (country: Country) => void;
disabled: boolean | undefined;
leftSectionWidth: number;
};
const CountrySelect = ({
country,
setCountry,
disabled,
leftSectionWidth,
}: Props) => {
const [search, setSearch] = useState("");
const selectedRef = useRef<HTMLDivElement>(null);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
onDropdownOpen: () => {
combobox.focusSearchInput();
setTimeout(() => {
selectedRef.current?.scrollIntoView({
behavior: "instant",
block: "center",
});
}, 0);
},
});
const options = countryOptionsData
.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase().trim())
)
.map(item => (
<Combobox.Option
ref={item.code === country.code ? selectedRef : undefined}
value={item.code}
key={item.code}>
<Group
justify={"space-between"}
wrap={"nowrap"}
align={"center"}>
<Text>
{item.emoji} +{item.callingCode} | {item.name}
</Text>
{item.code === country.code && (
<Box>
<CheckIcon size={12} />
</Box>
)}
</Group>
</Combobox.Option>
));
useEffect(() => {
if (search) {
combobox.selectFirstOption();
}
}, [search]);
return (
<Combobox
store={combobox}
width={350}
position={"bottom-start"}
withArrow
onOptionSubmit={val => {
setCountry(countryOptionsDataMap[val]);
combobox.closeDropdown();
}}>
<Combobox.Target withAriaAttributes={false}>
<ActionIcon
variant={"transparent"}
onClick={() => combobox.toggleDropdown()}
size={"lg"}
tabIndex={-1}
disabled={disabled}
w={leftSectionWidth}>
<Flex
align={"center"}
px={5}
ml={"md"}
w={"100%"}
className={style["country-select"]}>
<span>
{country.emoji} +{country.callingCode}
</span>
</Flex>
</ActionIcon>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={event => setSearch(event.currentTarget.value)}
placeholder="Поиск..."
/>
<Combobox.Options>
<ScrollArea.Autosize
mah={200}
type="scroll">
{options.length > 0 ? (
options
) : (
<Combobox.Empty>Нет данных</Combobox.Empty>
)}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
export default CountrySelect;

View File

@ -0,0 +1,8 @@
import type { CountryCallingCode, CountryCode } from "libphonenumber-js";
export type Country = {
code: CountryCode;
name: string;
emoji: string;
callingCode: CountryCallingCode;
};

View File

@ -0,0 +1,33 @@
import countries from "i18n-iso-countries";
import ru from "i18n-iso-countries/langs/ru.json";
import { type CountryCode, getCountries } from "libphonenumber-js";
import getFlagEmoji from "@/components/PhoneInput/utils/getFlagEmoji";
import { getCountryCallingCode } from "libphonenumber-js/max";
import { Country } from "@/components/PhoneInput/types";
countries.registerLocale(ru);
const libIsoCountries = countries.getNames("ru", { select: "official" });
const libPhoneNumberCountries = getCountries();
const countryOptionsDataMap = Object.fromEntries(
libPhoneNumberCountries
.map(code => {
const name = libIsoCountries[code];
const emoji = getFlagEmoji(code);
if (!name || !emoji) return null;
const callingCode = getCountryCallingCode(code);
return [
code,
{
code,
name,
emoji,
callingCode,
},
] as [CountryCode, Country];
})
.filter(o => !!o)
);
export default countryOptionsDataMap;

View File

@ -0,0 +1,8 @@
export default function getFlagEmoji(countryCode: string) {
const FLAG_CODE_OFFSET = 127397;
const codePoints = countryCode
.toUpperCase()
.split("")
.map(char => FLAG_CODE_OFFSET + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}

View File

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

View File

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