diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index c8df917..b87b4ed 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -17,6 +17,14 @@ const getNavPath = (key: string, fallback: string) => { export const URLs = { 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: { url: makeUrl(navs[`link.${pkg.name}.auth`]), isOn: Boolean(navs[`link.${pkg.name}.auth`]), diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6757c06..f163f06 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,7 +1,9 @@ import React from 'react' +import { useNavigate } from 'react-router-dom' import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react' import { useChallenge } from '../context/ChallengeContext' +import { URLs } from '../__data__/urls' interface HeaderProps { chainName?: string @@ -9,8 +11,14 @@ interface HeaderProps { } export const Header = ({ chainName, taskProgress }: HeaderProps) => { + const navigate = useNavigate() const { nickname, workplaceNumber, logout } = useChallenge() + const handleLogout = () => { + logout() + navigate(URLs.workplace) + } + if (!nickname) return null return ( @@ -42,7 +50,7 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => { )} - diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx deleted file mode 100644 index 63a13a2..0000000 --- a/src/components/LoginForm.tsx +++ /dev/null @@ -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 ( - - - {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Challenge Platform - - - Добро пожаловать! Введите номер рабочего места - - - -
- {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Номер рабочего места - - setWorkplaceNumber(e.target.value)} - placeholder="Например: 1" - size="lg" - autoFocus - // @ts-expect-error Chakra UI v2 uses isInvalid - isInvalid={!!error} - /> - {error && ( - - {error} - - )} - - - - -
-
-
-
- ) - } - - return ( - - - {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Challenge Platform - - - Рабочее место: №{workplaceNumber} - - - (номер сохранён) - - - Введите ваше ФИО для начала работы - - - -
- {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Ваше ФИО - - setFullName(e.target.value)} - placeholder="Иванов Иван Иванович" - size="lg" - autoFocus - // @ts-expect-error Chakra UI v2 uses isInvalid - isInvalid={!!error} - /> - {error && ( - - {error} - - )} - - - {/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */} - - - - -
-
-
-
- ) -} diff --git a/src/context/ChallengeContext.tsx b/src/context/ChallengeContext.tsx index 1b257a4..ce9ee4d 100644 --- a/src/context/ChallengeContext.tsx +++ b/src/context/ChallengeContext.tsx @@ -19,8 +19,7 @@ import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../ut import { ChallengeEventEmitter } from '../utils/events' import { clearDraft, loadDraft, saveDraft } from '../utils/drafts' import { PollingManager } from '../utils/polling' - -const isBrowser = () => typeof window !== 'undefined' +import { storage } from '../utils/storage' class ChallengeCache { private cache = new Map() @@ -77,10 +76,6 @@ interface ChallengeContextValue { const ChallengeContext = createContext(undefined) -const USER_ID_KEY = 'challengeUserId' -const USER_NICKNAME_KEY = 'challengeNickname' -const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' - export const ChallengeProvider = ({ children }: PropsWithChildren) => { const cacheRef = useRef(new ChallengeCache()) const metricsCollector = useMemo(() => new MetricsCollector(), []) @@ -88,15 +83,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { const eventEmitter = useMemo(() => new ChallengeEventEmitter(), []) const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), []) - const [userId, setUserId] = useState(() => - isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null, - ) - const [nickname, setNickname] = useState(() => - isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null, - ) - const [workplaceNumber, setWorkplaceNumber] = useState(() => - isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null, - ) + const [userId, setUserId] = useState(() => storage.getUserId()) + const [nickname, setNickname] = useState(() => storage.getNickname()) + const [workplaceNumber, setWorkplaceNumber] = useState(() => storage.getWorkplaceNumber()) const [stats, setStats] = useState(null) const [personalDashboard, setPersonalDashboard] = useState(null) const [chains, setChains] = useState(() => { @@ -162,11 +151,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { setNickname(nicknameValue) setWorkplaceNumber(workplaceNumberValue) - if (isBrowser()) { - window.localStorage.setItem(USER_ID_KEY, response.userId) - window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue) - window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue) - } + storage.setUserId(response.userId) + storage.setNickname(nicknameValue) + storage.setWorkplaceNumber(workplaceNumberValue) cacheRef.current.clear('chains') await refreshStatsById(response.userId) @@ -182,13 +169,8 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { setPersonalDashboard(null) cacheRef.current.clear() - if (isBrowser()) { - window.localStorage.removeItem(USER_ID_KEY) - window.localStorage.removeItem(USER_NICKNAME_KEY) - window.localStorage.removeItem(WORKPLACE_NUMBER_KEY) - window.localStorage.removeItem('challengeSelectedChainId') - window.localStorage.removeItem('challengeSelectedTaskId') - } + // Очищаем всё из localStorage + storage.clearAll() }, []) const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading diff --git a/src/dashboard.tsx b/src/dashboard.tsx index a691263..f5b1ec0 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,24 +1,107 @@ 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 { MainPage } from './pages' +import { + WorkplacePage, + LoginPage, + ChainsPage, + TaskPage, + CompletedPage +} from './pages' +import { storage } from './utils/storage' const PageWrapper = ({ children }: React.PropsWithChildren) => ( Loading...}>{children} ) +// Компонент для редиректа на нужную страницу +const IndexRedirect = () => { + const workplaceNumber = storage.getWorkplaceNumber() + const nickname = storage.getNickname() + const chainId = storage.getSelectedChainId() + const taskId = storage.getSelectedTaskId() + + // Если есть сохранённое задание - туда + if (nickname && chainId && taskId) { + return + } + // Если авторизован - к цепочкам + if (nickname) { + return + } + // Если есть номер места - к логину + if (workplaceNumber) { + return + } + // Иначе - к вводу места + return +} + export const Dashboard = () => { return ( + {/* Главная - редирект */} } + /> + + {/* Ввод номера рабочего места */} + - + } /> + + {/* Ввод ФИО */} + + + + } + /> + + {/* Выбор цепочки */} + + + + } + /> + + {/* Задание */} + + + + } + /> + + {/* Завершение цепочки */} + + + + } + /> + + {/* Fallback */} + } + /> ) } diff --git a/src/pages/chains/ChainsPage.tsx b/src/pages/chains/ChainsPage.tsx new file mode 100644 index 0000000..d31c62b --- /dev/null +++ b/src/pages/chains/ChainsPage.tsx @@ -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 ( + <> +
+ + + ) +} + diff --git a/src/pages/chains/index.ts b/src/pages/chains/index.ts new file mode 100644 index 0000000..1b71a88 --- /dev/null +++ b/src/pages/chains/index.ts @@ -0,0 +1,4 @@ +import { ChainsPage } from './ChainsPage' + +export default ChainsPage + diff --git a/src/pages/completed/CompletedPage.tsx b/src/pages/completed/CompletedPage.tsx new file mode 100644 index 0000000..ea05e71 --- /dev/null +++ b/src/pages/completed/CompletedPage.tsx @@ -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 ( + <> +
+ + + + 🎉 + + Поздравляем! + + + Вы успешно выполнили все задания + + {chain && ( + + + {chain.name} + + + )} + + Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий. + + + + + + + ) +} + diff --git a/src/pages/completed/index.ts b/src/pages/completed/index.ts new file mode 100644 index 0000000..1f4a978 --- /dev/null +++ b/src/pages/completed/index.ts @@ -0,0 +1,4 @@ +import { CompletedPage } from './CompletedPage' + +export default CompletedPage + diff --git a/src/pages/index.ts b/src/pages/index.ts index 0bc85fb..44cda1f 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,3 +1,8 @@ import { lazy } from 'react' -export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main')) \ No newline at end of file +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')) \ No newline at end of file diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..9625ab8 --- /dev/null +++ b/src/pages/login/LoginPage.tsx @@ -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 ( + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Challenge Platform + + + Рабочее место: №{workplaceNumber} + + + Введите ваше ФИО для начала работы + + + +
+ {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Ваше ФИО + + setFullName(e.target.value)} + placeholder="Иванов Иван Иванович" + size="lg" + autoFocus + // @ts-expect-error Chakra UI v2 uses isInvalid + isInvalid={!!error} + /> + {error && ( + + {error} + + )} + + + {/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */} + + +
+ + + + Изменить рабочее место + + +
+
+
+ ) +} + diff --git a/src/pages/login/index.ts b/src/pages/login/index.ts new file mode 100644 index 0000000..bb665da --- /dev/null +++ b/src/pages/login/index.ts @@ -0,0 +1,4 @@ +import { LoginPage } from './LoginPage' + +export default LoginPage + diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index 99a6e61..8936a21 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -1,190 +1,36 @@ -import React, { useEffect, useRef, useState } from 'react' -import { - Box, - Button, - Heading, - Text, - VStack, -} from '@chakra-ui/react' -import { Alert } from '@chakra-ui/react/alert' +import React from 'react' +import { Navigate } from 'react-router-dom' -import type { ChallengeChain, ChallengeTask } from '../../__data__/types' -import { useChallenge } from '../../context/ChallengeContext' -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' +import { URLs } from '../../__data__/urls' +import { storage } from '../../utils/storage' +/** + * MainPage теперь просто редиректит на нужную страницу + * Вся логика навигации находится в отдельных страницах: + * - /workplace - ввод номера рабочего места + * - /login - ввод ФИО + * - /chains - выбор цепочки + * - /chain/:chainId/task/:taskId - задание + * - /completed/:chainId - завершение цепочки + */ export const MainPage = () => { - const { nickname, chains } = useChallenge() - const [selectedChain, setSelectedChain] = useState(null) - const [selectedTask, setSelectedTask] = useState(null) - const [completedChainName, setCompletedChainName] = useState(null) - const [isOffline, setIsOffline] = useState(() => - typeof navigator !== 'undefined' ? !navigator.onLine : false, - ) - const hasRestoredState = useRef(false) + const workplaceNumber = storage.getWorkplaceNumber() + const nickname = storage.getNickname() + const chainId = storage.getSelectedChainId() + const taskId = storage.getSelectedTaskId() - // Восстановление состояния при загрузке - useEffect(() => { - if (hasRestoredState.current || !chains.length || !nickname) return - - 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]) - } - } - } - - hasRestoredState.current = true - }, [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) + // Если есть сохранённое задание - туда + if (nickname && chainId && taskId) { + return } - - 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) - } + // Если авторизован - к цепочкам + if (nickname) { + return } - - const handleContinueAfterCompletion = () => { - setCompletedChainName(null) + // Если есть номер места - к логину + if (workplaceNumber) { + return } - - - 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 - } - - // Если цепочка завершена, показываем экран поздравления - if (completedChainName) { - return ( - <> -
- - - - 🎉 - - Поздравляем! - - - Вы успешно выполнили все задания - - - - {completedChainName} - - - - Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий. - - - - - - - ) - } - - // Если цепочка не выбрана, показываем селектор цепочек - if (!selectedChain) { - return ( - <> -
- - - ) - } - - const taskProgress = `Задание ${selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1}` // из ${selectedChain.tasks.length}` - - // Показываем выбранную цепочку и задания - return ( - <> -
- - - {isOffline && ( - - - Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи. - - )} - - {selectedTask && ( - - )} - - - - ) + // Иначе - к вводу места + return } diff --git a/src/pages/task/TaskPage.tsx b/src/pages/task/TaskPage.tsx new file mode 100644 index 0000000..f782656 --- /dev/null +++ b/src/pages/task/TaskPage.tsx @@ -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 ( + <> +
+ + + {/* Навигация по заданиям */} + + + + + Задания: + + {chain.tasks.map((t, index) => ( + + ))} + + + + + + + + + + + ) +} + diff --git a/src/pages/task/index.ts b/src/pages/task/index.ts new file mode 100644 index 0000000..859438f --- /dev/null +++ b/src/pages/task/index.ts @@ -0,0 +1,4 @@ +import { TaskPage } from './TaskPage' + +export default TaskPage + diff --git a/src/pages/workplace/WorkplacePage.tsx b/src/pages/workplace/WorkplacePage.tsx new file mode 100644 index 0000000..00299cd --- /dev/null +++ b/src/pages/workplace/WorkplacePage.tsx @@ -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 ( + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Challenge Platform + + + Добро пожаловать! Введите номер рабочего места + + + +
+ {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Номер рабочего места + + setWorkplaceNumber(e.target.value)} + placeholder="Например: 1" + size="lg" + autoFocus + // @ts-expect-error Chakra UI v2 uses isInvalid + isInvalid={!!error} + /> + {error && ( + + {error} + + )} + + + {/* Полезные ссылки */} + + + Полезные ссылки: + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + {USEFUL_LINKS.map(link => ( + + 🔗 {link.label} + + ))} + + + + + +
+
+
+
+ ) +} + diff --git a/src/pages/workplace/index.ts b/src/pages/workplace/index.ts new file mode 100644 index 0000000..22ac727 --- /dev/null +++ b/src/pages/workplace/index.ts @@ -0,0 +1,4 @@ +import { WorkplacePage } from './WorkplacePage' + +export default WorkplacePage + diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..592f130 --- /dev/null +++ b/src/utils/storage.ts @@ -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) + }, +} + diff --git a/stubs/api/data/chains.json b/stubs/api/data/chains.json index 1059c30..0d831fa 100644 --- a/stubs/api/data/chains.json +++ b/stubs/api/data/chains.json @@ -23,6 +23,38 @@ "description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.", "createdAt": "2024-09-05T11:30: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" } ] }