refactoring

This commit is contained in:
2025-07-26 11:10:28 +04:00
parent 1ee9b235d5
commit a1a9e0dc93
50 changed files with 6197 additions and 2933 deletions

16
src/.storybook/main.ts Normal file
View File

@ -0,0 +1,16 @@
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
core: {
disableWhatsNewNotifications: true,
disableTelemetry: true,
enableCrashReports: false,
},
stories: ['../components/**/*.(stories|story).@(js|jsx|ts|tsx)'],
addons: ['storybook-dark-mode'],
framework: {
name: '@storybook/nextjs',
options: {},
},
};
export default config;

View File

@ -0,0 +1,36 @@
import '@mantine/core/styles.css';
import React, { useEffect } from 'react';
import { addons } from '@storybook/preview-api';
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
import { MantineProvider, useMantineColorScheme } from '@mantine/core';
import { darkTheme } from '../theme';
export const parameters = {
layout: 'fullscreen',
options: {
showPanel: false,
storySort: (a, b) => {
return a.title.localeCompare(b.title, undefined, { numeric: true });
},
},
};
const channel = addons.getChannel();
function ColorSchemeWrapper({ children }: { children: React.ReactNode }) {
const { setColorScheme } = useMantineColorScheme();
const handleColorScheme = (value: boolean) => setColorScheme(value ? 'dark' : 'light');
useEffect(() => {
channel.on(DARK_MODE_EVENT_NAME, handleColorScheme);
return () => channel.off(DARK_MODE_EVENT_NAME, handleColorScheme);
}, [channel]);
return <>{children}</>;
}
export const decorators = [
(renderStory: any) => <ColorSchemeWrapper>{renderStory()}</ColorSchemeWrapper>,
(renderStory: any) => <MantineProvider theme={darkTheme}>{renderStory()}</MantineProvider>,
];

View File

@ -0,0 +1,44 @@
"use client";
import { FC, useEffect, useState } from "react";
import { redirect } from "next/navigation";
import { useSelector } from "react-redux";
import { Button, Text } from "@mantine/core";
import SERVICES from "@/constants/services";
import { ServiceCode } from "@/enums/ServiceCode";
import { RootState } from "@/lib/store";
import ServiceData from "@/types/ServiceData";
const ConsentButton: FC = () => {
const serviceCode = useSelector(
(state: RootState) => state.targetService.serviceCode
);
const [serviceData, setServiceData] = useState<ServiceData>();
useEffect(() => {
if (serviceCode === ServiceCode.UNDEFINED) {
redirect("services");
}
setServiceData(SERVICES[serviceCode]);
}, [serviceCode]);
const confirmAccess = () => {};
return (
<>
<Button
onClick={() => confirmAccess()}
variant={"filled"}>
Войти
</Button>
<Text
fz={"h4"}
ta="center">
Сервис {serviceData?.name} получит{" "}
{serviceData?.requiredAccesses}
</Text>
</>
);
};
export default ConsentButton;

View File

@ -0,0 +1,14 @@
.container {
@media (min-width: 48em) {
max-width: 400px;
}
}
.gray-text {
@mixin dark {
color: #807e7e;
}
@mixin light {
color: gray;
}
}

View File

@ -0,0 +1,22 @@
import { FC } from "react";
import { Stack, Text } from "@mantine/core";
import ConfirmAccessButton from "@/app/consent/components/ConsentButton/ConsentButton";
import styles from "./ConsentForm.module.css";
const ConsentForm: FC = () => {
return (
<Stack
align={"center"}
className={styles.container}>
<ConfirmAccessButton />
<Text
className={styles["gray-text"]}
ta="center">
Данные из LogiDex ID передаются в другой сервис и обрабатываются
в соответствии с правилами этого сервиса
</Text>
</Stack>
);
};
export default ConsentForm;

16
src/app/consent/page.tsx Normal file
View File

