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}
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}
{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;
}