refactoring
This commit is contained in:
16
src/.storybook/main.ts
Normal file
16
src/.storybook/main.ts
Normal 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;
|
||||
36
src/.storybook/preview.tsx
Normal file
36
src/.storybook/preview.tsx
Normal 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>,
|
||||
];
|
||||
44
src/app/consent/components/ConsentButton/ConsentButton.tsx
Normal file
44
src/app/consent/components/ConsentButton/ConsentButton.tsx
Normal 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;
|
||||
@ -0,0 +1,14 @@
|
||||
.container {
|
||||
@media (min-width: 48em) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.gray-text {
|
||||
@mixin dark {
|
||||
color: #807e7e;
|
||||
}
|
||||
@mixin light {
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
22
src/app/consent/components/ConsentForm/ConsentForm.tsx
Normal file
22
src/app/consent/components/ConsentForm/ConsentForm.tsx
Normal 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
16
src/app/consent/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
src/app/create-id/page.tsx
Normal file
15
src/app/create-id/page.tsx
Normal 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
5
src/app/global.css
Normal file
@ -0,0 +1,5 @@
|
||||
body {
|
||||
@mixin light {
|
||||
background-color: whitesmoke;
|
||||
}
|
||||
}
|
||||
57
src/app/layout.tsx
Normal file
57
src/app/layout.tsx
Normal 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
15
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
.container {
|
||||
width: 400px;
|
||||
|
||||
@media (max-width: 48em) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
66
src/app/services/components/ServicesList/ServicesList.tsx
Normal file
66
src/app/services/components/ServicesList/ServicesList.tsx
Normal 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
15
src/app/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
15
src/app/verify-phone/page.tsx
Normal file
15
src/app/verify-phone/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
40
src/components/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal file
40
src/components/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/Footer/Footer.tsx
Normal file
30
src/components/Footer/Footer.tsx
Normal 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;
|
||||
15
src/components/Header/Header.tsx
Normal file
15
src/components/Header/Header.tsx
Normal 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;
|
||||
80
src/components/LoginForm/LoginForm.tsx
Normal file
80
src/components/LoginForm/LoginForm.tsx
Normal 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;
|
||||
44
src/components/Logo/Logo.tsx
Normal file
44
src/components/Logo/Logo.tsx
Normal 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;
|
||||
12
src/components/MotionWrapper/MotionWrapper.tsx
Normal file
12
src/components/MotionWrapper/MotionWrapper.tsx
Normal 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>;
|
||||
}
|
||||
41
src/components/PageBlock/PageItem.module.css
Normal file
41
src/components/PageBlock/PageItem.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
src/components/PageBlock/PageItem.tsx
Normal file
46
src/components/PageBlock/PageItem.tsx
Normal 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;
|
||||
7
src/components/PageContainer/PageContainer.module.css
Normal file
7
src/components/PageContainer/PageContainer.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: rem(10);
|
||||
min-height: 86vh;
|
||||
background-color: transparent;
|
||||
}
|
||||
24
src/components/PageContainer/PageContainer.tsx
Normal file
24
src/components/PageContainer/PageContainer.tsx
Normal 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;
|
||||
16
src/components/PhoneInput/PhoneInput.module.css
Normal file
16
src/components/PhoneInput/PhoneInput.module.css
Normal 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;
|
||||
}
|
||||
116
src/components/PhoneInput/PhoneInput.tsx
Normal file
116
src/components/PhoneInput/PhoneInput.tsx
Normal 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;
|
||||
145
src/components/PhoneInput/components/CountrySelect.tsx
Normal file
145
src/components/PhoneInput/components/CountrySelect.tsx
Normal 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;
|
||||
8
src/components/PhoneInput/types.ts
Normal file
8
src/components/PhoneInput/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { CountryCallingCode, CountryCode } from "libphonenumber-js";
|
||||
|
||||
export type Country = {
|
||||
code: CountryCode;
|
||||
name: string;
|
||||
emoji: string;
|
||||
callingCode: CountryCallingCode;
|
||||
};
|
||||
33
src/components/PhoneInput/utils/countryOptionsDataMap.ts
Normal file
33
src/components/PhoneInput/utils/countryOptionsDataMap.ts
Normal 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;
|
||||
8
src/components/PhoneInput/utils/getFlagEmoji.ts
Normal file
8
src/components/PhoneInput/utils/getFlagEmoji.ts
Normal 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);
|
||||
}
|
||||
20
src/components/PhoneInput/utils/getInitialDataFromValue.ts
Normal file
20
src/components/PhoneInput/utils/getInitialDataFromValue.ts
Normal 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;
|
||||
14
src/components/PhoneInput/utils/getPhoneMask.ts
Normal file
14
src/components/PhoneInput/utils/getPhoneMask.ts
Normal 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");
|
||||
}
|
||||
19
src/components/TitleWithLines/TitleWithLines.tsx
Normal file
19
src/components/TitleWithLines/TitleWithLines.tsx
Normal 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
20
src/constants/services.ts
Normal 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;
|
||||
1
src/constants/verification.ts
Normal file
1
src/constants/verification.ts
Normal file
@ -0,0 +1 @@
|
||||
export const MAX_COUNTDOWN = 30;
|
||||
4
src/enums/ServiceCode.ts
Normal file
4
src/enums/ServiceCode.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ServiceCode {
|
||||
CRM = "crm",
|
||||
UNDEFINED = "",
|
||||
}
|
||||
10
src/lib/features/rootReducer.ts
Normal file
10
src/lib/features/rootReducer.ts
Normal 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;
|
||||
24
src/lib/features/targetService/targetServiceSlice.tsx
Normal file
24
src/lib/features/targetService/targetServiceSlice.tsx
Normal 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;
|
||||
23
src/lib/features/verification/verificationSlice.ts
Normal file
23
src/lib/features/verification/verificationSlice.ts
Normal 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
28
src/lib/store.ts
Normal 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>();
|
||||
23
src/providers/ReduxProvider.tsx
Normal file
23
src/providers/ReduxProvider.tsx
Normal 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
5
src/redux-persist.d.ts
vendored
Normal 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
38
src/theme.ts
Normal 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
10
src/types/ServiceData.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ServiceCode } from "@/enums/ServiceCode";
|
||||
|
||||
type ServiceData = {
|
||||
code: ServiceCode;
|
||||
name: string;
|
||||
link: string;
|
||||
requiredAccesses: string;
|
||||
}
|
||||
|
||||
export default ServiceData;
|
||||
Reference in New Issue
Block a user