@ -0,0 +1,16 @@
import PageContainer from "@/components/PageContainer/PageContainer";
import PageItem from "@/components/PageBlock/PageItem";
import Logo from "@/components/Logo/Logo";
import ConsentForm from "@/app/consent/components/ConsentForm/ConsentForm";
export default function ConfirmAccessPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Вход с помощью LogiDex ID"} />
<ConsentForm />
</PageItem>
</PageContainer>
)
}

View File

@ -0,0 +1,15 @@
import LoginForm from "@/components/LoginForm/LoginForm";
import Logo from "@/components/Logo/Logo";
import PageItem from "@/components/PageBlock/PageItem";
import PageContainer from "@/components/PageContainer/PageContainer";
export default function CreateIdPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Создание аккаунта"} />
<LoginForm isCreatingId />
</PageItem>
</PageContainer>
);
}

5
src/app/global.css Normal file
View File

@ -0,0 +1,5 @@
body {
@mixin light {
background-color: whitesmoke;
}
}

57
src/app/layout.tsx Normal file
View File

@ -0,0 +1,57 @@
import "@mantine/core/styles.css";
import React, { ReactNode } from "react";
import {
ColorSchemeScript,
mantineHtmlProps,
MantineProvider,
} from "@mantine/core";
import Header from "@/components/Header/Header";
import { theme } from "@/theme";
import "@/app/global.css";
import Footer from "@/components/Footer/Footer";
import ReduxProvider from "@/providers/ReduxProvider";
export const metadata = {
title: "LogiDex ID",
description: "LogiDex ID",
};
type Props = {
children: ReactNode;
};
export default function RootLayout({ children }: Props) {
return (
<html
lang="ru"
{...mantineHtmlProps}>
<head>
<ColorSchemeScript defaultColorScheme={"auto"} />
<link
rel="shortcut icon"
href="/favicon.svg"
/>
<link
rel="stylesheet"
href="global.css"
/>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
/>
<title />
</head>
<body>
<MantineProvider
theme={theme}
defaultColorScheme={"auto"}>
<ReduxProvider>
<Header />
{children}
<Footer />
</ReduxProvider>
</MantineProvider>
</body>
</html>
);
}

15
src/app/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import LoginForm from "@/components/LoginForm/LoginForm";
import Logo from "@/components/Logo/Logo";
import PageItem from "@/components/PageBlock/PageItem";
import PageContainer from "@/components/PageContainer/PageContainer";
export default function MainPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Вход"} />
<LoginForm />
</PageItem>
</PageContainer>
);
}

View File

@ -0,0 +1,7 @@
.container {
width: 400px;
@media (max-width: 48em) {
width: 100%;
}
}

View File

@ -0,0 +1,66 @@
"use client";
import { useMemo } from "react";
import { redirect } from "next/navigation";
import { Button, Stack, Title } from "@mantine/core";
import styles from "@/app/services/components/ServicesList/ServicesList.module.css";
import TitleWithLines from "@/components/TitleWithLines/TitleWithLines";
import SERVICES from "@/constants/services";
import { ServiceCode } from "@/enums/ServiceCode";
import { setTargetService } from "@/lib/features/targetService/targetServiceSlice";
import { useAppDispatch } from "@/lib/store";
import ServiceData from "@/types/ServiceData";
const ServicesList = () => {
const dispatch = useAppDispatch();
const services = useMemo(
() =>
Object.entries(SERVICES)
.filter(([key]) => key !== ServiceCode.UNDEFINED)
.map(([, value]) => value),
[SERVICES]
);
const onServiceClick = (service: ServiceData) => {
dispatch(setTargetService(service.code));
redirect("consent");
};
const getServiceButton = (service: ServiceData, key: number) => {
return (
<Button
key={key}
size={"xl"}
onClick={() => onServiceClick(service)}>
<Stack gap={0}>
<Title order={4}>{service.name}</Title>
</Stack>
</Button>
);
};
const getServiceInDevelopment = (title: string) => {
return (
<Button
size={"xl"}
onClick={() => {}}
disabled>
<Stack gap={0}>
<Title order={4}>{title}</Title>
</Stack>
</Button>
);
};
return (
<Stack
className={styles.container}
gap={"lg"}>
{services.map((service, i) => getServiceButton(service, i))}
<TitleWithLines title="Скоро будет" />
{getServiceInDevelopment("Analytics")}
</Stack>
);
};
export default ServicesList;

