Flatlogic Bot e7df955f4e 1
2026-03-27 19:50:03 +00:00

340 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
};