Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7df955f4e |
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -62,3 +62,117 @@ body {
|
|||||||
.introjs-prevbutton{
|
.introjs-prevbutton{
|
||||||
@apply bg-transparent border border-midnightBlueTheme-buttonColor text-midnightBlueTheme-buttonColor !important;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
|
|||||||
import BaseIcon from "../components/BaseIcon";
|
import BaseIcon from "../components/BaseIcon";
|
||||||
import { getPageTitle } from '../config'
|
import { getPageTitle } from '../config'
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
|
||||||
import { hasPermission } from "../helpers/userPermissions";
|
import { hasPermission } from "../helpers/userPermissions";
|
||||||
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||||
@ -16,6 +17,13 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
|||||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
type JourneyContext = {
|
||||||
|
roleId: string;
|
||||||
|
roleTitle: string;
|
||||||
|
city: string;
|
||||||
|
contractor: string;
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
@ -39,6 +47,7 @@ const Dashboard = () => {
|
|||||||
});
|
});
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||||
|
const [journeyContext, setJourneyContext] = React.useState<JourneyContext | null>(null);
|
||||||
|
|
||||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
||||||
|
|
||||||
@ -82,6 +91,22 @@ const Dashboard = () => {
|
|||||||
if (!currentUser || !widgetsRole?.role?.value) return;
|
if (!currentUser || !widgetsRole?.role?.value) return;
|
||||||
getWidgets(widgetsRole?.role?.value || '').then();
|
getWidgets(widgetsRole?.role?.value || '').then();
|
||||||
}, [widgetsRole?.role?.value]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -97,6 +122,45 @@ const Dashboard = () => {
|
|||||||
main>
|
main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</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
|
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
|||||||
@ -1,166 +1,740 @@
|
|||||||
|
import React from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
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 BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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() {
|
type RoleCard = {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
id: string;
|
||||||
src: undefined,
|
title: string;
|
||||||
photographer: undefined,
|
caption: string;
|
||||||
photographer_url: undefined,
|
description: string;
|
||||||
})
|
accent: string;
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
};
|
||||||
const [contentType, setContentType] = useState('image');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'Кадровый суверенитет Дороги'
|
type JourneyContext = {
|
||||||
|
roleId: string;
|
||||||
|
roleTitle: string;
|
||||||
|
city: string;
|
||||||
|
contractor: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
const journeyStorageKey = 'roadTalentJourney';
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const palette = {
|
||||||
<div
|
navy: '#243B74',
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
midnight: '#1A2A52',
|
||||||
style={{
|
burgundy: '#56061D',
|
||||||
backgroundImage: `${
|
berry: '#7A2338',
|
||||||
image
|
paper: '#F5F2EC',
|
||||||
? `url(${image?.src?.original})`
|
};
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
const cityMarkers: CityMarker[] = [
|
||||||
backgroundSize: 'cover',
|
{
|
||||||
backgroundPosition: 'left center',
|
id: 'moscow',
|
||||||
backgroundRepeat: 'no-repeat',
|
name: 'Москва',
|
||||||
}}
|
district: 'Центральный кластер',
|
||||||
>
|
contractor: 'АО «ДорИнфраструктура»',
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
description:
|
||||||
<a
|
'Координирует набор инженерных команд, стажировки и быстрый вывод специалистов на федеральные дорожные объекты.',
|
||||||
className='text-[8px]'
|
specialization: 'Цифровое управление проектами и кадровый резерв',
|
||||||
href={image?.photographer_url}
|
top: '35%',
|
||||||
target='_blank'
|
left: '18%',
|
||||||
rel='noreferrer'
|
popupTop: '18%',
|
||||||
>
|
popupLeft: '23%',
|
||||||
Photo by {image?.photographer} on Pexels
|
},
|
||||||
</a>
|
{
|
||||||
</div>
|
id: 'saint-petersburg',
|
||||||
</div>
|
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 (roleId === 'college') {
|
||||||
if (video?.video_files?.length > 0) {
|
return (
|
||||||
return (
|
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
<circle cx='80' cy='34' r='16' stroke={stroke} strokeWidth='4' />
|
||||||
<video
|
<path d='M52 80c9-18 19-27 28-27 12 0 22 8 28 22' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
<path d='M63 82v42m34-42v42' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
autoPlay
|
<path d='M80 18l46 18-46 18-46-18 46-18z' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
|
||||||
loop
|
<path d='M124 39v28' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
muted
|
<path d='M42 118c12-10 25-15 38-15s26 5 38 15' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
>
|
</svg>
|
||||||
<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'>
|
if (roleId === 'contractor') {
|
||||||
<a
|
return (
|
||||||
className='text-[8px]'
|
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
|
||||||
href={video?.user?.url}
|
<circle cx='80' cy='34' r='16' stroke={stroke} strokeWidth='4' />
|
||||||
target='_blank'
|
<path d='M56 80c7-19 16-28 24-28 11 0 21 9 27 28' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
rel='noreferrer'
|
<path d='M62 82v38m34-38v38' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
>
|
<path d='M48 56l32-20 32 20' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
Video by {video.user.name} on Pexels
|
<path d='M48 122h64' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
</a>
|
<path d='M36 101h88' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
</div>
|
<path d='M28 101l8-23h88l8 23' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
|
||||||
</div>)
|
</svg>
|
||||||
}
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<svg viewBox='0 0 160 160' className='h-36 w-36' fill='none' aria-hidden='true'>
|
||||||
style={
|
<circle cx='80' cy='34' r='16' stroke={stroke} strokeWidth='4' />
|
||||||
contentPosition === 'background'
|
<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' />
|
||||||
backgroundImage: `${
|
<path d='M36 114h88' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
illustrationImage
|
<path d='M80 56v-16' stroke={stroke} strokeWidth='4' strokeLinecap='round' />
|
||||||
? `url(${illustrationImage.src?.original})`
|
<path d='M48 64l32-16 32 16' stroke={stroke} strokeWidth='4' strokeLinejoin='round' />
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
<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' />
|
||||||
backgroundSize: 'cover',
|
</svg>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,273 +1,336 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import Link from 'next/link';
|
||||||
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 { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
import { mdiArrowLeft, mdiEye, mdiEyeOff, mdiInformation, mdiMapMarker, mdiShieldAccount } from '@mdi/js';
|
||||||
import FormCheckRadio from '../components/FormCheckRadio';
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
import BaseButton from '../components/BaseButton';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
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 { getPageTitle } from '../config';
|
||||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import { useRouter } from 'next/router';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
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() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const notify = (type: 'success' | 'error', msg: string) => toast(msg, { type });
|
||||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
src: undefined,
|
const [journeyContext, setJourneyContext] = React.useState<JourneyContext | null>(null);
|
||||||
photographer: undefined,
|
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector((state) => state.auth);
|
||||||
photographer_url: undefined,
|
const [initialValues, setInitialValues] = React.useState({
|
||||||
})
|
email: 'admin@flatlogic.com',
|
||||||
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',
|
|
||||||
password: '26ac88a4',
|
password: '26ac88a4',
|
||||||
remember: true })
|
remember: true,
|
||||||
|
});
|
||||||
|
|
||||||
const title = 'Кадровый суверенитет Дороги'
|
React.useEffect(() => {
|
||||||
|
|
||||||
// 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(() => {
|
|
||||||
if (token) {
|
if (token) {
|
||||||
dispatch(findMe());
|
dispatch(findMe());
|
||||||
}
|
}
|
||||||
}, [token, dispatch]);
|
}, [token, dispatch]);
|
||||||
// Redirect to dashboard if user is logged in
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentUser?.id) {
|
if (currentUser?.id) {
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
}
|
}
|
||||||
}, [currentUser?.id, router]);
|
}, [currentUser?.id, router]);
|
||||||
// Show error message if there is one
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (errorMessage){
|
if (errorMessage) {
|
||||||
notify('error', 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])
|
const roleFromQuery = typeof router.query.role === 'string' ? router.query.role : '';
|
||||||
// Show notification if there is one
|
const cityFromQuery = typeof router.query.city === 'string' ? router.query.city : '';
|
||||||
useEffect(() => {
|
const contractorFromQuery = typeof router.query.contractor === 'string' ? router.query.contractor : '';
|
||||||
if (notifyState?.showNotification) {
|
|
||||||
notify('success', notifyState?.textNotification)
|
|
||||||
dispatch(resetAction());
|
|
||||||
}
|
|
||||||
}, [notifyState?.showNotification])
|
|
||||||
|
|
||||||
const togglePasswordVisibility = () => {
|
if (roleFromQuery || cityFromQuery || contractorFromQuery) {
|
||||||
setShowPassword(!showPassword);
|
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 storedJourney = localStorage.getItem(journeyStorageKey);
|
||||||
const {remember, ...rest} = value
|
|
||||||
|
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));
|
await dispatch(loginUser(rest));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLogin = (target: HTMLElement) => {
|
const setLogin = (target: HTMLElement) => {
|
||||||
setInitialValues(prev => ({
|
setInitialValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email : target.innerText.trim(),
|
email: target.innerText.trim(),
|
||||||
password: target.dataset.password ?? '',
|
password: target.dataset.password ?? '',
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const title = 'Кадровый суверенитет Дороги';
|
||||||
<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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<>
|
||||||
backgroundImage: `${
|
<Head>
|
||||||
illustrationImage
|
<title>{getPageTitle('Login')}</title>
|
||||||
? `url(${illustrationImage.src?.original})`
|
</Head>
|
||||||
: '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>
|
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<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={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<div className='mx-auto flex min-h-[calc(100vh-2rem)] max-w-7xl flex-col gap-6'>
|
||||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
<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'>
|
||||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
<div>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<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>
|
||||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
<Link
|
||||||
|
href='/'
|
||||||
<div className='flex flex-row justify-between'>
|
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'
|
||||||
<div>
|
>
|
||||||
|
<BaseIcon path={mdiArrowLeft} size={18} />
|
||||||
<p className='mb-2'>Use{' '}
|
Назад к карте
|
||||||
<code className={`cursor-pointer ${textColor} `}
|
</Link>
|
||||||
data-password="26ac88a4"
|
<BaseButton
|
||||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
href='/dashboard'
|
||||||
<code className={`${textColor}`}>26ac88a4</code>{' / '}
|
label='Если уже вошли — в кабинет'
|
||||||
to login as Admin</p>
|
color='white'
|
||||||
<p>Use <code
|
outline
|
||||||
className={`cursor-pointer ${textColor} `}
|
className='border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/10'
|
||||||
data-password="779ee45a8363"
|
/>
|
||||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
</div>
|
||||||
<code className={`${textColor}`}>779ee45a8363</code>{' / '}
|
</header>
|
||||||
to login as User</p>
|
|
||||||
</div>
|
<div className='grid flex-1 gap-6 lg:grid-cols-[0.92fr_1.08fr]'>
|
||||||
<div>
|
<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'>
|
||||||
<BaseIcon
|
<p className='text-xs uppercase tracking-[0.3em] text-white/55'>контекст маршрута</p>
|
||||||
className={`${iconsColor}`}
|
<h2 className='mt-2 text-3xl font-semibold'>Путь пользователя сохраняется до входа</h2>
|
||||||
w='w-16'
|
<p className='mt-3 max-w-xl text-sm leading-7 text-white/70'>
|
||||||
h='h-16'
|
Мы переносим выбранные на лендинге город, подрядчика и роль в экран авторизации, чтобы вход ощущался частью цельного сценария, а не отдельной формой.
|
||||||
size={48}
|
</p>
|
||||||
path={mdiInformation}
|
|
||||||
/>
|
<div className='mt-6 space-y-4'>
|
||||||
</div>
|
{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>
|
||||||
</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='relative'>
|
<div className='mt-5 grid gap-3 md:grid-cols-2'>
|
||||||
<FormField
|
<div className='rounded-[22px] bg-white p-4'>
|
||||||
label='Password'
|
<p className='text-xs uppercase tracking-[0.24em] text-[#1A2A52]/45'>Город</p>
|
||||||
help='Please enter your password'>
|
<p className='mt-2 text-base font-semibold'>{journeyContext.city || 'Не выбран'}</p>
|
||||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
</div>
|
||||||
</FormField>
|
<div className='rounded-[22px] bg-white p-4'>
|
||||||
<div
|
<p className='text-xs uppercase tracking-[0.24em] text-[#1A2A52]/45'>Подрядчик</p>
|
||||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
<p className='mt-2 text-base font-semibold'>{journeyContext.contractor || 'Не выбран'}</p>
|
||||||
onClick={togglePasswordVisibility}
|
</div>
|
||||||
>
|
</div>
|
||||||
<BaseIcon
|
|
||||||
className='text-gray-500 hover:text-gray-700'
|
|
||||||
size={20}
|
|
||||||
path={showPassword ? mdiEyeOff : mdiEye}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={'flex justify-between'}>
|
<p className='mt-4 text-sm leading-7 text-[#1A2A52]/78'>
|
||||||
<FormCheckRadio type='checkbox' label='Remember'>
|
После авторизации этот контекст появится на обзорной странице и поможет быстро перейти к справочникам городов и подрядчиков в админ-интерфейсе.
|
||||||
<Field type='checkbox' name='remember' />
|
</p>
|
||||||
</FormCheckRadio>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
<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'>
|
||||||
Forgot password?
|
<div className='flex items-start gap-4'>
|
||||||
</Link>
|
<div className='rounded-2xl bg-white/10 p-3'>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<BaseDivider />
|
<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>
|
||||||
|
|
||||||
<BaseButtons>
|
<div className='mt-5 grid gap-4 rounded-[24px] bg-white p-5 md:grid-cols-2'>
|
||||||
<BaseButton
|
<div>
|
||||||
className={'w-full'}
|
<p className='text-sm font-medium text-[#243B74]'>Администратор</p>
|
||||||
type='submit'
|
<p className='mt-2 text-sm leading-7 text-[#1A2A52]/72'>
|
||||||
label={isFetching ? 'Loading...' : 'Login'}
|
<code
|
||||||
color='info'
|
className={`cursor-pointer rounded-md bg-[#F5F2EC] px-2 py-1 ${textColor}`}
|
||||||
disabled={isFetching}
|
data-password='26ac88a4'
|
||||||
/>
|
onClick={(event) => setLogin(event.target as HTMLElement)}
|
||||||
</BaseButtons>
|
>
|
||||||
<br />
|
admin@flatlogic.com
|
||||||
<p className={'text-center'}>
|
</code>{' '}
|
||||||
Don’t have an account yet?{' '}
|
/ <code className={textColor}>26ac88a4</code>
|
||||||
<Link className={`${textColor}`} href={'/register'}>
|
</p>
|
||||||
New Account
|
</div>
|
||||||
</Link>
|
<div>
|
||||||
</p>
|
<p className='text-sm font-medium text-[#243B74]'>Пользователь</p>
|
||||||
</Form>
|
<p className='mt-2 text-sm leading-7 text-[#1A2A52]/72'>
|
||||||
</Formik>
|
<code
|
||||||
</CardBox>
|
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>
|
</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>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user