15
src/app/services/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 ServicesList from "@/app/services/components/ServicesList/ServicesList";
export default function ServicesPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Сервисы LogiDex"} />
<ServicesList />
</PageItem>
</PageContainer>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { FC, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Button } from "@mantine/core";
import { setLastSendTime } from "@/lib/features/verification/verificationSlice";
import { RootState } from "@/lib/store";
import { MAX_COUNTDOWN } from "@/constants/verification";
const ResendVerificationCode: FC = () => {
const [countdown, setCountdown] = useState(0);
const dispatch = useDispatch();
const lastSendTime = useSelector(
(state: RootState) => state.verification.lastSendTime
);
useEffect(() => {
dispatch(setLastSendTime(Date.now()));
setCountdown(MAX_COUNTDOWN);
}, []);
useEffect(() => {
if (lastSendTime) {
const elapsed = Math.floor((Date.now() - lastSendTime) / 1000);
const remaining = Math.max(0, MAX_COUNTDOWN - elapsed);
setCountdown(remaining);
}
}, [lastSendTime]);
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const sendCode = () => {
if (countdown > 0) return;
};
const handleResend = () => {
sendCode();
dispatch(setLastSendTime(Date.now()));
setCountdown(MAX_COUNTDOWN);
};
return (
<Button
variant={"outline"}
disabled={countdown > 0}
onClick={handleResend}>
{countdown > 0
? `Отправить код через ${countdown}с`
: "Отправить код"}
</Button>
);
};
export default ResendVerificationCode;

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,63 @@
"use client";
import { FC } from "react";
import { redirect } from "next/navigation";
import { Button, PinInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import ResendVerificationCode from "@/app/verify-phone/components/ResendVerificationCode/ResendVerificationCode";
import style from "@/app/verify-phone/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);
redirect("/services");
};
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>
<ResendVerificationCode />
<Button
variant={"outline"}
onClick={navigateToLogin}>
Назад
</Button>
</Stack>
</form>
);
};
export default VerifyPhoneForm;

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 "@/app/verify-phone/components/VerifyPhoneForm/VerifyPhoneForm";
export default function CreateIdPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Введите код, отправленный на номер"} />
<VerifyPhoneForm />
</PageItem>
</PageContainer>
);
}

View File

@ -0,0 +1,32 @@
.container {
@mixin dark {
background-color: var(--mantine-color-dark-7);
box-shadow: 0 2px 4px var(--mantine-color-dark-6),
0 4px 24px var(--mantine-color-dark-6);
}
@mixin light {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.16),
0 4px 24px rgba(0, 0, 0, 0.16);
}
}
.icon {
width: rem(18px);
height: rem(18px);
}
.light {
display: block;
}
.dark {
display: none;
}
:global([data-mantine-color-scheme='dark']) .light {
display: none;
}
:global([data-mantine-color-scheme='dark']) .dark {
display: block;
}

View File

@ -0,0 +1,40 @@
"use client";
import { IconMoon, IconSun } from "@tabler/icons-react";
import classNames from "classnames";
import {
ActionIcon,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import style from "@/components/ColorSchemeToggle/ColorSchemeToggle.module.css";
export function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme(undefined, {
getInitialValueInEffect: true,
});
const toggleColorScheme = () => {
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
};
return (
<ActionIcon
onClick={toggleColorScheme}
variant="default"
size="xl"
radius="lg"
aria-label="Toggle color scheme"
className={style.container}>
<IconSun
className={classNames(style.icon, style.light)}
stroke={1.5}
/>
<IconMoon
className={classNames(style.icon, style.dark)}
stroke={1.5}
/>
</ActionIcon>
);
}

