# Challenge Service - React Example Полный пример интеграции сервиса в React приложение с TypeScript. ## Структура проекта ``` src/ ├── api/ │ └── challenge.ts # API клиент ├── components/ │ ├── AuthForm.tsx │ ├── ChainList.tsx │ ├── TaskView.tsx │ ├── CheckStatus.tsx │ ├── ResultView.tsx │ └── UserStats.tsx ├── context/ │ └── ChallengeContext.tsx # State management ├── hooks/ │ ├── useChallenge.ts │ ├── usePolling.ts │ └── useSubmission.ts ├── types/ │ └── challenge.ts # TypeScript типы └── App.tsx ``` ## Полный код ### 1. TypeScript Types (`src/types/challenge.ts`) ```typescript export interface ChallengeUser { _id: string id: string nickname: string createdAt: string } export interface ChallengeTask { _id: string id: string title: string description: string createdAt: string updatedAt: string } export interface ChallengeChain { _id: string id: string name: string tasks: ChallengeTask[] createdAt: string updatedAt: string } export type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision' export interface ChallengeSubmission { _id: string id: string user: string task: string result: string status: SubmissionStatus queueId?: string feedback?: string submittedAt: string checkedAt?: string attemptNumber: number } export type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found' export interface QueueStatus { status: QueueStatusType submission?: ChallengeSubmission & { task: ChallengeTask } error?: string position?: number } export interface UserStats { totalTasksAttempted: number completedTasks: number inProgressTasks: number needsRevisionTasks: number totalSubmissions: number averageCheckTimeMs: number taskStats: Array<{ taskId: string taskTitle: string totalAttempts: number status: string lastAttemptAt: string | null }> chainStats: Array<{ chainId: string chainName: string totalTasks: number completedTasks: number progress: number }> } ``` ### 2. API Client (`src/api/challenge.ts`) ```typescript import type { ChallengeChain, ChallengeSubmission, QueueStatus, UserStats, } from '../types/challenge' const API_BASE = 'http://localhost:8082/api/challenge' interface APIResponse { error: any data: T } async function request(endpoint: string, options?: RequestInit): Promise { const response = await fetch(`${API_BASE}${endpoint}`, { headers: { 'Content-Type': 'application/json', ...options?.headers, }, ...options, }) const json: APIResponse = await response.json() if (json.error) { throw new Error(json.error.message || 'API Error') } return json.data } export const challengeAPI = { // Аутентификация async auth(nickname: string) { return request<{ ok: boolean; userId: string }>('/auth', { method: 'POST', body: JSON.stringify({ nickname }), }) }, // Цепочки async getChains() { return request('/chains') }, async getChain(chainId: string) { return request(`/chain/${chainId}`) }, // Отправка решения async submit(userId: string, taskId: string, result: string) { return request<{ queueId: string; submissionId: string }>('/submit', { method: 'POST', body: JSON.stringify({ userId, taskId, result }), }) }, // Проверка статуса async checkStatus(queueId: string) { return request(`/check-status/${queueId}`) }, // Статистика async getUserStats(userId: string) { return request(`/user/${userId}/stats`) }, // Попытки async getSubmissions(userId: string, taskId?: string) { const query = taskId ? `?taskId=${taskId}` : '' return request(`/user/${userId}/submissions${query}`) }, } ``` ### 3. Context (`src/context/ChallengeContext.tsx`) ```typescript import React, { createContext, useContext, useState, useEffect } from 'react' import { challengeAPI } from '../api/challenge' import type { UserStats } from '../types/challenge' interface ChallengeContextType { userId: string | null nickname: string | null stats: UserStats | null isAuthenticated: boolean login: (nickname: string) => Promise logout: () => void refreshStats: () => Promise } const ChallengeContext = createContext(undefined) export function ChallengeProvider({ children }: { children: React.ReactNode }) { const [userId, setUserId] = useState( localStorage.getItem('challenge_user_id') ) const [nickname, setNickname] = useState( localStorage.getItem('challenge_nickname') ) const [stats, setStats] = useState(null) const login = async (nickname: string) => { const { userId } = await challengeAPI.auth(nickname) setUserId(userId) setNickname(nickname) localStorage.setItem('challenge_user_id', userId) localStorage.setItem('challenge_nickname', nickname) } const logout = () => { setUserId(null) setNickname(null) setStats(null) localStorage.removeItem('challenge_user_id') localStorage.removeItem('challenge_nickname') } const refreshStats = async () => { if (userId) { const userStats = await challengeAPI.getUserStats(userId) setStats(userStats) } } useEffect(() => { if (userId) { refreshStats() } }, [userId]) return ( {children} ) } export function useChallenge() { const context = useContext(ChallengeContext) if (!context) { throw new Error('useChallenge must be used within ChallengeProvider') } return context } ``` ### 4. Custom Hooks #### `src/hooks/usePolling.ts` ```typescript import { useEffect, useRef } from 'react' export function usePolling( callback: () => Promise, interval: number = 2000, enabled: boolean = true ) { const timeoutRef = useRef() useEffect(() => { if (!enabled) return const poll = async () => { try { const shouldContinue = await callback() if (shouldContinue) { timeoutRef.current = setTimeout(poll, interval) } } catch (error) { console.error('Polling error:', error) } } poll() return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, [callback, interval, enabled]) } ``` #### `src/hooks/useSubmission.ts` ```typescript import { useState } from 'react' import { challengeAPI } from '../api/challenge' import type { ChallengeSubmission, QueueStatus } from '../types/challenge' export function useSubmission(userId: string, taskId: string) { const [result, setResult] = useState('') const [submitting, setSubmitting] = useState(false) const [queueId, setQueueId] = useState(null) const [queueStatus, setQueueStatus] = useState(null) const [finalSubmission, setFinalSubmission] = useState(null) const submit = async () => { if (!result.trim()) return setSubmitting(true) try { const { queueId: newQueueId } = await challengeAPI.submit(userId, taskId, result) setQueueId(newQueueId) } catch (error) { console.error('Submit error:', error) alert('Ошибка отправки решения') } finally { setSubmitting(false) } } const checkStatus = async () => { if (!queueId) return false const status = await challengeAPI.checkStatus(queueId) setQueueStatus(status) if (status.status === 'completed' && status.submission) { setFinalSubmission(status.submission as any) return false // Останавливаем polling } if (status.status === 'error') { return false // Останавливаем polling } return true // Продолжаем polling } const reset = () => { setResult('') setQueueId(null) setQueueStatus(null) setFinalSubmission(null) } return { result, setResult, submitting, queueId, queueStatus, finalSubmission, submit, checkStatus, reset, } } ``` ### 5. Components #### `src/components/AuthForm.tsx` ```typescript import React, { useState } from 'react' import { useChallenge } from '../context/ChallengeContext' export function AuthForm() { const [nickname, setNickname] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') const { login } = useChallenge() const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError('') try { await login(nickname) } catch (err) { setError('Ошибка входа. Попробуйте другой nickname.') } finally { setLoading(false) } } return (

