340 lines
16 KiB
TypeScript
340 lines
16 KiB
TypeScript
import React from 'react';
|
||
import type { ReactElement } from 'react';
|
||
import Head from 'next/head';
|
||
import Link from 'next/link';
|
||
import { Field, Form, Formik } from 'formik';
|
||
import { mdiArrowLeft, mdiEye, mdiEyeOff, mdiInformation, mdiMapMarker, mdiShieldAccount } from '@mdi/js';
|
||
import { toast, ToastContainer } from 'react-toastify';
|
||
import BaseButton from '../components/BaseButton';
|
||
import BaseButtons from '../components/BaseButtons';
|
||
import BaseDivider from '../components/BaseDivider';
|
||
import BaseIcon from '../components/BaseIcon';
|
||
import CardBox from '../components/CardBox';
|
||
import FormCheckRadio from '../components/FormCheckRadio';
|
||
import FormField from '../components/FormField';
|
||
import LayoutGuest from '../layouts/Guest';
|
||
import { getPageTitle } from '../config';
|
||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||
import { useRouter } from 'next/router';
|
||
|
||
type JourneyContext = {
|
||
roleId: string;
|
||
roleTitle: string;
|
||
city: string;
|
||
contractor: string;
|
||
};
|
||
|
||
const journeyStorageKey = 'roadTalentJourney';
|
||
|
||
const roleLabels: Record<string, string> = {
|
||
school: 'Школа',
|
||
college: 'СПО и ВО',
|
||
contractor: 'Подрядные организации',
|
||
government: 'Государственные институты',
|
||
};
|
||
|
||
export default function Login() {
|
||
const router = useRouter();
|
||
const dispatch = useAppDispatch();
|
||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||
const notify = (type: 'success' | 'error', msg: string) => toast(msg, { type });
|
||
const [showPassword, setShowPassword] = React.useState(false);
|
||
const [journeyContext, setJourneyContext] = React.useState<JourneyContext | null>(null);
|
||
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector((state) => state.auth);
|
||
const [initialValues, setInitialValues] = React.useState({
|
||
email: 'admin@flatlogic.com',
|
||
password: '26ac88a4',
|
||
remember: true,
|
||
});
|
||
|
||
React.useEffect(() => {
|
||
if (token) {
|
||
dispatch(findMe());
|
||
}
|
||
}, [token, dispatch]);
|
||
|
||
React.useEffect(() => {
|
||
if (currentUser?.id) {
|
||
router.push('/dashboard');
|
||
}
|
||
}, [currentUser?.id, router]);
|
||
|
||
React.useEffect(() => {
|
||
if (errorMessage) {
|
||
notify('error', errorMessage);
|
||
}
|
||
}, [errorMessage]);
|
||
|
||
React.useEffect(() => {
|
||
if (notifyState?.showNotification) {
|
||
notify('success', notifyState?.textNotification);
|
||
dispatch(resetAction());
|
||
}
|
||
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
||
|
||
React.useEffect(() => {
|
||
if (!router.isReady) {
|
||
return;
|
||
}
|
||
|
||
const roleFromQuery = typeof router.query.role === 'string' ? router.query.role : '';
|
||
const cityFromQuery = typeof router.query.city === 'string' ? router.query.city : '';
|
||
const contractorFromQuery = typeof router.query.contractor === 'string' ? router.query.contractor : '';
|
||
|
||
if (roleFromQuery || cityFromQuery || contractorFromQuery) {
|
||
const context = {
|
||
roleId: roleFromQuery,
|
||
roleTitle: roleLabels[roleFromQuery] || '',
|
||
city: cityFromQuery,
|
||
contractor: contractorFromQuery,
|
||
};
|
||
setJourneyContext(context);
|
||
localStorage.setItem(journeyStorageKey, JSON.stringify(context));
|
||
return;
|
||
}
|
||
|
||
const storedJourney = localStorage.getItem(journeyStorageKey);
|
||
|
||
if (!storedJourney) {
|
||
setJourneyContext(null);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const parsedJourney = JSON.parse(storedJourney) as JourneyContext;
|
||
setJourneyContext(parsedJourney);
|
||
} catch (error) {
|
||
console.error('Failed to parse road talent journey from storage', error);
|
||
setJourneyContext(null);
|
||
}
|
||
}, [router.isReady, router.query.city, router.query.contractor, router.query.role]);
|
||
|
||
const handleSubmit = async (value: { email: string; password: string; remember: boolean }) => {
|
||
const { remember, ...rest } = value;
|
||
await dispatch(loginUser(rest));
|
||
};
|
||
|
||
const setLogin = (target: HTMLElement) => {
|
||
setInitialValues((prev) => ({
|
||
...prev,
|
||
email: target.innerText.trim(),
|
||
password: target.dataset.password ?? '',
|
||
}));
|
||
};
|
||
|
||
const title = 'Кадровый суверенитет Дороги';
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>{getPageTitle('Login')}</title>
|
||
</Head>
|
||
|
||
<div className='min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(36,59,116,0.45),_transparent_32%),radial-gradient(circle_at_80%_20%,_rgba(122,35,56,0.22),_transparent_28%),linear-gradient(180deg,_#1A2A52_0%,_#101936_65%,_#0c1225_100%)] px-4 py-6 text-[#F5F2EC] md:px-6 md:py-8'>
|
||
<div className='mx-auto flex min-h-[calc(100vh-2rem)] max-w-7xl flex-col gap-6'>
|
||
<header className='flex flex-col gap-3 rounded-[28px] border border-white/10 bg-white/5 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.35em] text-white/55'>вход в систему</p>
|
||
<h1 className='mt-2 text-2xl font-semibold md:text-3xl'>{title}</h1>
|
||
</div>
|
||
<div className='flex flex-wrap gap-3'>
|
||
<Link
|
||
href='/'
|
||
className='inline-flex items-center gap-2 rounded-full border border-white/15 px-4 py-2 text-sm text-white/80 transition hover:border-white/35 hover:bg-white/8 focus:outline-none focus:ring focus:ring-white/35'
|
||
>
|
||
<BaseIcon path={mdiArrowLeft} size={18} />
|
||
Назад к карте
|
||
</Link>
|
||
<BaseButton
|
||
href='/dashboard'
|
||
label='Если уже вошли — в кабинет'
|
||
color='white'
|
||
outline
|
||
className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10'
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
<div className='grid flex-1 gap-6 lg:grid-cols-[0.92fr_1.08fr]'>
|
||
<section className='rounded-[32px] border border-white/10 bg-white/6 p-6 shadow-[0_30px_90px_rgba(5,9,20,0.38)] backdrop-blur'>
|
||
<p className='text-xs uppercase tracking-[0.3em] text-white/55'>контекст маршрута</p>
|
||
<h2 className='mt-2 text-3xl font-semibold'>Путь пользователя сохраняется до входа</h2>
|
||
<p className='mt-3 max-w-xl text-sm leading-7 text-white/70'>
|
||
Мы переносим выбранные на лендинге город, подрядчика и роль в экран авторизации, чтобы вход ощущался частью цельного сценария, а не отдельной формой.
|
||
</p>
|
||
|
||
<div className='mt-6 space-y-4'>
|
||
{journeyContext ? (
|
||
<div className='rounded-[28px] border border-white/10 bg-[#F5F2EC] p-5 text-[#1A2A52] shadow-[0_18px_44px_rgba(6,10,24,0.22)]'>
|
||
<div className='flex items-start justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.26em] text-[#7A2338]'>Выбранный маршрут</p>
|
||
<h3 className='mt-2 text-2xl font-semibold'>{journeyContext.roleTitle || 'Роль не выбрана'}</h3>
|
||
</div>
|
||
<div className='rounded-2xl bg-[#1A2A52] p-3 text-white'>
|
||
<BaseIcon path={mdiShieldAccount} size={22} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mt-5 grid gap-3 md:grid-cols-2'>
|
||
<div className='rounded-[22px] bg-white p-4'>
|
||
<p className='text-xs uppercase tracking-[0.24em] text-[#1A2A52]/45'>Город</p>
|
||
<p className='mt-2 text-base font-semibold'>{journeyContext.city || 'Не выбран'}</p>
|
||
</div>
|
||
<div className='rounded-[22px] bg-white p-4'>
|
||
<p className='text-xs uppercase tracking-[0.24em] text-[#1A2A52]/45'>Подрядчик</p>
|
||
<p className='mt-2 text-base font-semibold'>{journeyContext.contractor || 'Не выбран'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p className='mt-4 text-sm leading-7 text-[#1A2A52]/78'>
|
||
После авторизации этот контекст появится на обзорной странице и поможет быстро перейти к справочникам городов и подрядчиков в админ-интерфейсе.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className='rounded-[28px] border border-dashed border-white/18 bg-[#101936]/75 p-6'>
|
||
<p className='text-lg font-semibold'>Маршрут ещё не выбран</p>
|
||
<p className='mt-3 text-sm leading-7 text-white/70'>
|
||
Вернитесь на карту России, чтобы выбрать город и роль. Вход доступен и без этого шага, но сценарий будет менее полным.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className='rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,_rgba(36,59,116,0.5),_rgba(122,35,56,0.28))] p-5'>
|
||
<div className='flex items-start gap-4'>
|
||
<div className='rounded-2xl bg-white/10 p-3'>
|
||
<BaseIcon className={iconsColor} path={mdiInformation} size={22} />
|
||
</div>
|
||
<div>
|
||
<p className='text-sm font-medium text-white'>Демо-доступ для MVP</p>
|
||
<p className='mt-2 text-sm leading-7 text-white/75'>
|
||
Для тестирования используйте стандартные аккаунты. Выбор роли на лендинге — это контекст пользовательского входа, а не замена существующей системной авторизации.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className='space-y-5'>
|
||
<CardBox className='w-full border border-white/10 bg-[#f5f2ec] text-[#1A2A52] shadow-[0_28px_80px_rgba(5,9,20,0.32)]'>
|
||
<div className='flex flex-col gap-4 md:flex-row md:items-start md:justify-between'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.3em] text-[#7A2338]'>Демо-учётные записи</p>
|
||
<h2 className='mt-2 text-3xl font-semibold'>Быстрый вход</h2>
|
||
<p className='mt-2 text-sm leading-7 text-[#1A2A52]/72'>Подставьте готовую учётную запись кликом по email или введите собственные данные.</p>
|
||
</div>
|
||
<div className='rounded-2xl bg-[#1A2A52] p-3 text-white'>
|
||
<BaseIcon path={mdiMapMarker} size={22} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mt-5 grid gap-4 rounded-[24px] bg-white p-5 md:grid-cols-2'>
|
||
<div>
|
||
<p className='text-sm font-medium text-[#243B74]'>Администратор</p>
|
||
<p className='mt-2 text-sm leading-7 text-[#1A2A52]/72'>
|
||
<code
|
||
className={`cursor-pointer rounded-md bg-[#F5F2EC] px-2 py-1 ${textColor}`}
|
||
data-password='26ac88a4'
|
||
onClick={(event) => setLogin(event.target as HTMLElement)}
|
||
>
|
||
admin@flatlogic.com
|
||
</code>{' '}
|
||
/ <code className={textColor}>26ac88a4</code>
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className='text-sm font-medium text-[#243B74]'>Пользователь</p>
|
||
<p className='mt-2 text-sm leading-7 text-[#1A2A52]/72'>
|
||
<code
|
||
className={`cursor-pointer rounded-md bg-[#F5F2EC] px-2 py-1 ${textColor}`}
|
||
data-password='779ee45a8363'
|
||
onClick={(event) => setLogin(event.target as HTMLElement)}
|
||
>
|
||
client@hello.com
|
||
</code>{' '}
|
||
/ <code className={textColor}>779ee45a8363</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardBox>
|
||
|
||
<CardBox className='w-full border border-white/10 bg-white shadow-[0_28px_80px_rgba(5,9,20,0.32)]'>
|
||
<Formik initialValues={initialValues} enableReinitialize onSubmit={(values) => handleSubmit(values)}>
|
||
<Form>
|
||
<FormField label='Логин' help='Введите email для входа'>
|
||
<Field name='email' />
|
||
</FormField>
|
||
|
||
<div className='relative'>
|
||
<FormField label='Пароль' help='Введите пароль'>
|
||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||
</FormField>
|
||
<button
|
||
type='button'
|
||
className='absolute bottom-8 right-0 pr-3'
|
||
onClick={() => setShowPassword((prev) => !prev)}
|
||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||
>
|
||
<BaseIcon className='text-gray-500 hover:text-gray-700' size={20} path={showPassword ? mdiEyeOff : mdiEye} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||
<FormCheckRadio type='checkbox' label='Запомнить меня'>
|
||
<Field type='checkbox' name='remember' />
|
||
</FormCheckRadio>
|
||
|
||
<Link className={`${textColor} text-blue-600`} href='/forgot'>
|
||
Забыли пароль?
|
||
</Link>
|
||
</div>
|
||
|
||
<BaseDivider />
|
||
|
||
<BaseButtons>
|
||
<BaseButton
|
||
className='w-full border-0 bg-[#243B74] text-white hover:bg-[#314c90]'
|
||
type='submit'
|
||
label={isFetching ? 'Входим...' : 'Войти'}
|
||
color='info'
|
||
disabled={isFetching}
|
||
/>
|
||
</BaseButtons>
|
||
<br />
|
||
<p className='text-center text-[#1A2A52]/72'>
|
||
Нет аккаунта?{' '}
|
||
<Link className={textColor} href='/register'>
|
||
Создать новый
|
||
</Link>
|
||
</p>
|
||
</Form>
|
||
</Formik>
|
||
</CardBox>
|
||
</section>
|
||
</div>
|
||
|
||
<div className='flex flex-col items-center justify-between gap-4 border-t border-white/10 px-1 pt-2 text-center text-sm text-white/60 md:flex-row'>
|
||
<p>© 2026 {title}. Все права защищены.</p>
|
||
<div className='flex flex-wrap items-center justify-center gap-4'>
|
||
<Link className='transition hover:text-white' href='/privacy-policy/'>
|
||
Privacy Policy
|
||
</Link>
|
||
<Link className='transition hover:text-white' href='/'>
|
||
Вернуться на интерактивную карту
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ToastContainer />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
Login.getLayout = function getLayout(page: ReactElement) {
|
||
return <LayoutGuest>{page}</LayoutGuest>;
|
||
};
|