View File

@ -0,0 +1,30 @@
import Link from "next/link";
import { Group, Text } from "@mantine/core";
const Footer = () => {
return (
<Group
justify={"flex-end"}
align={"flex-end"}
h={"7vh"}
p={"md"}>
<Group gap={"xl"}>
<Link
href={"#"}
style={{
textDecoration: "none",
color: "inherit",
fontSize: 14,
}}>
Помощь
</Link>
<Group gap={5}>
<Text style={{ fontSize: 18 }}>©</Text>
<Text style={{ fontSize: 14 }}>2025, LogiDex</Text>
</Group>
</Group>
</Group>
);
};
export default Footer;

View File

@ -0,0 +1,15 @@
import { Group } from "@mantine/core";
import { ColorSchemeToggle } from "@/components/ColorSchemeToggle/ColorSchemeToggle";
const Header = () => {
return (
<Group
justify="flex-end"
h="7vh"
px="md">
<ColorSchemeToggle />
</Group>
);
};
export default Header;

View File

@ -0,0 +1,80 @@
"use client";
import { FC, useState } from "react";
import { redirect } from "next/navigation";
import { Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import PhoneInput from "@/components/PhoneInput/PhoneInput";
type LoginForm = {
phoneNumber: string;
};
type Props = {
isCreatingId?: boolean;
};
const LoginForm: FC<Props> = ({ isCreatingId = false }) => {
const [phoneMask, setPhoneMask] = useState<string>("");
const form = useForm<LoginForm>({
initialValues: {
phoneNumber: "",
},
validate: {
phoneNumber: phoneNumber =>
phoneNumber.length !== phoneMask.length &&
"Введите корректный номер",
},
});
const handleSubmit = (values: LoginForm) => {
console.log(values);
console.log(phoneMask);
redirect("/verify-phone");
};
const navigateToCreateId = () => redirect("/create-id");
const navigateToLogin = () => redirect("/");
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<PhoneInput
{...form.getInputProps("phoneNumber")}
setPhoneMask={setPhoneMask}
/>
{isCreatingId ? (
<>
<Button
variant={"filled"}
type={"submit"}>
Создать аккаунт
</Button>
<Button
variant={"outline"}
onClick={() => navigateToLogin()}>
Уже есть LogiDex ID
</Button>
</>
) : (
<>
<Button
variant={"filled"}
type={"submit"}>
Войти
</Button>
<Button
variant={"outline"}
onClick={() => navigateToCreateId()}>
Создать LogiDex ID
</Button>
</>
)}
</Stack>
</form>
);
};
export default LoginForm;

View File

@ -0,0 +1,44 @@
import { Center, Divider, Image, Stack, Title } from "@mantine/core";
import { myColor } from "@/theme";
type Props = {
title?: string;
};
const Logo = ({ title }: Props) => {
return (
<Stack
align="center"
gap={0}>
<Image
src="/favicon.svg"
alt="LogiDex Logo"
w={80}
/>
<Title
ta={"center"}
order={2}
mt={"md"}>
LogiDex
</Title>
{title && (
<Divider
w={"100%"}
my={"lg"}
/>
)}
{title && (
<Center>
<Title
order={4}
mb={"lg"}
style={{ color: myColor[6] }}>
{title}
</Title>
</Center>
)}
</Stack>
);
};
export default Logo;

View File

@ -0,0 +1,12 @@
'use client';
import { motion, MotionProps } from 'framer-motion';
import { ReactNode } from 'react';
interface MotionWrapperProps extends MotionProps {
children: ReactNode;
}
export function MotionWrapper({ children, ...props }: MotionWrapperProps) {
return <motion.div {...props}>{children}</motion.div>;
}

View File