Вход в систему

setNickname(e.target.value)} minLength={3} maxLength={50} required disabled={loading} /> {error &&
{error}
}
) } ``` #### `src/components/ChainList.tsx` ```typescript import React, { useState, useEffect } from 'react' import { challengeAPI } from '../api/challenge' import { useChallenge } from '../context/ChallengeContext' import type { ChallengeChain } from '../types/challenge' interface Props { onSelectChain: (chain: ChallengeChain) => void } export function ChainList({ onSelectChain }: Props) { const [chains, setChains] = useState([]) const [loading, setLoading] = useState(true) const { stats } = useChallenge() useEffect(() => { challengeAPI .getChains() .then(setChains) .finally(() => setLoading(false)) }, []) if (loading) return
Загрузка цепочек...
return (

Доступные цепочки заданий

{chains.map((chain) => { const chainProgress = stats?.chainStats.find((cs) => cs.chainId === chain.id) return (
onSelectChain(chain)} >

{chain.name}

{chain.tasks.length} заданий

{chainProgress && (
{chainProgress.completedTasks} / {chainProgress.totalTasks} выполнено
)}
) })}
) } ``` #### `src/components/TaskView.tsx` ```typescript import React from 'react' import ReactMarkdown from 'react-markdown' import { useSubmission } from '../hooks/useSubmission' import { usePolling } from '../hooks/usePolling' import { useChallenge } from '../context/ChallengeContext' import { CheckStatus } from './CheckStatus' import { ResultView } from './ResultView' import type { ChallengeTask } from '../types/challenge' interface Props { task: ChallengeTask onComplete: () => void } export function TaskView({ task, onComplete }: Props) { const { userId } = useChallenge() const { result, setResult, submitting, queueId, queueStatus, finalSubmission, submit, checkStatus, reset, } = useSubmission(userId!, task.id) // Запускаем polling когда есть queueId usePolling(checkStatus, 2000, !!queueId && !finalSubmission) if (finalSubmission) { return ( ) } if (queueId) { return } return (

{task.title}

{task.description}

Ваше решение: