Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
e7df955f4e 1 2026-03-27 19:50:03 +00:00
6 changed files with 1187 additions and 374 deletions

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -62,3 +62,117 @@ body {
.introjs-prevbutton{
@apply bg-transparent border border-midnightBlueTheme-buttonColor text-midnightBlueTheme-buttonColor !important;
}
.road-intro-overlay {
background:
radial-gradient(circle at 20% 20%, rgba(36, 59, 116, 0.68), transparent 28%),
radial-gradient(circle at 80% 18%, rgba(122, 35, 56, 0.38), transparent 24%),
linear-gradient(180deg, rgba(26, 42, 82, 0.98) 0%, rgba(12, 18, 37, 0.99) 100%);
animation: roadIntroFade 2.6s ease forwards;
}
.road-intro-panel {
animation: roadIntroLift 1.3s ease both;
}
.road-intro-title {
animation: roadIntroGlow 1.8s ease both;
}
.road-intro-road {
position: relative;
height: 92px;
border-radius: 9999px;
overflow: hidden;
background: linear-gradient(90deg, rgba(245, 242, 236, 0.06), rgba(245, 242, 236, 0.16), rgba(245, 242, 236, 0.06));
border: 1px solid rgba(245, 242, 236, 0.14);
}
.road-intro-road::before {
content: '';
position: absolute;
inset: 14px 24px;
border-radius: 9999px;
border: 1px solid rgba(245, 242, 236, 0.08);
}
.road-intro-road span {
position: absolute;
top: 50%;
width: 22%;
height: 6px;
border-radius: 9999px;
background: rgba(245, 242, 236, 0.85);
transform: translateY(-50%);
animation: roadIntroDrive 1.6s ease-in-out infinite;
}
.road-intro-road span:nth-child(1) {
left: 10%;
animation-delay: 0s;
}
.road-intro-road span:nth-child(2) {
left: 39%;
animation-delay: 0.15s;
}
.road-intro-road span:nth-child(3) {
left: 68%;
animation-delay: 0.3s;
}
@keyframes roadIntroDrive {
0% {
opacity: 0.2;
transform: translateY(-50%) scaleX(0.86);
}
50% {
opacity: 1;
transform: translateY(-50%) scaleX(1.08);
}
100% {
opacity: 0.2;
transform: translateY(-50%) scaleX(0.86);
}
}
@keyframes roadIntroGlow {
0% {
opacity: 0;
transform: translateY(24px);
filter: blur(8px);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
@keyframes roadIntroLift {
0% {
opacity: 0;
transform: translateY(28px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes roadIntroFade {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
88% {
opacity: 1;
}
100% {
opacity: 0;
visibility: hidden;
}
}

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -9,6 +9,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config'
import Link from "next/link";
import BaseButton from '../components/BaseButton';
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
@ -16,6 +17,13 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
type JourneyContext = {
roleId: string;
roleTitle: string;
city: string;
contractor: string;
};
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
@ -39,6 +47,7 @@ const Dashboard = () => {
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const [journeyContext, setJourneyContext] = React.useState<JourneyContext | null>(null);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
@ -83,6 +92,22 @@ const Dashboard = () => {
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
React.useEffect(() => {
const storedJourney = localStorage.getItem('roadTalentJourney');
if (!storedJourney) {
setJourneyContext(null);
return;
}
try {
setJourneyContext(JSON.parse(storedJourney));
} catch (error) {
console.error('Failed to parse road talent journey from storage', error);
setJourneyContext(null);
}
}, []);
return (
<>
<Head>
@ -98,6 +123,45 @@ const Dashboard = () => {
{''}
</SectionTitleLineWithButton>
{journeyContext && (
<div className={`mb-6 overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border border-white/10 bg-[linear-gradient(135deg,_rgba(36,59,116,0.92),_rgba(26,42,82,0.96))] p-6 text-white shadow-[0_24px_70px_rgba(10,16,34,0.26)]`}>
<div className='flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='max-w-3xl'>
<p className='text-xs uppercase tracking-[0.32em] text-white/55'>Маршрут из публичного MVP</p>
<h2 className='mt-2 text-2xl font-semibold'>Вы вошли через сценарий «город подрядчик роль»</h2>
<p className='mt-3 text-sm leading-7 text-white/75'>
Контекст сохранён после публичной карты и теперь может использоваться как стартовая точка для наполнения справочников, проверки подрядчиков и настройки ролевых карточек.
</p>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<div className='rounded-[22px] bg-white/10 p-4 backdrop-blur'>
<p className='text-xs uppercase tracking-[0.22em] text-white/45'>Город</p>
<p className='mt-2 text-base font-semibold'>{journeyContext.city}</p>
</div>
<div className='rounded-[22px] bg-white/10 p-4 backdrop-blur'>
<p className='text-xs uppercase tracking-[0.22em] text-white/45'>Подрядчик</p>
<p className='mt-2 text-base font-semibold'>{journeyContext.contractor}</p>
</div>
<div className='rounded-[22px] bg-white/10 p-4 backdrop-blur'>
<p className='text-xs uppercase tracking-[0.22em] text-white/45'>Роль</p>
<p className='mt-2 text-base font-semibold'>{journeyContext.roleTitle || 'Не выбрана'}</p>
</div>
</div>
</div>
<div className='mt-5 flex flex-wrap gap-3'>
{hasPermission(currentUser, 'READ_CITIES') && (
<BaseButton href='/cities/cities-list' label='Открыть Cities' color='white' outline className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10' />
)}
{hasPermission(currentUser, 'READ_CONTRACTORS') && (
<BaseButton href='/contractors/contractors-list' label='Открыть Contractors' color='white' outline className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10' />
)}
{hasPermission(currentUser, 'READ_ROLE_CARDS') && (
<BaseButton href='/role_cards/role_cards-list' label='Открыть Role cards' color='white' outline className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10' />
)}
</div>
</div>
)}
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}

View File

@ -1,166 +1,740 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
mdiArrowDown,
mdiArrowRight,
mdiCityVariantOutline,
mdiCompassOutline,
mdiMapMarker,
mdiRoadVariant,
mdiShieldAccount,
mdiViewDashboardOutline,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
type CityMarker = {
id: string;
name: string;
district: string;
contractor: string;
description: string;
specialization: string;
top: string;
left: string;
popupTop: string;
popupLeft: string;
};
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
type RoleCard = {
id: string;
title: string;
caption: string;
description: string;
accent: string;
};
const title = 'Кадровый суверенитет Дороги'
type JourneyContext = {
roleId: string;
roleTitle: string;
city: string;
contractor: string;
};
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const journeyStorageKey = 'roadTalentJourney';
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
const palette = {
navy: '#243B74',
midnight: '#1A2A52',
burgundy: '#56061D',
berry: '#7A2338',
paper: '#F5F2EC',
};
const cityMarkers: CityMarker[] = [
{
id: 'moscow',
name: 'Москва',
district: 'Центральный кластер',
contractor: 'АО «ДорИнфраструктура»',
description:
'Координирует набор инженерных команд, стажировки и быстрый вывод специалистов на федеральные дорожные объекты.',
specialization: 'Цифровое управление проектами и кадровый резерв',
top: '35%',
left: '18%',
popupTop: '18%',
popupLeft: '23%',
},
{
id: 'saint-petersburg',
name: 'Санкт-Петербург',
district: 'Северо-Западный кластер',
contractor: 'ООО «Балтийские магистрали»',
description:
'Развивает дорожное проектирование и практикум для молодых специалистов под задачи северо-западных регионов.',
specialization: 'Проектирование, BIM и подготовка мастеров участка',
top: '26%',
left: '16%',
popupTop: '8%',
popupLeft: '21%',
},
{
id: 'kazan',
name: 'Казань',
district: 'Приволжский кластер',
contractor: 'ГК «Трасса Развития»',
description:
'Собирает межвузовские команды для пилотных дорожных полигонов, подготовки механиков и специалистов по безопасности движения.',
specialization: 'Учебные полигоны и отраслевые акселераторы',
top: '43%',
left: '29%',
popupTop: '24%',
popupLeft: '34%',
},
{
id: 'yekaterinburg',
name: 'Екатеринбург',
district: 'Уральский кластер',
contractor: 'АО «УралДорКадры»',
description:
'Формирует региональный кадровый центр по линейному строительству, эксплуатации техники и управлению подрядом.',
specialization: 'Линейное строительство и эксплуатация дорожной техники',
top: '45%',
left: '42%',
popupTop: '26%',
popupLeft: '47%',
},
{
id: 'novosibirsk',
name: 'Новосибирск',
district: 'Сибирский кластер',
contractor: 'ООО «СибАвтоДор»',
description:
'Запускает маршруты переобучения, отраслевую аналитику и совместные программы для СПО и подрядчиков.',
specialization: 'Переобучение и аналитика по потребности в кадрах',
top: '50%',
left: '58%',
popupTop: '31%',
popupLeft: '62%',
},
{
id: 'vladivostok',
name: 'Владивосток',
district: 'Дальневосточный кластер',
contractor: 'ГК «Восточный путь»',
description:
'Курирует кадровые потоки для портовых подходов, мостовых объектов и дорожных узлов Дальнего Востока.',
specialization: 'Мостостроение, портовая логистика и восточные коридоры',
top: '54%',
left: '87%',
popupTop: '35%',
popupLeft: '69%',
},
];
const roleCards: RoleCard[] = [
{
id: 'school',
title: 'Школа',
caption: 'Ранняя профориентация',
description: 'Маршрут для учеников, наставников и школьных команд, которым нужен вход в отрасль через экскурсии и треки развития.',
accent: '#243B74',
},
{
id: 'college',
title: 'СПО и ВО',
caption: 'Подготовка специалистов',
description: 'Для колледжей и вузов, которые синхронизируют образовательные программы с реальными дорожными проектами.',
accent: '#1A2A52',
},
{
id: 'contractor',
title: 'Подрядные организации',
caption: 'Быстрый кадровый контур',
description: 'Для компаний, которым важно увидеть локальный кадровый резерв, потребности по ролям и точки входа в экосистему.',
accent: '#56061D',
},
{
id: 'government',
title: 'Государственные институты',
caption: 'Управление системой',
description: 'Для операторов отрасли, которые управляют координацией партнёров, показателями и региональными соглашениями.',
accent: '#7A2338',
},
];
function getRoleTitle(roleId: string) {
return roleCards.find((role) => role.id === roleId)?.title || '';
}
function RoleIllustration({ roleId }: { roleId: string }) {
const stroke = palette.paper;
if (roleId === 'school') {
return (
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
<circle cx='82' cy='38' r='16' stroke={stroke} strokeWidth='4' />
<path d='M48 78c10-18 21-27 34-27 12 0 22 8 31 24' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M63 86v35m39-35v35' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M35 110h92' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M42 110l20-18m56 18-20-18' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M18 44h32l8 14H26z' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
</svg>
);
}
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
if (roleId === 'college') {
return (
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
<circle cx='80' cy='34' r='16' stroke={stroke} strokeWidth='4' />
<path d='M52 80c9-18 19-27 28-27 12 0 22 8 28 22' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M63 82v42m34-42v42' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M80 18l46 18-46 18-46-18 46-18z' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
<path d='M124 39v28' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M42 118c12-10 25-15 38-15s26 5 38 15' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
</svg>
);
}
if (roleId === 'contractor') {
return (
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
<circle cx='80' cy='34' r='16' stroke={stroke} strokeWidth='4' />
<path d='M56 80c7-19 16-28 24-28 11 0 21 9 27 28' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M62 82v38m34-38v38' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M48 56l32-20 32 20' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M48 122h64' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M36 101h88' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M28 101l8-23h88l8 23' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
</svg>
);
}
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head>
<title>{getPageTitle('Starter Page')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Кадровый суверенитет Дороги app!"/>
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
<circle cx='80' cy='34' r='16' stroke={stroke} strokeWidth='4' />
<path d='M55 79c8-18 17-27 25-27 11 0 20 8 26 24' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M61 82v40m34-40v40' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M36 114h88' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M80 56v-16' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
<path d='M48 64l32-16 32 16' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
<rect x='48' y='64' width='64' height='30' rx='8' stroke={stroke} strokeWidth='4' />
<path d='M72 79h16' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
</svg>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
export default function RoadTalentLanding() {
const router = useRouter();
const [showIntro, setShowIntro] = React.useState(true);
const [selectedCity, setSelectedCity] = React.useState<CityMarker | null>(cityMarkers[0]);
const [selectedRole, setSelectedRole] = React.useState<string>('');
const [roleStageVisible, setRoleStageVisible] = React.useState(false);
const [validationMessage, setValidationMessage] = React.useState('');
React.useEffect(() => {
const introTimer = window.setTimeout(() => {
setShowIntro(false);
}, 2600);
return () => window.clearTimeout(introTimer);
}, []);
const openRoleStage = () => {
if (!selectedCity) {
setValidationMessage('Сначала выберите город на карте.');
return;
}
setValidationMessage('');
setRoleStageVisible(true);
window.setTimeout(() => {
document.getElementById('roles-stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 120);
};
const continueToLogin = () => {
if (!selectedCity) {
setValidationMessage('Сначала выберите город и подрядчика.');
return;
}
if (!selectedRole) {
setValidationMessage('Выберите роль, чтобы продолжить ко входу.');
return;
}
const roleTitle = getRoleTitle(selectedRole);
const journeyContext: JourneyContext = {
roleId: selectedRole,
roleTitle,
city: selectedCity.name,
contractor: selectedCity.contractor,
};
localStorage.setItem(journeyStorageKey, JSON.stringify(journeyContext));
router.push({
pathname: '/login',
query: {
role: selectedRole,
city: selectedCity.name,
contractor: selectedCity.contractor,
},
});
};
return (
<>
<Head>
<title>{getPageTitle('Кадровый суверенитет дорожной отрасли')}</title>
</Head>
<div className='relative overflow-hidden bg-[#0d1530] text-[#F5F2EC]'>
{showIntro && (
<div className='road-intro-overlay fixed inset-0 z-50 flex items-center justify-center px-6'>
<button
type='button'
onClick={() => setShowIntro(false)}
className='absolute right-6 top-6 rounded-full border border-white/25 px-4 py-2 text-sm text-white/80 transition hover:border-white/60 hover:text-white focus:outline-none focus:ring focus:ring-white/40'
>
Пропустить
</button>
<div className='road-intro-panel flex max-w-3xl flex-col items-center text-center'>
<div className='road-intro-road mb-10 w-full max-w-xl'>
<span />
<span />
<span />
</div>
<p className='mb-4 text-sm uppercase tracking-[0.45em] text-white/65'>кадровый суверенитет дорожной отрасли</p>
<h1 className='road-intro-title max-w-4xl text-4xl font-semibold tracking-tight md:text-6xl'>
Соединяем города, подрядчиков и роли в одном маршруте входа
</h1>
<p className='mt-6 max-w-2xl text-base text-[#F5F2EC]/78 md:text-lg'>
Интерактивный MVP-портал для выбора города, просмотра подрядчика и быстрого перехода к авторизации по нужной роли.
</p>
</div>
</div>
)}
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(36,59,116,0.46),_transparent_36%),radial-gradient(circle_at_80%_15%,_rgba(122,35,56,0.28),_transparent_30%),linear-gradient(180deg,_#1A2A52_0%,_#0d1530_60%,_#0c1123_100%)]' />
<div className='absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/40 to-transparent' />
<div className='relative z-10 mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-6 lg:px-10'>
<header className='mb-10 flex flex-col gap-4 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.4em] text-white/55'>MVP-портал</p>
<div className='mt-2 flex items-center gap-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[#243B74] shadow-[0_0_0_6px_rgba(245,242,236,0.04)]'>
<BaseIcon path={mdiRoadVariant} size={24} />
</div>
<div>
<p className='text-lg font-semibold md:text-xl'>Кадровый суверенитет</p>
<p className='text-sm text-white/65'>дорожной отрасли России</p>
</div>
</div>
</div>
<div className='flex flex-wrap items-center gap-3'>
<button
type='button'
onClick={() => document.getElementById('map-stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
className='inline-flex items-center gap-2 rounded-full border border-white/15 px-4 py-2 text-sm text-white/85 transition hover:border-white/35 hover:bg-white/8 focus:outline-none focus:ring focus:ring-white/30'
>
<BaseIcon path={mdiCompassOutline} size={18} />
К карте
</button>
<BaseButton href='/login' label='Войти' color='info' className='border-0 bg-[#243B74] px-5 text-white hover:bg-[#314c90]' />
<BaseButton
href='/login'
label='Админ-интерфейс'
color='white'
outline
className='border-white/25 bg-transparent px-5 text-white hover:border-white/45 hover:bg-white/10'
/>
</div>
</header>
<section className='grid items-start gap-8 pb-10 lg:grid-cols-[1.1fr_0.9fr] lg:pb-16'>
<div className='max-w-3xl'>
<div className='mb-5 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/6 px-4 py-2 text-sm text-white/75'>
<span className='h-2.5 w-2.5 rounded-full bg-[#F5F2EC]' />
Анимация входа выбор города роль логин
</div>
<h1 className='max-w-4xl text-4xl font-semibold leading-tight md:text-6xl'>
Выберите город на карте России и войдите в отраслевую экосистему с нужной ролью.
</h1>
<p className='mt-6 max-w-2xl text-base leading-7 text-white/70 md:text-lg'>
Первый MVP-срез уже проводит пользователя по ключевому сценарию: показывает городские точки, раскрывает карточку подрядчика,
предлагает роль и передаёт контекст в авторизацию без лишних шагов.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<button
type='button'
onClick={() => document.getElementById('map-stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
className='inline-flex items-center gap-2 rounded-full bg-[#F5F2EC] px-6 py-3 text-sm font-medium text-[#1A2A52] transition hover:translate-y-[-1px] hover:bg-white focus:outline-none focus:ring focus:ring-white/40'
>
Начать маршрут
<BaseIcon path={mdiArrowDown} size={18} />
</button>
<BaseButton
href='/login'
label='Перейти ко входу'
color='white'
outline
className='border-white/20 bg-transparent px-6 text-white hover:border-white/40 hover:bg-white/10'
/>
</div>
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
{[
{ value: '6', label: 'городов в демо-карте' },
{ value: '4', label: 'роли входа' },
{ value: '1', label: 'сквозной маршрут MVP' },
].map((item) => (
<div key={item.label} className='rounded-[24px] border border-white/10 bg-white/6 p-5 backdrop-blur'>
<div className='text-3xl font-semibold text-white'>{item.value}</div>
<div className='mt-2 text-sm leading-6 text-white/65'>{item.label}</div>
</div>
))}
</div>
</div>
<div className='rounded-[32px] border border-white/10 bg-gradient-to-br from-white/12 via-white/8 to-white/5 p-6 shadow-[0_40px_120px_rgba(5,9,20,0.45)] backdrop-blur'>
<div className='mb-6 flex items-center justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.32em] text-white/55'>Маршрут пользователя</p>
<h2 className='mt-2 text-2xl font-semibold'>Первый экран, который продаёт логику сервиса</h2>
</div>
<div className='rounded-full border border-white/15 bg-white/8 px-3 py-1 text-xs text-white/70'>public MVP</div>
</div>
<div className='space-y-4'>
{[
'1. Анимированный вход знакомит пользователя с сервисом.',
'2. Карта России позволяет кликнуть по городам и увидеть подрядчика.',
'3. Выбор роли переносится в экран логина и дальше сохраняется для рабочего кабинета.',
].map((item) => (
<div key={item} className='flex items-start gap-3 rounded-[22px] border border-white/8 bg-[#101936]/70 p-4'>
<div className='mt-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#243B74] text-sm font-semibold'></div>
<p className='text-sm leading-6 text-white/75'>{item}</p>
</div>
))}
</div>
<div className='mt-6 rounded-[24px] border border-[#7A2338]/60 bg-[linear-gradient(135deg,_rgba(122,35,56,0.18),_rgba(26,42,82,0.25))] p-5'>
<p className='text-sm uppercase tracking-[0.28em] text-[#F5F2EC]/55'>что дальше в админке</p>
<p className='mt-3 text-sm leading-7 text-[#F5F2EC]/82'>
После входа администратор уже может пользоваться существующими справочниками <span className='font-medium text-white'>Cities</span>,
<span className='font-medium text-white'> Contractors</span> и <span className='font-medium text-white'>Role cards</span> для наполнения отраслевых данных.
</p>
</div>
</div>
</section>
<section id='map-stage' className='grid gap-6 pb-10 lg:grid-cols-[1.25fr_0.75fr] lg:pb-16'>
<div className='rounded-[32px] border border-white/10 bg-white/6 p-5 shadow-[0_24px_90px_rgba(4,8,18,0.45)] backdrop-blur md:p-6'>
<div className='mb-5 flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.32em] text-white/55'>Шаг 1</p>
<h2 className='mt-2 text-2xl font-semibold'>Интерактивная карта России по городам</h2>
</div>
<div className='rounded-full border border-white/10 bg-[#0f1734] px-4 py-2 text-sm text-white/70'>
Нажмите на город, чтобы открыть подрядчика
</div>
</div>
<div className='relative overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_20%_20%,_rgba(245,242,236,0.08),_transparent_28%),linear-gradient(180deg,_rgba(15,23,52,0.96),_rgba(14,19,38,0.98))] p-4 md:p-6'>
<div className='absolute inset-x-0 top-6 flex justify-center'>
<div className='rounded-full border border-white/10 bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.25em] text-white/55'>Россия · дорожные кластеры</div>
</div>
<div className='relative mt-10 min-h-[450px] overflow-hidden rounded-[24px] border border-white/8 bg-[linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]'>
<div className='absolute inset-0 bg-[linear-gradient(90deg,_rgba(255,255,255,0.02)_1px,_transparent_1px),linear-gradient(_rgba(255,255,255,0.02)_1px,_transparent_1px)] bg-[size:42px_42px] opacity-30' />
<svg viewBox='0 0 900 460' className='absolute inset-0 h-full w-full'>
<defs>
<linearGradient id='russiaFill' x1='0%' x2='100%' y1='0%' y2='100%'>
<stop offset='0%' stopColor='rgba(245,242,236,0.16)' />
<stop offset='100%' stopColor='rgba(245,242,236,0.07)' />
</linearGradient>
</defs>
<path
d='M63 245l41-61 92-27 91 13 55-32 59 7 48-24 70 16 35-12 65 20 74-3 61 28 25 32-37 23 35 25-14 33-63 10-54 24-90 4-72 29-91 3-86 26-81-14-79 17-64-19 10-34-36-35z'
fill='url(#russiaFill)'
stroke='rgba(245,242,236,0.22)'
strokeWidth='3'
strokeLinejoin='round'
/>
</svg>
{selectedCity && (
<div
className='absolute hidden w-72 max-w-[80vw] rounded-[22px] border border-white/10 bg-[#f5f2ec] p-4 text-[#1A2A52] shadow-[0_22px_60px_rgba(5,10,28,0.55)] md:block'
style={{ top: selectedCity.popupTop, left: selectedCity.popupLeft }}
>
<p className='text-xs uppercase tracking-[0.28em] text-[#7A2338]'>Подрядчик</p>
<h3 className='mt-2 text-lg font-semibold'>{selectedCity.contractor}</h3>
<p className='mt-2 text-sm leading-6 text-[#1A2A52]/82'>{selectedCity.description}</p>
</div>
)}
{cityMarkers.map((city) => {
const isActive = selectedCity?.id === city.id;
return (
<button
key={city.id}
type='button'
onClick={() => {
setSelectedCity(city);
setValidationMessage('');
}}
className='group absolute -translate-x-1/2 -translate-y-1/2 focus:outline-none'
style={{ top: city.top, left: city.left }}
aria-label={`Открыть подрядчика для города ${city.name}`}
>
<span
className={`absolute left-1/2 top-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full blur-md transition ${
isActive ? 'bg-[#F5F2EC]/35' : 'bg-[#243B74]/20 group-hover:bg-[#F5F2EC]/20'
}`}
/>
<span
className={`relative flex h-12 w-12 items-center justify-center rounded-full border transition ${
isActive
? 'border-[#F5F2EC] bg-[#F5F2EC] text-[#1A2A52]'
: 'border-white/25 bg-[#243B74]/85 text-white group-hover:border-white/55 group-hover:bg-[#314c90]'
}`}
>
<BaseIcon path={mdiMapMarker} size={22} />
</span>
<span
className={`mt-2 inline-flex rounded-full px-3 py-1 text-xs font-medium transition ${
isActive
? 'bg-[#F5F2EC] text-[#1A2A52]'
: 'bg-[#101936]/90 text-white/80 group-hover:text-white'
}`}
>
{city.name}
</span>
</button>
);
})}
</div>
<div className='mt-5 flex flex-wrap gap-2'>
{cityMarkers.map((city) => (
<button
key={city.id}
type='button'
onClick={() => {
setSelectedCity(city);
setValidationMessage('');
}}
className={`rounded-full border px-4 py-2 text-sm transition focus:outline-none focus:ring focus:ring-white/30 ${
selectedCity?.id === city.id
? 'border-[#F5F2EC] bg-[#F5F2EC] text-[#1A2A52]'
: 'border-white/12 bg-white/5 text-white/75 hover:border-white/30 hover:text-white'
}`}
>
{city.name}
</button>
))}
</div>
</div>
</div>
<aside className='rounded-[32px] border border-white/10 bg-[#f5f2ec] p-6 text-[#1A2A52] shadow-[0_24px_90px_rgba(4,8,18,0.35)]'>
<div className='mb-4 flex items-center justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.3em] text-[#7A2338]'>Выбранная точка</p>
<h2 className='mt-2 text-2xl font-semibold'>Карточка подрядчика</h2>
</div>
<div className='rounded-2xl bg-[#1A2A52] p-3 text-white'>
<BaseIcon path={mdiCityVariantOutline} size={22} />
</div>
</div>
{selectedCity ? (
<>
<div className='rounded-[26px] border border-[#1A2A52]/8 bg-white p-5 shadow-[0_16px_40px_rgba(26,42,82,0.08)]'>
<p className='text-sm uppercase tracking-[0.25em] text-[#7A2338]'>{selectedCity.district}</p>
<h3 className='mt-2 text-3xl font-semibold'>{selectedCity.name}</h3>
<p className='mt-2 text-base font-medium text-[#243B74]'>{selectedCity.contractor}</p>
<p className='mt-4 text-sm leading-7 text-[#1A2A52]/78'>{selectedCity.description}</p>
<div className='mt-5 rounded-[22px] bg-[#F5F2EC] p-4'>
<p className='text-xs uppercase tracking-[0.24em] text-[#1A2A52]/55'>Фокус подрядчика</p>
<p className='mt-2 text-sm leading-6 text-[#1A2A52]/82'>{selectedCity.specialization}</p>
</div>
</div>
<div className='mt-5 grid grid-cols-2 gap-3'>
{[
{ label: 'Город', value: selectedCity.name },
{ label: 'Формат', value: 'Открытый вход' },
{ label: 'Этап', value: 'Выбор роли' },
{ label: 'Доступ', value: 'Публичный MVP' },
].map((item) => (
<div key={item.label} className='rounded-[22px] bg-[#1A2A52] p-4 text-[#F5F2EC]'>
<p className='text-xs uppercase tracking-[0.22em] text-white/45'>{item.label}</p>
<p className='mt-2 text-sm font-medium'>{item.value}</p>
</div>
))}
</div>
</>
) : (
<div className='rounded-[24px] border border-dashed border-[#1A2A52]/18 bg-white p-6 text-center'>
<p className='text-lg font-semibold'>Пока город не выбран</p>
<p className='mt-2 text-sm leading-6 text-[#1A2A52]/72'>Нажмите на маркер на карте России, чтобы открыть карточку подрядчика и продолжить маршрут.</p>
</div>
)}
<div className='mt-6 rounded-[24px] bg-[linear-gradient(135deg,_#243B74,_#1A2A52)] p-5 text-[#F5F2EC]'>
<p className='text-xs uppercase tracking-[0.28em] text-white/55'>Шаг 2</p>
<h3 className='mt-2 text-xl font-semibold'>Перейти к выбору роли</h3>
<p className='mt-3 text-sm leading-6 text-white/70'>Продолжите сценарий после выбора города: откроем четыре роли и передадим выбор в экран логина.</p>
<button
type='button'
onClick={openRoleStage}
className='mt-5 inline-flex items-center gap-2 rounded-full bg-[#F5F2EC] px-5 py-3 text-sm font-medium text-[#1A2A52] transition hover:translate-y-[-1px] hover:bg-white focus:outline-none focus:ring focus:ring-white/40'
>
Далее
<BaseIcon path={mdiArrowRight} size={18} />
</button>
{validationMessage && <p className='mt-3 text-sm text-[#F5F2EC]/82'>{validationMessage}</p>}
</div>
</aside>
</section>
<section id='roles-stage' className={`pb-14 transition duration-500 ${roleStageVisible ? 'opacity-100' : 'opacity-90'}`}>
<div className='mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.32em] text-white/55'>Шаг 2</p>
<h2 className='mt-2 text-3xl font-semibold'>Выберите роль входа</h2>
<p className='mt-3 max-w-2xl text-sm leading-7 text-white/70'>Четыре карточки роли отражают ключевых участников экосистемы: от школы до подрядных организаций и государственных институтов.</p>
</div>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/70'>
{selectedCity ? `Город: ${selectedCity.name}` : 'Сначала выберите город'}
</div>
</div>
<div className='grid gap-5 lg:grid-cols-4'>
{roleCards.map((role) => {
const isActive = selectedRole === role.id;
return (
<button
key={role.id}
type='button'
onClick={() => {
setSelectedRole(role.id);
setValidationMessage('');
}}
className={`group relative overflow-hidden rounded-[30px] border p-6 text-left transition duration-200 focus:outline-none focus:ring focus:ring-white/35 ${
isActive
? 'border-white/50 bg-[linear-gradient(180deg,_rgba(245,242,236,0.16),_rgba(255,255,255,0.06))] shadow-[0_28px_70px_rgba(5,9,20,0.42)]'
: 'border-white/10 bg-white/5 hover:border-white/25 hover:bg-white/8'
}`}
>
<div
className='absolute inset-x-0 top-0 h-1'
style={{ background: `linear-gradient(90deg, ${role.accent}, rgba(245,242,236,0.75))` }}
/>
<div className='flex items-center justify-between'>
<p className='text-xs uppercase tracking-[0.28em] text-white/55'>{role.caption}</p>
<span className={`rounded-full px-3 py-1 text-xs ${isActive ? 'bg-[#F5F2EC] text-[#1A2A52]' : 'bg-white/10 text-white/65'}`}>
{isActive ? 'Выбрано' : 'Роль'}
</span>
</div>
<div className='mt-4 flex justify-center rounded-[26px] bg-[linear-gradient(180deg,_rgba(245,242,236,0.05),_rgba(245,242,236,0.01))] p-4'>
<RoleIllustration roleId={role.id} />
</div>
<h3 className='mt-5 text-2xl font-semibold'>{role.title}</h3>
<p className='mt-3 text-sm leading-7 text-white/70'>{role.description}</p>
</button>
);
})}
</div>
<div className='mt-8 grid gap-6 rounded-[32px] border border-white/10 bg-white/5 p-6 backdrop-blur lg:grid-cols-[0.95fr_1.05fr]'>
<div>
<p className='text-xs uppercase tracking-[0.32em] text-white/55'>Шаг 3</p>
<h3 className='mt-2 text-2xl font-semibold'>Переход к логину и паролю</h3>
<p className='mt-3 text-sm leading-7 text-white/70'>Выбранные город, подрядчик и роль будут показаны на экране логина и сохранятся для первого экрана внутри кабинета.</p>
</div>
<div className='grid gap-4 rounded-[26px] bg-[#f5f2ec] p-5 text-[#1A2A52] md:grid-cols-3'>
<div>
<p className='text-xs uppercase tracking-[0.25em] text-[#1A2A52]/50'>Город</p>
<p className='mt-2 text-base font-semibold'>{selectedCity?.name || 'Не выбран'}</p>
</div>
<div>
<p className='text-xs uppercase tracking-[0.25em] text-[#1A2A52]/50'>Подрядчик</p>
<p className='mt-2 text-base font-semibold'>{selectedCity?.contractor || 'Ожидание выбора'}</p>
</div>
<div>
<p className='text-xs uppercase tracking-[0.25em] text-[#1A2A52]/50'>Роль</p>
<p className='mt-2 text-base font-semibold'>{getRoleTitle(selectedRole) || 'Не выбрана'}</p>
</div>
</div>
</div>
<div className='mt-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<p className='text-sm text-white/70'>
{selectedRole
? `Готово: выбран маршрут «${selectedCity?.name || ''}${getRoleTitle(selectedRole)}».`
: 'Выберите одну из ролей, чтобы открыть следующий шаг.'}
</p>
<div className='flex flex-wrap gap-3'>
<BaseButton
href='/login'
label='Пропустить к логину'
color='white'
outline
className='border-white/20 bg-transparent px-5 text-white hover:border-white/40 hover:bg-white/10'
/>
<button
type='button'
onClick={continueToLogin}
className='inline-flex items-center gap-2 rounded-full bg-[#F5F2EC] px-6 py-3 text-sm font-medium text-[#1A2A52] transition hover:translate-y-[-1px] hover:bg-white focus:outline-none focus:ring focus:ring-white/35'
>
Продолжить к логину
<BaseIcon path={mdiShieldAccount} size={18} />
</button>
</div>
</div>
</section>
<footer className='mt-auto flex flex-col gap-4 border-t border-white/10 pt-6 text-sm text-white/60 md:flex-row md:items-center md:justify-between'>
<p>© 2026 Кадровый суверенитет дорожной отрасли первый публичный маршрут входа для партнёров и образовательных учреждений.</p>
<div className='flex flex-wrap items-center gap-3'>
<BaseButton
href='/login'
label='Открыть кабинет'
color='white'
outline
className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10'
/>
<BaseButton
href='/dashboard'
label='После входа: Overview'
color='white'
outline
className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10'
icon={mdiViewDashboardOutline}
/>
</div>
</footer>
</div>
</div>
</>
);
}
RoadTalentLanding.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,273 +1,336 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import Link from 'next/link';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider';
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 { useRouter } from 'next/router';
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 Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
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, msg) => toast(msg, { type });
const [ illustrationImage, setIllustrationImage ] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('left');
const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
(state) => state.auth,
);
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
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 })
remember: true,
});
const title = 'Кадровый суверенитет Дороги'
// Fetch Pexels image/video
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
// Fetch user data
useEffect(() => {
React.useEffect(() => {
if (token) {
dispatch(findMe());
}
}, [token, dispatch]);
// Redirect to dashboard if user is logged in
useEffect(() => {
React.useEffect(() => {
if (currentUser?.id) {
router.push('/dashboard');
}
}, [currentUser?.id, router]);
// Show error message if there is one
useEffect(() => {
if (errorMessage){
notify('error', errorMessage)
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;
}
}, [errorMessage])
// Show notification if there is one
useEffect(() => {
if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification)
dispatch(resetAction());
}
}, [notifyState?.showNotification])
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 : '';
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
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 handleSubmit = async (value) => {
const {remember, ...rest} = value
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 ?? '',
}));
setInitialValues((prev) => ({
...prev,
email: target.innerText.trim(),
password: target.dataset.password ?? '',
}));
};
const imageBlock = (image) => (
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}>
<div className="flex justify-center w-full bg-blue-300/20">
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
by {image?.photographer} on Pexels</a>
</div>
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const title = 'Кадровый суверенитет Дороги';
return (
<div style={contentPosition === 'background' ? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
} : {}}>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<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>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<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>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row justify-between'>
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="26ac88a4"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>26ac88a4</code>{' / '}
to login as Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="779ee45a8363"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>779ee45a8363</code>{' / '}
to login as User</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
<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>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<Formik
initialValues={initialValues}
enableReinitialize
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label='Login'
help='Please enter your login'>
<Field name='email' />
</FormField>
<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>
<div className='relative'>
<FormField
label='Password'
help='Please enter your password'>
<Field name='password' type={showPassword ? 'text' : 'password'} />
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
className='text-gray-500 hover:text-gray-700'
size={20}
path={showPassword ? mdiEyeOff : mdiEye}
/>
</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={'flex justify-between'}>
<FormCheckRadio type='checkbox' label='Remember'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<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>
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
Forgot password?
</Link>
</div>
<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>
<BaseDivider />
<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>
<BaseButtons>
<BaseButton
className={'w-full'}
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='info'
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>
</p>
</Form>
</Formik>
</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>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<ToastContainer />
</div>
</>
);
}