@ -0,0 +1,41 @@
.container {
border-radius: rem(40);
background-color: white;
@mixin dark {
background-color: var(--mantine-color-dark-8);
box-shadow: 5px 5px 30px 1px var(--mantine-color-dark-6);
}
@mixin light {
box-shadow: 5px 5px 24px rgba(0, 0, 0, 0.16);
}
padding: rem(35);
}
.container-full-height {
min-height: calc(100vh - (rem(20) * 2));
}
.container-full-height-fixed {
height: calc(100vh - (rem(20) * 2));
}
.container-no-border-radius {
border-radius: 0 !important;
}
.container-full-screen-mobile {
@media (max-width: 48em) {
min-height: 100vh;
height: 100vh;
width: 100vw;
border-radius: 0 !important;
padding: rem(40) rem(20) rem(20);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
overflow-y: auto;
}
}

View File

@ -0,0 +1,46 @@
import { CSSProperties, FC, ReactNode } from "react";
import classNames from "classnames";
import { MotionWrapper } from "@/components/MotionWrapper/MotionWrapper";
import styles from "./PageItem.module.css";
type Props = {
children: ReactNode;
style?: CSSProperties;
fullHeight?: boolean;
fullHeightFixed?: boolean;
noBorderRadius?: boolean;
fullScreenMobile?: boolean;
};
const PageItem: FC<Props> = ({
children,
style,
fullHeight = false,
fullHeightFixed = false,
noBorderRadius = false,
fullScreenMobile = false,
}) => {
const pageItemBody = (
<div
style={style}
className={classNames(
styles.container,
fullHeight && styles["container-full-height"],
fullHeightFixed && styles["container-full-height-fixed"],
noBorderRadius && styles["container-no-border-radius"],
fullScreenMobile && styles["container-full-screen-mobile"]
)}>
{children}
</div>
);
return (
<MotionWrapper
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}>
{pageItemBody}
</MotionWrapper>
);
};
export default PageItem;

View File

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: column;
gap: rem(10);
min-height: 86vh;
background-color: transparent;
}

View File

@ -0,0 +1,24 @@
import { CSSProperties, FC, ReactNode } from "react";
import styles from "./PageContainer.module.css";
type Props = {
children: ReactNode;
style?: CSSProperties;
center?: boolean;
};
const PageContainer: FC<Props> = ({ children, style, center }) => {
return (
<div
className={styles.container}
style={{
...style,
alignItems: center ? "center" : "",
justifyContent: center ? "center" : "",
}}>
{children}
</div>
);
};
export default PageContainer;

View File

@ -0,0 +1,16 @@
.country-select {
font-size: medium;
@mixin light {
color: black;
}
@mixin dark {
color: white;
}
}
.country-search input {
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
border-radius: 15px;
}

View File

@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IMaskInput } from "react-imask";
import {
InputBase,
type InputBaseProps,
type PolymorphicComponentProps,
} from "@mantine/core";
import CountrySelect from "@/components/PhoneInput/components/CountrySelect";
import { Country } from "@/components/PhoneInput/types";
import getInitialDataFromValue from "@/components/PhoneInput/utils/getInitialDataFromValue";
import getPhoneMask from "@/components/PhoneInput/utils/getPhoneMask";
type AdditionalProps = {
onChange: (value: string | null) => void;
setPhoneMask: (mask: string) => void;
initialCountryCode?: string;
};
type InputProps = Omit<
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>,
"onChange" | "defaultValue"
>;
export type Props = AdditionalProps & InputProps;
const PhoneInput = ({
initialCountryCode = "RU",
value: _value,
onChange: _onChange,
setPhoneMask: _setPhoneMask,
...props
}: Props) => {
const [mask, setMask] = useState<string>("");
const initialData = useRef(getInitialDataFromValue(initialCountryCode));
const [country, setCountry] = useState<Country>(
initialData.current.country
);
const [value, setValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const [dropdownWidth, setDropdownWidth] = useState<number>(300);
const lastNotifiedValue = useRef<string | null>(value ?? "");
const onChange = (numberWithoutCode: string) => {
setValue(numberWithoutCode);
_onChange(`+${country.callingCode} ${numberWithoutCode}`);
};
const setPhoneMask = (phoneMask: string, country: Country) => {
setMask(phoneMask);
_setPhoneMask(`+${country.callingCode} ${phoneMask}`);
};
useEffect(() => {
setPhoneMask(initialData.current.format, country);
}, [initialData.current.format]);
useEffect(() => {
const localValue = value.trim();
if (localValue !== lastNotifiedValue.current) {
lastNotifiedValue.current = localValue;
onChange(localValue);
}
}, [country.code, value]);
const { readOnly, disabled } = props;
const leftSectionWidth = 90;
useEffect(() => {
if (!inputRef.current?.offsetWidth) return;
setDropdownWidth(inputRef.current?.offsetWidth);
}, [inputRef.current?.offsetWidth]);
return (
<InputBase
{...props}
component={IMaskInput}
inputRef={inputRef}
leftSection={
<CountrySelect
disabled={disabled || readOnly}
country={country}
setCountry={country => {
setCountry(country);
setPhoneMask(getPhoneMask(country.code), country);
setValue("");
if (inputRef.current) {
inputRef.current.focus();
}
}}
leftSectionWidth={leftSectionWidth}
inputWidth={dropdownWidth}
/>
}
leftSectionWidth={leftSectionWidth}
styles={{
input: {
fontSize: 17,
paddingLeft: `calc(${leftSectionWidth}px + var(--mantine-spacing-sm))`,
},
section: {
borderRight:
"1px solid var(--mantine-color-default-border)",
},
}}
inputMode={"numeric"}
mask={mask}
value={value}
onAccept={value => setValue(value)}
/>
);
};
export default PhoneInput;

View File

@ -0,0 +1,145 @@
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;
inputWidth?: number;
};
const CountrySelect = ({
country,
setCountry,
disabled,
leftSectionWidth,
inputWidth,
}: 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={14} />
</Box>
)}
</Group>
</Combobox.Option>
));
useEffect(() => {
if (search) {
combobox.selectFirstOption();
}
}, [search]);
return (
<Combobox
store={combobox}
position={"bottom-start"}
transitionProps={{ duration: 200, transition: "fade-down" }}
styles={{
dropdown: {
borderRadius: "15px",
minWidth: `${inputWidth}px`,
},
}}
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"}
ml={15}
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="Поиск..."
className={style["country-search"]}
size={"md"}
/>
<Combobox.Options>
<ScrollArea.Autosize
mah={250}
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,20 @@
import { CountryCode } from "libphonenumber-js";
import { Country } from "@/components/PhoneInput/types";
import countryOptionsDataMap from "@/components/PhoneInput/utils/countryOptionsDataMap";
import getPhoneMask from "@/components/PhoneInput/utils/getPhoneMask";
type InitialDataFromValue = {
country: Country;
format: ReturnType<typeof getPhoneMask>;
};
const getInitialDataFromValue = (
initialCountryCode: string
): InitialDataFromValue => {
return {
country: countryOptionsDataMap[initialCountryCode],
format: getPhoneMask(initialCountryCode as CountryCode),
};
};
export default getInitialDataFromValue;

View File

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

View File

@ -0,0 +1,19 @@
import { Divider, Flex, Text } from "@mantine/core";
type Props = {
title: string;
}
const TitleWithLines = ({ title }: Props) => {
return (
<Flex
align="center"
gap="xs">
<Divider style={{ flex: 1 }} />
<Text>{title}</Text>
<Divider style={{ flex: 1 }} />
</Flex>
);
};
export default TitleWithLines;

20
src/constants/services.ts Normal file
View File

@ -0,0 +1,20 @@
import { ServiceCode } from "@/enums/ServiceCode";
import ServiceData from "@/types/ServiceData";
const SERVICES = {
[ServiceCode.UNDEFINED]: {
code: ServiceCode.UNDEFINED,
name: "undefined",
link: "",
requiredAccesses: "",
},
[ServiceCode.CRM]: {
code: ServiceCode.CRM,
name: "LogiDex CRM",
link: "https://skirbo.ru",
requiredAccesses:
"доступ к учетной записи и сделкам по фулфиллменту",
} as ServiceData,
};
export default SERVICES;

View File

@ -0,0 +1 @@
export const MAX_COUNTDOWN = 30;

4
src/enums/ServiceCode.ts Normal file
View File

@ -0,0 +1,4 @@
export enum ServiceCode {
CRM = "crm",
UNDEFINED = "",
}

View File

@ -0,0 +1,10 @@
import { combineReducers } from "@reduxjs/toolkit";
import targetServiceReducer from "@/lib/features/targetService/targetServiceSlice";
import verificationReducer from "@/lib/features/verification/verificationSlice";
const rootReducer = combineReducers({
targetService: targetServiceReducer,
verification: verificationReducer,
});
export default rootReducer;

View File

@ -0,0 +1,24 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ServiceCode } from "@/enums/ServiceCode";
interface TargetServiceState {
serviceCode: ServiceCode;
}
const initialState: TargetServiceState = {
serviceCode: ServiceCode.UNDEFINED,
};
export const targetServiceSlice = createSlice({
name: "targetService",
initialState,
reducers: {
setTargetService: (state, action: PayloadAction<ServiceCode>) => {
state.serviceCode = action.payload;
},
},
});
export const { setTargetService } = targetServiceSlice.actions;
export default targetServiceSlice.reducer;

