Refactor Dashboard component to implement a structured routing system with dedicated pages for workplace input, login, chain selection, task management, and completion. Introduce centralized localStorage management for user data and navigation logic, enhancing user experience and streamlining the application flow. Remove the deprecated LoginForm component and update the MainPage to redirect users based on their authentication and task status.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
This commit is contained in:
@@ -17,6 +17,14 @@ const getNavPath = (key: string, fallback: string) => {
|
|||||||
|
|
||||||
export const URLs = {
|
export const URLs = {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
// Основные маршруты
|
||||||
|
workplace: makeUrl('/workplace'),
|
||||||
|
login: makeUrl('/login'),
|
||||||
|
chains: makeUrl('/chains'),
|
||||||
|
chain: (chainId: string) => makeUrl(`/chain/${chainId}`),
|
||||||
|
task: (chainId: string, taskId: string) => makeUrl(`/chain/${chainId}/task/${taskId}`),
|
||||||
|
completed: (chainId: string) => makeUrl(`/completed/${chainId}`),
|
||||||
|
// Старые маршруты
|
||||||
auth: {
|
auth: {
|
||||||
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
||||||
isOn: Boolean(navs[`link.${pkg.name}.auth`]),
|
isOn: Boolean(navs[`link.${pkg.name}.auth`]),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
|
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
import { useChallenge } from '../context/ChallengeContext'
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
import { URLs } from '../__data__/urls'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
chainName?: string
|
chainName?: string
|
||||||
@@ -9,8 +11,14 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { nickname, workplaceNumber, logout } = useChallenge()
|
const { nickname, workplaceNumber, logout } = useChallenge()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate(URLs.workplace)
|
||||||
|
}
|
||||||
|
|
||||||
if (!nickname) return null
|
if (!nickname) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +50,7 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
<Button onClick={logout} variant="ghost" size="sm">
|
<Button onClick={handleLogout} variant="ghost" size="sm">
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Heading,
|
|
||||||
Input,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
|
|
||||||
import { useChallenge } from '../context/ChallengeContext'
|
|
||||||
|
|
||||||
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
|
||||||
|
|
||||||
export const LoginForm = () => {
|
|
||||||
const [step, setStep] = useState<'workplace' | 'fio'>('workplace')
|
|
||||||
const [workplaceNumber, setWorkplaceNumber] = useState('')
|
|
||||||
const [fullName, setFullName] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const { login, isAuthLoading } = useChallenge()
|
|
||||||
|
|
||||||
// Проверяем, есть ли сохранённый номер рабочего места
|
|
||||||
useEffect(() => {
|
|
||||||
const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY)
|
|
||||||
if (savedWorkplace) {
|
|
||||||
setWorkplaceNumber(savedWorkplace)
|
|
||||||
setStep('fio')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleWorkplaceSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const trimmedWorkplace = workplaceNumber.trim()
|
|
||||||
if (!trimmedWorkplace) {
|
|
||||||
setError('Пожалуйста, введите номер рабочего места')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем номер рабочего места сразу
|
|
||||||
localStorage.setItem(WORKPLACE_NUMBER_KEY, trimmedWorkplace)
|
|
||||||
setWorkplaceNumber(trimmedWorkplace)
|
|
||||||
setError('')
|
|
||||||
setStep('fio')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFioSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const trimmedName = fullName.trim()
|
|
||||||
if (!trimmedName) {
|
|
||||||
setError('Пожалуйста, введите ваше ФИО')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedName.length < 3) {
|
|
||||||
setError('ФИО должно содержать минимум 3 символа')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError('')
|
|
||||||
await login(trimmedName, workplaceNumber.trim())
|
|
||||||
} catch (err) {
|
|
||||||
setError('Произошла ошибка при входе. Попробуйте снова.')
|
|
||||||
console.error('Login error:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === 'workplace') {
|
|
||||||
return (
|
|
||||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
|
||||||
<Box
|
|
||||||
bg="white"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderRadius="lg"
|
|
||||||
borderColor="gray.200"
|
|
||||||
p={8}
|
|
||||||
maxW="480px"
|
|
||||||
w="full"
|
|
||||||
>
|
|
||||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
|
||||||
<VStack spacing={6} align="stretch">
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Heading size="lg" color="teal.600" mb={2}>
|
|
||||||
Challenge Platform
|
|
||||||
</Heading>
|
|
||||||
<Text color="gray.600">
|
|
||||||
Добро пожаловать! Введите номер рабочего места
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<form onSubmit={handleWorkplaceSubmit}>
|
|
||||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
|
||||||
<VStack spacing={4} align="stretch">
|
|
||||||
<Box>
|
|
||||||
<Text fontWeight="medium" mb={2}>
|
|
||||||
Номер рабочего места
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
value={workplaceNumber}
|
|
||||||
onChange={(e) => setWorkplaceNumber(e.target.value)}
|
|
||||||
placeholder="Например: 1"
|
|
||||||
size="lg"
|
|
||||||
autoFocus
|
|
||||||
// @ts-expect-error Chakra UI v2 uses isInvalid
|
|
||||||
isInvalid={!!error}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Text color="red.500" fontSize="sm" mt={2}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
colorScheme="teal"
|
|
||||||
size="lg"
|
|
||||||
// @ts-expect-error Chakra UI v2 uses isDisabled
|
|
||||||
isDisabled={!workplaceNumber.trim()}
|
|
||||||
>
|
|
||||||
Продолжить
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</form>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
|
||||||
<Box
|
|
||||||
bg="white"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderRadius="lg"
|
|
||||||
borderColor="gray.200"
|
|
||||||
p={8}
|
|
||||||
maxW="480px"
|
|
||||||
w="full"
|
|
||||||
>
|
|
||||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
|
||||||
<VStack spacing={6} align="stretch">
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Heading size="lg" color="teal.600" mb={2}>
|
|
||||||
Challenge Platform
|
|
||||||
</Heading>
|
|
||||||
<Text color="gray.600" fontWeight="medium">
|
|
||||||
Рабочее место: №{workplaceNumber}
|
|
||||||
</Text>
|
|
||||||
<Text color="gray.500" fontSize="sm" mt={1}>
|
|
||||||
(номер сохранён)
|
|
||||||
</Text>
|
|
||||||
<Text color="gray.600" mt={3}>
|
|
||||||
Введите ваше ФИО для начала работы
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<form onSubmit={handleFioSubmit}>
|
|
||||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
|
||||||
<VStack spacing={4} align="stretch">
|
|
||||||
<Box>
|
|
||||||
<Text fontWeight="medium" mb={2}>
|
|
||||||
Ваше ФИО
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
|
||||||
placeholder="Иванов Иван Иванович"
|
|
||||||
size="lg"
|
|
||||||
autoFocus
|
|
||||||
// @ts-expect-error Chakra UI v2 uses isInvalid
|
|
||||||
isInvalid={!!error}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<Text color="red.500" fontSize="sm" mt={2}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
|
||||||
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
|
|
||||||
Войти
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setStep('workplace')
|
|
||||||
setError('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Изменить рабочее место
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</form>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,7 @@ import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../ut
|
|||||||
import { ChallengeEventEmitter } from '../utils/events'
|
import { ChallengeEventEmitter } from '../utils/events'
|
||||||
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
|
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
|
||||||
import { PollingManager } from '../utils/polling'
|
import { PollingManager } from '../utils/polling'
|
||||||
|
import { storage } from '../utils/storage'
|
||||||
const isBrowser = () => typeof window !== 'undefined'
|
|
||||||
|
|
||||||
class ChallengeCache {
|
class ChallengeCache {
|
||||||
private cache = new Map<string, { data: unknown; expires: number }>()
|
private cache = new Map<string, { data: unknown; expires: number }>()
|
||||||
@@ -77,10 +76,6 @@ interface ChallengeContextValue {
|
|||||||
|
|
||||||
const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefined)
|
const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefined)
|
||||||
|
|
||||||
const USER_ID_KEY = 'challengeUserId'
|
|
||||||
const USER_NICKNAME_KEY = 'challengeNickname'
|
|
||||||
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
|
||||||
|
|
||||||
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||||
const cacheRef = useRef(new ChallengeCache())
|
const cacheRef = useRef(new ChallengeCache())
|
||||||
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
||||||
@@ -88,15 +83,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
|
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
|
||||||
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), [])
|
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), [])
|
||||||
|
|
||||||
const [userId, setUserId] = useState<string | null>(() =>
|
const [userId, setUserId] = useState<string | null>(() => storage.getUserId())
|
||||||
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
|
const [nickname, setNickname] = useState<string | null>(() => storage.getNickname())
|
||||||
)
|
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() => storage.getWorkplaceNumber())
|
||||||
const [nickname, setNickname] = useState<string | null>(() =>
|
|
||||||
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
|
|
||||||
)
|
|
||||||
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() =>
|
|
||||||
isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null,
|
|
||||||
)
|
|
||||||
const [stats, setStats] = useState<UserStats | null>(null)
|
const [stats, setStats] = useState<UserStats | null>(null)
|
||||||
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
||||||
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
||||||
@@ -162,11 +151,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setNickname(nicknameValue)
|
setNickname(nicknameValue)
|
||||||
setWorkplaceNumber(workplaceNumberValue)
|
setWorkplaceNumber(workplaceNumberValue)
|
||||||
|
|
||||||
if (isBrowser()) {
|
storage.setUserId(response.userId)
|
||||||
window.localStorage.setItem(USER_ID_KEY, response.userId)
|
storage.setNickname(nicknameValue)
|
||||||
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
|
storage.setWorkplaceNumber(workplaceNumberValue)
|
||||||
window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheRef.current.clear('chains')
|
cacheRef.current.clear('chains')
|
||||||
await refreshStatsById(response.userId)
|
await refreshStatsById(response.userId)
|
||||||
@@ -182,13 +169,8 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setPersonalDashboard(null)
|
setPersonalDashboard(null)
|
||||||
cacheRef.current.clear()
|
cacheRef.current.clear()
|
||||||
|
|
||||||
if (isBrowser()) {
|
// Очищаем всё из localStorage
|
||||||
window.localStorage.removeItem(USER_ID_KEY)
|
storage.clearAll()
|
||||||
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
|
||||||
window.localStorage.removeItem(WORKPLACE_NUMBER_KEY)
|
|
||||||
window.localStorage.removeItem('challengeSelectedChainId')
|
|
||||||
window.localStorage.removeItem('challengeSelectedTaskId')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
|
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
|
||||||
|
|||||||
@@ -1,24 +1,107 @@
|
|||||||
import React, { Suspense } from 'react'
|
import React, { Suspense } from 'react'
|
||||||
import { Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import { URLs } from './__data__/urls'
|
import { URLs } from './__data__/urls'
|
||||||
import { MainPage } from './pages'
|
import {
|
||||||
|
WorkplacePage,
|
||||||
|
LoginPage,
|
||||||
|
ChainsPage,
|
||||||
|
TaskPage,
|
||||||
|
CompletedPage
|
||||||
|
} from './pages'
|
||||||
|
import { storage } from './utils/storage'
|
||||||
|
|
||||||
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Компонент для редиректа на нужную страницу
|
||||||
|
const IndexRedirect = () => {
|
||||||
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
|
const nickname = storage.getNickname()
|
||||||
|
const chainId = storage.getSelectedChainId()
|
||||||
|
const taskId = storage.getSelectedTaskId()
|
||||||
|
|
||||||
|
// Если есть сохранённое задание - туда
|
||||||
|
if (nickname && chainId && taskId) {
|
||||||
|
return <Navigate to={URLs.task(chainId, taskId)} replace />
|
||||||
|
}
|
||||||
|
// Если авторизован - к цепочкам
|
||||||
|
if (nickname) {
|
||||||
|
return <Navigate to={URLs.chains} replace />
|
||||||
|
}
|
||||||
|
// Если есть номер места - к логину
|
||||||
|
if (workplaceNumber) {
|
||||||
|
return <Navigate to={URLs.login} replace />
|
||||||
|
}
|
||||||
|
// Иначе - к вводу места
|
||||||
|
return <Navigate to={URLs.workplace} replace />
|
||||||
|
}
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Главная - редирект */}
|
||||||
<Route
|
<Route
|
||||||
path={URLs.baseUrl}
|
path={URLs.baseUrl}
|
||||||
|
element={<IndexRedirect />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ввод номера рабочего места */}
|
||||||
|
<Route
|
||||||
|
path={URLs.workplace}
|
||||||
element={
|
element={
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
<MainPage />
|
<WorkplacePage />
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Ввод ФИО */}
|
||||||
|
<Route
|
||||||
|
path={URLs.login}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<LoginPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Выбор цепочки */}
|
||||||
|
<Route
|
||||||
|
path={URLs.chains}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<ChainsPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Задание */}
|
||||||
|
<Route
|
||||||
|
path={`${URLs.baseUrl}/chain/:chainId/task/:taskId`}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<TaskPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Завершение цепочки */}
|
||||||
|
<Route
|
||||||
|
path={`${URLs.baseUrl}/completed/:chainId`}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<CompletedPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<IndexRedirect />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/pages/chains/ChainsPage.tsx
Normal file
47
src/pages/chains/ChainsPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { Header } from '../../components/Header'
|
||||||
|
import { ChainSelector } from '../../components/ChainSelector'
|
||||||
|
import { storage } from '../../utils/storage'
|
||||||
|
import type { ChallengeChain } from '../../__data__/types'
|
||||||
|
|
||||||
|
export const ChainsPage = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { nickname } = useChallenge()
|
||||||
|
|
||||||
|
// Проверяем авторизацию
|
||||||
|
useEffect(() => {
|
||||||
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
|
if (!workplaceNumber) {
|
||||||
|
navigate(URLs.workplace, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nickname) {
|
||||||
|
navigate(URLs.login, { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, nickname])
|
||||||
|
|
||||||
|
const handleSelectChain = (chain: ChallengeChain) => {
|
||||||
|
storage.setSelectedChainId(chain.id)
|
||||||
|
// Переходим к первому заданию цепочки
|
||||||
|
if (chain.tasks.length > 0) {
|
||||||
|
storage.setSelectedTaskId(chain.tasks[0].id)
|
||||||
|
navigate(URLs.task(chain.id, chain.tasks[0].id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nickname) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<ChainSelector onSelectChain={handleSelectChain} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/chains/index.ts
Normal file
4
src/pages/chains/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { ChainsPage } from './ChainsPage'
|
||||||
|
|
||||||
|
export default ChainsPage
|
||||||
|
|
||||||
99
src/pages/completed/CompletedPage.tsx
Normal file
99
src/pages/completed/CompletedPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { Header } from '../../components/Header'
|
||||||
|
import { storage } from '../../utils/storage'
|
||||||
|
|
||||||
|
export const CompletedPage = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { chainId } = useParams<{ chainId: string }>()
|
||||||
|
const { nickname, chains } = useChallenge()
|
||||||
|
|
||||||
|
// Проверяем авторизацию
|
||||||
|
useEffect(() => {
|
||||||
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
|
if (!workplaceNumber) {
|
||||||
|
navigate(URLs.workplace, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nickname) {
|
||||||
|
navigate(URLs.login, { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, nickname])
|
||||||
|
|
||||||
|
const chain = useMemo(() => {
|
||||||
|
return chains.find(c => c.id === chainId) || null
|
||||||
|
}, [chains, chainId])
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
navigate(URLs.chains)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nickname) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="2px"
|
||||||
|
borderRadius="xl"
|
||||||
|
borderColor="green.300"
|
||||||
|
p={10}
|
||||||
|
maxW="600px"
|
||||||
|
w="full"
|
||||||
|
textAlign="center"
|
||||||
|
shadow="lg"
|
||||||
|
>
|
||||||
|
<VStack gap={6}>
|
||||||
|
<Text fontSize="6xl">🎉</Text>
|
||||||
|
<Heading size="xl" color="green.600">
|
||||||
|
Поздравляем!
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize="lg" color="gray.700">
|
||||||
|
Вы успешно выполнили все задания
|
||||||
|
</Text>
|
||||||
|
{chain && (
|
||||||
|
<Box
|
||||||
|
bg="green.50"
|
||||||
|
borderRadius="lg"
|
||||||
|
px={6}
|
||||||
|
py={3}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="green.200"
|
||||||
|
>
|
||||||
|
<Text fontSize="xl" fontWeight="bold" color="green.700">
|
||||||
|
{chain.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Text fontSize="md" color="gray.600">
|
||||||
|
Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
colorScheme="green"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleContinue}
|
||||||
|
mt={4}
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/completed/index.ts
Normal file
4
src/pages/completed/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { CompletedPage } from './CompletedPage'
|
||||||
|
|
||||||
|
export default CompletedPage
|
||||||
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
import { lazy } from 'react'
|
import { lazy } from 'react'
|
||||||
|
|
||||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||||
|
export const WorkplacePage = lazy(() => import(/* webpackChunkName: 'workplace' */'./workplace'))
|
||||||
|
export const LoginPage = lazy(() => import(/* webpackChunkName: 'login' */'./login'))
|
||||||
|
export const ChainsPage = lazy(() => import(/* webpackChunkName: 'chains' */'./chains'))
|
||||||
|
export const TaskPage = lazy(() => import(/* webpackChunkName: 'task' */'./task'))
|
||||||
|
export const CompletedPage = lazy(() => import(/* webpackChunkName: 'completed' */'./completed'))
|
||||||
141
src/pages/login/LoginPage.tsx
Normal file
141
src/pages/login/LoginPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Heading,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { storage } from '../../utils/storage'
|
||||||
|
|
||||||
|
export const LoginPage = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login, isAuthLoading, nickname } = useChallenge()
|
||||||
|
const [fullName, setFullName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
|
|
||||||
|
// Если нет номера рабочего места, возвращаемся
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workplaceNumber) {
|
||||||
|
navigate(URLs.workplace, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Если уже авторизован, переходим к цепочкам
|
||||||
|
if (nickname) {
|
||||||
|
navigate(URLs.chains, { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, workplaceNumber, nickname])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedName = fullName.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('Пожалуйста, введите ваше ФИО')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.length < 3) {
|
||||||
|
setError('ФИО должно содержать минимум 3 символа')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
await login(trimmedName, workplaceNumber || '')
|
||||||
|
navigate(URLs.chains)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Произошла ошибка при входе. Попробуйте снова.')
|
||||||
|
console.error('Login error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangeWorkplace = () => {
|
||||||
|
storage.removeWorkplaceNumber()
|
||||||
|
navigate(URLs.workplace)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workplaceNumber) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={8}
|
||||||
|
maxW="480px"
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Heading size="lg" color="teal.600" mb={2}>
|
||||||
|
Challenge Platform
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600" fontWeight="medium">
|
||||||
|
Рабочее место: №{workplaceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray.600" mt={3}>
|
||||||
|
Введите ваше ФИО для начала работы
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="medium" mb={2}>
|
||||||
|
Ваше ФИО
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
placeholder="Иванов Иван Иванович"
|
||||||
|
size="lg"
|
||||||
|
autoFocus
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||||
|
isInvalid={!!error}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text color="red.500" fontSize="sm" mt={2}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
||||||
|
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Text
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
color="gray.500"
|
||||||
|
fontSize="sm"
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{ color: 'teal.600', textDecoration: 'underline' }}
|
||||||
|
onClick={handleChangeWorkplace}
|
||||||
|
>
|
||||||
|
Изменить рабочее место
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/login/index.ts
Normal file
4
src/pages/login/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { LoginPage } from './LoginPage'
|
||||||
|
|
||||||
|
export default LoginPage
|
||||||
|
|
||||||
@@ -1,190 +1,36 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React from 'react'
|
||||||
import {
|
import { Navigate } from 'react-router-dom'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Heading,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { Alert } from '@chakra-ui/react/alert'
|
|
||||||
|
|
||||||
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
import { URLs } from '../../__data__/urls'
|
||||||
import { useChallenge } from '../../context/ChallengeContext'
|
import { storage } from '../../utils/storage'
|
||||||
import { TaskWorkspace } from '../../components/personal'
|
|
||||||
import { Header } from '../../components/Header'
|
|
||||||
import { LoginForm } from '../../components/LoginForm'
|
|
||||||
import { ChainSelector } from '../../components/ChainSelector'
|
|
||||||
|
|
||||||
const SELECTED_CHAIN_KEY = 'challengeSelectedChainId'
|
|
||||||
const SELECTED_TASK_KEY = 'challengeSelectedTaskId'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MainPage теперь просто редиректит на нужную страницу
|
||||||
|
* Вся логика навигации находится в отдельных страницах:
|
||||||
|
* - /workplace - ввод номера рабочего места
|
||||||
|
* - /login - ввод ФИО
|
||||||
|
* - /chains - выбор цепочки
|
||||||
|
* - /chain/:chainId/task/:taskId - задание
|
||||||
|
* - /completed/:chainId - завершение цепочки
|
||||||
|
*/
|
||||||
export const MainPage = () => {
|
export const MainPage = () => {
|
||||||
const { nickname, chains } = useChallenge()
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
|
const nickname = storage.getNickname()
|
||||||
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
|
const chainId = storage.getSelectedChainId()
|
||||||
const [completedChainName, setCompletedChainName] = useState<string | null>(null)
|
const taskId = storage.getSelectedTaskId()
|
||||||
const [isOffline, setIsOffline] = useState(() =>
|
|
||||||
typeof navigator !== 'undefined' ? !navigator.onLine : false,
|
|
||||||
)
|
|
||||||
const hasRestoredState = useRef(false)
|
|
||||||
|
|
||||||
// Восстановление состояния при загрузке
|
// Если есть сохранённое задание - туда
|
||||||
useEffect(() => {
|
if (nickname && chainId && taskId) {
|
||||||
if (hasRestoredState.current || !chains.length || !nickname) return
|
return <Navigate to={URLs.task(chainId, taskId)} replace />
|
||||||
|
|
||||||
const savedChainId = localStorage.getItem(SELECTED_CHAIN_KEY)
|
|
||||||
const savedTaskId = localStorage.getItem(SELECTED_TASK_KEY)
|
|
||||||
|
|
||||||
if (savedChainId) {
|
|
||||||
const chain = chains.find(c => c.id === savedChainId)
|
|
||||||
if (chain) {
|
|
||||||
setSelectedChain(chain)
|
|
||||||
|
|
||||||
if (savedTaskId) {
|
|
||||||
const task = chain.tasks.find(t => t.id === savedTaskId)
|
|
||||||
setSelectedTask(task || chain.tasks[0])
|
|
||||||
} else {
|
|
||||||
setSelectedTask(chain.tasks[0])
|
|
||||||
}
|
}
|
||||||
|
// Если авторизован - к цепочкам
|
||||||
|
if (nickname) {
|
||||||
|
return <Navigate to={URLs.chains} replace />
|
||||||
}
|
}
|
||||||
|
// Если есть номер места - к логину
|
||||||
|
if (workplaceNumber) {
|
||||||
|
return <Navigate to={URLs.login} replace />
|
||||||
}
|
}
|
||||||
|
// Иначе - к вводу места
|
||||||
hasRestoredState.current = true
|
return <Navigate to={URLs.workplace} replace />
|
||||||
}, [chains, nickname])
|
|
||||||
|
|
||||||
const handleSelectChain = (chain: ChallengeChain) => {
|
|
||||||
setSelectedChain(chain)
|
|
||||||
setSelectedTask(chain.tasks[0])
|
|
||||||
localStorage.setItem(SELECTED_CHAIN_KEY, chain.id)
|
|
||||||
localStorage.setItem(SELECTED_TASK_KEY, chain.tasks[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTaskComplete = () => {
|
|
||||||
if (!selectedChain) return
|
|
||||||
const currentIndex = selectedChain.tasks.findIndex((item) => item.id === selectedTask?.id)
|
|
||||||
const nextTask = currentIndex >= 0 ? selectedChain.tasks[currentIndex + 1] : null
|
|
||||||
|
|
||||||
if (nextTask) {
|
|
||||||
setSelectedTask(nextTask)
|
|
||||||
localStorage.setItem(SELECTED_TASK_KEY, nextTask.id)
|
|
||||||
} else {
|
|
||||||
// Цепочка завершена - показываем экран поздравления
|
|
||||||
setCompletedChainName(selectedChain.name)
|
|
||||||
setSelectedChain(null)
|
|
||||||
setSelectedTask(null)
|
|
||||||
localStorage.removeItem(SELECTED_CHAIN_KEY)
|
|
||||||
localStorage.removeItem(SELECTED_TASK_KEY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContinueAfterCompletion = () => {
|
|
||||||
setCompletedChainName(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleOnline = () => setIsOffline(false)
|
|
||||||
const handleOffline = () => setIsOffline(true)
|
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline)
|
|
||||||
window.addEventListener('offline', handleOffline)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('online', handleOnline)
|
|
||||||
window.removeEventListener('offline', handleOffline)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Если пользователь не авторизован, показываем форму входа
|
|
||||||
if (!nickname) {
|
|
||||||
return <LoginForm />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если цепочка завершена, показываем экран поздравления
|
|
||||||
if (completedChainName) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
|
||||||
<Box
|
|
||||||
bg="white"
|
|
||||||
borderWidth="2px"
|
|
||||||
borderRadius="xl"
|
|
||||||
borderColor="green.300"
|
|
||||||
p={10}
|
|
||||||
maxW="600px"
|
|
||||||
w="full"
|
|
||||||
textAlign="center"
|
|
||||||
shadow="lg"
|
|
||||||
>
|
|
||||||
<VStack gap={6}>
|
|
||||||
<Text fontSize="6xl">🎉</Text>
|
|
||||||
<Heading size="xl" color="green.600">
|
|
||||||
Поздравляем!
|
|
||||||
</Heading>
|
|
||||||
<Text fontSize="lg" color="gray.700">
|
|
||||||
Вы успешно выполнили все задания
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
bg="green.50"
|
|
||||||
borderRadius="lg"
|
|
||||||
px={6}
|
|
||||||
py={3}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor="green.200"
|
|
||||||
>
|
|
||||||
<Text fontSize="xl" fontWeight="bold" color="green.700">
|
|
||||||
{completedChainName}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Text fontSize="md" color="gray.600">
|
|
||||||
Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
colorScheme="green"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleContinueAfterCompletion}
|
|
||||||
mt={4}
|
|
||||||
>
|
|
||||||
Продолжить
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если цепочка не выбрана, показываем селектор цепочек
|
|
||||||
if (!selectedChain) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
<ChainSelector onSelectChain={handleSelectChain} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskProgress = `Задание ${selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1}` // из ${selectedChain.tasks.length}`
|
|
||||||
|
|
||||||
// Показываем выбранную цепочку и задания
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header chainName={selectedChain.name} taskProgress={taskProgress} />
|
|
||||||
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
|
|
||||||
<Box maxW="1200px" mx="auto">
|
|
||||||
{isOffline && (
|
|
||||||
<Alert.Root status="warning" borderRadius="md" mb={4}>
|
|
||||||
<Alert.Indicator />
|
|
||||||
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
|
||||||
</Alert.Root>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTask && (
|
|
||||||
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/pages/task/TaskPage.tsx
Normal file
138
src/pages/task/TaskPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { Alert } from '@chakra-ui/react/alert'
|
||||||
|
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { TaskWorkspace } from '../../components/personal'
|
||||||
|
import { Header } from '../../components/Header'
|
||||||
|
import { storage } from '../../utils/storage'
|
||||||
|
|
||||||
|
export const TaskPage = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { chainId, taskId } = useParams<{ chainId: string; taskId: string }>()
|
||||||
|
const { nickname, chains } = useChallenge()
|
||||||
|
|
||||||
|
// Проверяем авторизацию
|
||||||
|
useEffect(() => {
|
||||||
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
|
if (!workplaceNumber) {
|
||||||
|
navigate(URLs.workplace, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nickname) {
|
||||||
|
navigate(URLs.login, { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, nickname])
|
||||||
|
|
||||||
|
// Находим цепочку и задание
|
||||||
|
const chain = useMemo(() => {
|
||||||
|
return chains.find(c => c.id === chainId) || null
|
||||||
|
}, [chains, chainId])
|
||||||
|
|
||||||
|
const task = useMemo(() => {
|
||||||
|
return chain?.tasks.find(t => t.id === taskId) || null
|
||||||
|
}, [chain, taskId])
|
||||||
|
|
||||||
|
const currentTaskIndex = useMemo(() => {
|
||||||
|
if (!chain || !taskId) return -1
|
||||||
|
return chain.tasks.findIndex(t => t.id === taskId)
|
||||||
|
}, [chain, taskId])
|
||||||
|
|
||||||
|
// Сохраняем текущее состояние в storage
|
||||||
|
useEffect(() => {
|
||||||
|
if (chainId && taskId) {
|
||||||
|
storage.setSelectedChainId(chainId)
|
||||||
|
storage.setSelectedTaskId(taskId)
|
||||||
|
}
|
||||||
|
}, [chainId, taskId])
|
||||||
|
|
||||||
|
const handleTaskComplete = () => {
|
||||||
|
if (!chain || currentTaskIndex === -1) return
|
||||||
|
|
||||||
|
const nextTask = chain.tasks[currentTaskIndex + 1]
|
||||||
|
if (nextTask) {
|
||||||
|
navigate(URLs.task(chain.id, nextTask.id))
|
||||||
|
} else {
|
||||||
|
// Цепочка завершена
|
||||||
|
storage.clearSessionData()
|
||||||
|
navigate(URLs.completed(chain.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavigateToTask = (newTaskId: string) => {
|
||||||
|
if (chain) {
|
||||||
|
navigate(URLs.task(chain.id, newTaskId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackToChains = () => {
|
||||||
|
storage.clearSessionData()
|
||||||
|
navigate(URLs.chains)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nickname || !chain || !task) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskProgress = `Задание ${currentTaskIndex + 1}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header chainName={chain.name} taskProgress={taskProgress} />
|
||||||
|
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
|
||||||
|
<Box maxW="1200px" mx="auto">
|
||||||
|
{/* Навигация по заданиям */}
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
borderRadius="md"
|
||||||
|
p={3}
|
||||||
|
mb={4}
|
||||||
|
shadow="sm"
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center" flexWrap="wrap" gap={3}>
|
||||||
|
<HStack gap={2} flexWrap="wrap">
|
||||||
|
<Text fontSize="sm" fontWeight="medium" color="gray.600" mr={2}>
|
||||||
|
Задания:
|
||||||
|
</Text>
|
||||||
|
{chain.tasks.map((t, index) => (
|
||||||
|
<Button
|
||||||
|
key={t.id}
|
||||||
|
size="sm"
|
||||||
|
variant={t.id === taskId ? 'solid' : 'outline'}
|
||||||
|
colorScheme={t.id === taskId ? 'teal' : 'gray'}
|
||||||
|
onClick={() => handleNavigateToTask(t.id)}
|
||||||
|
minW="40px"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme="gray"
|
||||||
|
onClick={handleBackToChains}
|
||||||
|
>
|
||||||
|
← К выбору цепочки
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TaskWorkspace task={task} onTaskComplete={handleTaskComplete} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/task/index.ts
Normal file
4
src/pages/task/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { TaskPage } from './TaskPage'
|
||||||
|
|
||||||
|
export default TaskPage
|
||||||
|
|
||||||
145
src/pages/workplace/WorkplacePage.tsx
Normal file
145
src/pages/workplace/WorkplacePage.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Heading,
|
||||||
|
Input,
|
||||||
|
Link,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { storage } from '../../utils/storage'
|
||||||
|
|
||||||
|
// Список полезных ссылок
|
||||||
|
const USEFUL_LINKS = [
|
||||||
|
{ url: 'https://ya.ru', label: 'Яндекс' },
|
||||||
|
{ url: 'https://giga.chat', label: 'GigaChat' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const WorkplacePage = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [workplaceNumber, setWorkplaceNumber] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Если номер уже есть, переходим дальше
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = storage.getWorkplaceNumber()
|
||||||
|
if (saved) {
|
||||||
|
// Проверяем, авторизован ли пользователь
|
||||||
|
const nickname = storage.getNickname()
|
||||||
|
if (nickname) {
|
||||||
|
navigate(URLs.chains, { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(URLs.login, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmed = workplaceNumber.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Пожалуйста, введите номер рабочего места')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.setWorkplaceNumber(trimmed)
|
||||||
|
navigate(URLs.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={8}
|
||||||
|
maxW="480px"
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Heading size="lg" color="teal.600" mb={2}>
|
||||||
|
Challenge Platform
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">
|
||||||
|
Добро пожаловать! Введите номер рабочего места
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="medium" mb={2}>
|
||||||
|
Номер рабочего места
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={workplaceNumber}
|
||||||
|
onChange={(e) => setWorkplaceNumber(e.target.value)}
|
||||||
|
placeholder="Например: 1"
|
||||||
|
size="lg"
|
||||||
|
autoFocus
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||||
|
isInvalid={!!error}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text color="red.500" fontSize="sm" mt={2}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Полезные ссылки */}
|
||||||
|
<Box
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="md"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={4}
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<Text fontWeight="medium" mb={3} fontSize="sm">
|
||||||
|
Полезные ссылки:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||||
|
<VStack spacing={2} align="stretch">
|
||||||
|
{USEFUL_LINKS.map(link => (
|
||||||
|
<Link
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
color="teal.600"
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="medium"
|
||||||
|
_hover={{ color: 'teal.800', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
🔗 {link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorScheme="teal"
|
||||||
|
size="lg"
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||||
|
isDisabled={!workplaceNumber.trim()}
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/workplace/index.ts
Normal file
4
src/pages/workplace/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { WorkplacePage } from './WorkplacePage'
|
||||||
|
|
||||||
|
export default WorkplacePage
|
||||||
|
|
||||||
113
src/utils/storage.ts
Normal file
113
src/utils/storage.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Централизованная работа с localStorage
|
||||||
|
* Все ключи и операции в одном месте
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isBrowser = () => typeof window !== 'undefined'
|
||||||
|
|
||||||
|
// Ключи localStorage
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
USER_ID: 'challengeUserId',
|
||||||
|
NICKNAME: 'challengeNickname',
|
||||||
|
WORKPLACE_NUMBER: 'challengeWorkplaceNumber',
|
||||||
|
SELECTED_CHAIN_ID: 'challengeSelectedChainId',
|
||||||
|
SELECTED_TASK_ID: 'challengeSelectedTaskId',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Получение значений
|
||||||
|
export const storage = {
|
||||||
|
getUserId: (): string | null => {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.USER_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
getNickname: (): string | null => {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.NICKNAME)
|
||||||
|
},
|
||||||
|
|
||||||
|
getWorkplaceNumber: (): string | null => {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.WORKPLACE_NUMBER)
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedChainId: (): string | null => {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedTaskId: (): string | null => {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Установка значений
|
||||||
|
setUserId: (value: string): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.setItem(STORAGE_KEYS.USER_ID, value)
|
||||||
|
},
|
||||||
|
|
||||||
|
setNickname: (value: string): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.setItem(STORAGE_KEYS.NICKNAME, value)
|
||||||
|
},
|
||||||
|
|
||||||
|
setWorkplaceNumber: (value: string): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.setItem(STORAGE_KEYS.WORKPLACE_NUMBER, value)
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedChainId: (value: string): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_CHAIN_ID, value)
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedTaskId: (value: string): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SELECTED_TASK_ID, value)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление значений
|
||||||
|
removeUserId: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.USER_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNickname: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.NICKNAME)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeWorkplaceNumber: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.WORKPLACE_NUMBER)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSelectedChainId: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSelectedTaskId: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Полная очистка при выходе
|
||||||
|
clearAll: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.USER_ID)
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.NICKNAME)
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.WORKPLACE_NUMBER)
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Очистка данных сессии (цепочка, задание) без выхода
|
||||||
|
clearSessionData: (): void => {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,38 @@
|
|||||||
"description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.",
|
"description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.",
|
||||||
"createdAt": "2024-09-05T11:30:00.000Z",
|
"createdAt": "2024-09-05T11:30:00.000Z",
|
||||||
"updatedAt": "2024-10-01T09:45:00.000Z"
|
"updatedAt": "2024-10-01T09:45:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-css-layout",
|
||||||
|
"_id": "task-css-layout",
|
||||||
|
"title": "CSS-верстка",
|
||||||
|
"description": "# CSS-верстка\n\nСверстайте карточку с заголовком, описанием и кнопкой. Используйте Flexbox и тени.",
|
||||||
|
"createdAt": "2024-09-10T14:00:00.000Z",
|
||||||
|
"updatedAt": "2024-10-05T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-js-dom",
|
||||||
|
"_id": "task-js-dom",
|
||||||
|
"title": "DOM-манипуляции",
|
||||||
|
"description": "# DOM-манипуляции\n\nНапишите скрипт, который добавляет элементы списка по клику на кнопку и очищает их по второй кнопке.",
|
||||||
|
"createdAt": "2024-09-15T10:20:00.000Z",
|
||||||
|
"updatedAt": "2024-10-07T12:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-react-state",
|
||||||
|
"_id": "task-react-state",
|
||||||
|
"title": "Состояние React",
|
||||||
|
"description": "# Состояние React\n\nСоздайте компонент, который показывает счётчик кликов и два кнопки — увеличить/сбросить. Используйте хуки.",
|
||||||
|
"createdAt": "2024-09-20T09:15:00.000Z",
|
||||||
|
"updatedAt": "2024-10-08T08:45:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-api-fetch",
|
||||||
|
"_id": "task-api-fetch",
|
||||||
|
"title": "Работа с API",
|
||||||
|
"description": "# Работа с API\n\nСделайте запрос к фиктивному API и отобразите список пользователей с именами и email.",
|
||||||
|
"createdAt": "2024-09-25T13:40:00.000Z",
|
||||||
|
"updatedAt": "2024-10-09T10:10:00.000Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user