import { useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; const STAGE_SIZE = 11; const API = import.meta.env.VITE_API_URL; const NAV_BTN = { padding: '14px 40px', borderRadius: 999, fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600, cursor: 'pointer', border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8', }; const FORFEIT_BTN = { padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif', fontSize: 14, fontWeight: 600, cursor: 'pointer', border: '1.5px solid #e57373', background: '#fff', color: '#c62828', }; function authHeaders() { const token = sessionStorage.getItem('ranked_token'); return token ? { Authorization: `Bearer ${token}` } : {}; } function OpponentProgressStrip({ progress, total, name, dced, disconnected }) { const items = []; for (let qi = 0; qi < total; qi++) { const filled = qi < progress; items.push(
{qi + 1}
); if (qi < total - 1) { items.push(
); } } return (
{name} {dced && DC'd} {!dced && disconnected && disconnected?}
{items}
); } function ProgressStrip({ total, current, answers, onJump }) { const items = []; for (let qi = 0; qi < total; qi++) { const active = qi === current, answered = answers[qi] != null, filled = answered; items.push( ); if (qi < total - 1) items.push(
); } return
{items}
; } function PlayerChip({ name, avatar, reverse }) { const pic = avatar ? :
{name?.[0]?.toUpperCase() ?? '?'}
; const label = {name}; return (
{pic}{label}
); } function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) { const [count, setCount] = useState(5); const gradeNum = grade?.replace('G', '') ?? ''; useEffect(() => { const id = setInterval(() => { setCount(c => { if (c <= 1) { clearInterval(id); onNext(); return 0; } return c - 1; }); }, 1000); return () => clearInterval(id); }, [onNext]); return (

Ranked Match

EQAO Grade {gradeNum}

vs

First to finish all {STAGE_SIZE} questions wins.

{count}
); } function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit }) { useEffect(() => { const handler = (e) => { const i = +e.key - 1; if (i >= 0 && i < q.choices.length) { onSelect(i); return; } if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext(); if (e.key === 'ArrowLeft' && qIdx > 0) onBack(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [q.choices.length, qIdx, onSelect, onNext, onBack]); return (

Question {qIdx + 1} of {STAGE_SIZE}

You

{q.question}

{q.choices.map((choice, i) => { const sel = selected === i; return ( ); })}
{qIdx > 0 && } {qIdx === STAGE_SIZE - 1 ? ( ) : ( )}
); } function DefeatOverlay({ opponentName, onViewResults }) { return (
💀

Defeated!

{opponentName} finished before you.

); } function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit }) { return (
{opponentDced ?
🔌
:
}

{opponentDced ? `${opponentName} disconnected!` : `Waiting for ${opponentName}...`}

{opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'}

); } function CompleteScreen({ won, opponentName, duration, eloChange, opponentDced, youDced, onDashboard }) { const subtitle = opponentDced && won ? `${opponentName} disconnected. Victory by default.` : youDced && !won ? `You disconnected. ${opponentName} wins.` : won ? `You beat ${opponentName}! Well played.` : `${opponentName} beat you. Better luck next time.`; return (
{won ? (opponentDced ? '🔌' : '🏆') : '💀'}

{won ? 'Victory!' : 'Defeated!'}

{subtitle}

{duration > 0 && (
⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')}
)} {eloChange !== 0 &&
0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}
}
); } export default function RankedGame() { const navigate = useNavigate(); const { state } = useLocation(); if (!state || !state.gameId || !state.questions) { return ; } const { gameId, questions: initialQuestions, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = state; const [phase, setPhase] = useState('intro'); const [questions] = useState(initialQuestions); const [qIdx, setQIdx] = useState(0); const [answers, setAnswers] = useState({}); const [selected, setSelected] = useState(null); const [opponentProgress, setOpponentProgress] = useState(0); const [opponentName] = useState(initOppName); const [opponentAvatar] = useState(initOppAvatar); const [playerWon, setPlayerWon] = useState(null); const [showDefeatOverlay, setShowDefeatOverlay] = useState(false); const [startTime] = useState(() => Date.now()); const [duration, setDuration] = useState(0); const [eloChange, setEloChange] = useState(0); const [opponentDced, setOpponentDced] = useState(false); const [youDced, setYouDced] = useState(false); const [opponentConnected, setOpponentConnected] = useState(true); const pollRef = useRef(null); const completedRef = useRef(false); const submittedRef = useRef(false); useEffect(() => { return () => clearInterval(pollRef.current); }, []); function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) { fetch(`${API}/game/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs }), }).catch(() => {}); } function pollGameStatus() { pollRef.current = setInterval(() => { if (completedRef.current) { clearInterval(pollRef.current); return; } fetch(`${API}/game/status/${gameId}`, { headers: authHeaders() }) .then(r => r.json()) .then(data => { if (data.opponentDced) setOpponentDced(true); if (data.youDced) setYouDced(true); if (data.opponentConnected === false) setOpponentConnected(false); else if (data.opponentConnected === true && !data.opponentDced) setOpponentConnected(true); if (data.status === 'completed') { clearInterval(pollRef.current); const you = data.players.you; const won = data.youWon === true; completedRef.current = true; setDuration(you.timeSpentMs || (Date.now() - startTime)); setEloChange(you.eloChange || 0); setPlayerWon(won); if (won) { setPhase('complete'); } else { setShowDefeatOverlay(true); } } else { const opp = data.players.opponent; setOpponentProgress(opp.currentQuestion + 1); } }) .catch(() => {}); }, 1000); } function handleNext() { const newAnswers = { ...answers, [qIdx]: selected }; setAnswers(newAnswers); const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; const elapsed = Date.now() - startTime; if (qIdx < STAGE_SIZE - 1) { submitProgress(qIdx + 1, correctCount, false); const next = qIdx + 1; setQIdx(next); setSelected(newAnswers[next] ?? null); } else { submittedRef.current = true; submitProgress(STAGE_SIZE, correctCount, true, elapsed); setPhase('waiting'); } } function handleBack() { const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; setAnswers(newAnswers); const prev = qIdx - 1; setQIdx(prev); setSelected(newAnswers[prev] ?? null); } function handleJump(targetIdx) { const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; setAnswers(newAnswers); setQIdx(targetIdx); setSelected(newAnswers[targetIdx] ?? null); } async function handleForfeit() { if (!window.confirm('Are you sure you want to forfeit? This counts as a loss.')) return; clearInterval(pollRef.current); completedRef.current = true; try { await fetch(`${API}/game/forfeit`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId }), }); } catch {} navigate('/dashboard'); } if (phase === 'intro') return { setPhase('question'); pollGameStatus(); }} />; if (phase === 'question') return ( <> {showDefeatOverlay && ( { setShowDefeatOverlay(false); setPhase('complete'); }} /> )} ); if (phase === 'waiting') return ( <> {showDefeatOverlay && ( { setShowDefeatOverlay(false); setPhase('complete'); }} /> )} ); if (phase === 'complete') return ( navigate('/dashboard')} /> ); return null; }