View File

@ -0,0 +1,23 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface VerificationState {
lastSendTime: number | null;
}
const initialState: VerificationState = {
lastSendTime: null,
};
export const verificationSlice = createSlice({
name: "verification",
initialState,
reducers: {
setLastSendTime: (state, action: PayloadAction<number>) => {
state.lastSendTime = action.payload;
},
},
});
export const { setLastSendTime } = verificationSlice.actions;
export default verificationSlice.reducer;

28
src/lib/store.ts Normal file
View File

@ -0,0 +1,28 @@
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import { persistReducer, persistStore } from "redux-persist";
import storage from "redux-persist/lib/storage";
import rootReducer from "@/lib/features/rootReducer";
const persistConfig = {
key: "root",
storage,
whitelist: ["targetService", "verification"],
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();

View File

@ -0,0 +1,23 @@
"use client";
import { ReactNode } from "react";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { persistor, store } from "@/lib/store";
type Props = {
children: ReactNode;
};
export default function ReduxProvider({ children }: Props) {
return (
<Provider store={store}>
{" "}
<PersistGate
loading={null}
persistor={persistor}>
{children}
</PersistGate>
</Provider>
);
}

5
src/redux-persist.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module "redux-persist/lib/storage" {
import { WebStorage } from "redux-persist/es/types";
const localStorage: WebStorage;
export default localStorage;
}

38
src/theme.ts Normal file
View File

@ -0,0 +1,38 @@
import { createTheme, MantineColorsTuple } from "@mantine/core";
export const myColor: MantineColorsTuple = [
"#e2faff",
"#d4eff8",
"#afdce9",
"#87c8db",
"#65b7cf",
"#4aaac7",
"#3fa7c6",
"#2c92af",
"#1b829e",
"#00718c",
];
const radius = "lg";
const size = "lg";
export const theme = createTheme({
colors: {
myColor,
},
primaryColor: "myColor",
components: {
Button: {
defaultProps: {
radius,
size,
},
},
InputBase: {
defaultProps: {
radius,
size,
},
},
},
});

10
src/types/ServiceData.ts Normal file
View File

@ -0,0 +1,10 @@
import { ServiceCode } from "@/enums/ServiceCode";
type ServiceData = {
code: ServiceCode;
name: string;
link: string;
requiredAccesses: string;
}
export default ServiceData;