import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; const VALID_GRADES = new Set(['G3', 'G6', 'G9']); const API = import.meta.env.VITE_API_URL; function authHeaders() { const token = sessionStorage.getItem('ranked_token'); return token ? { Authorization: `Bearer ${token}` } : {}; } function getNameFromToken() { try { const token = sessionStorage.getItem('ranked_token'); if (!token) return 'You'; const payload = JSON.parse(atob(token.split('.')[1])); return payload.name ?? 'You'; } catch { return 'You'; } } function getAvatarFromToken() { try { const token = sessionStorage.getItem('ranked_token'); if (!token) return null; const payload = JSON.parse(atob(token.split('.')[1])); return payload.picture ?? null; } catch { return null; } } function MatchFoundScreen({ myName, myAvatar, opponentName, opponentAvatar, grade, onDone }) { const [count, setCount] = useState(5); const [phase, setPhase] = useState('intro'); // intro → reveal → countdown useEffect(() => { const t1 = setTimeout(() => setPhase('reveal'), 400); const t2 = setTimeout(() => setPhase('countdown'), 1200); return () => { clearTimeout(t1); clearTimeout(t2); }; }, []); useEffect(() => { if (phase !== 'countdown') return; if (count <= 0) { onDone(); return; } const id = setTimeout(() => setCount(c => c - 1), 1000); return () => clearTimeout(id); }, [count, phase, onDone]); const countColor = count <= 1 ? '#ff3b30' : count <= 2 ? '#ff9500' : count <= 3 ? '#ffcc00' : '#4fc3f7'; const countGlow = count <= 1 ? '#ff3b3088' : count <= 2 ? '#ff950088' : count <= 3 ? '#ffcc0088' : '#4fc3f788'; return (
{/* Scanline overlay */}
{/* subtle grid */}
{/* Header */}
Match Found
{/* Players row */}
{/* You */}
{myAvatar ? {myName} : {(myName?.[0] ?? '?').toUpperCase()} }
{myName} You
{/* VS + countdown */}
VS {phase === 'countdown' && (
{/* pulse rings */}
{count}
)}
{/* Opponent */}
{opponentAvatar ? {opponentName} : {(opponentName?.[0] ?? '?').toUpperCase()} }
{opponentName} Opponent
{/* Divider line */}
{/* Grade tag */}
Ranked · Grade {grade?.replace('G', '') ?? '?'}
); } const QUEUE_DIAMONDS = [ { left: '8%', top: '15%', size: 10, dur: '12s', delay: '0s' }, { left: '22%', top: '68%', size: 7, dur: '15s', delay: '2s' }, { left: '52%', top: '20%', size: 13, dur: '11s', delay: '1s' }, { left: '68%', top: '72%', size: 9, dur: '14s', delay: '3s' }, { left: '84%', top: '35%', size: 11, dur: '13s', delay: '0.5s' }, { left: '40%', top: '82%', size: 8, dur: '10s', delay: '4s' }, ]; function FindingScreen({ elapsed, grade, onBack }) { const [dots, setDots] = useState(''); const [playersOnline, setPlayersOnline] = useState(null); useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []); useEffect(() => { const fetchCount = () => { fetch(`${API}/online-count`) .then(r => r.json()) .then(d => setPlayersOnline(d.online ?? 0)) .catch(() => {}); }; fetchCount(); const t = setInterval(fetchCount, 30000); return () => clearInterval(t); }, []); const minutes = Math.floor(elapsed / 60); const seconds = elapsed % 60; const gradeNum = grade?.replace('G', '') ?? '?'; return (
{/* Arena background */}
{QUEUE_DIAMONDS.map((d, i) => (
))}
{/* Content */}
{/* Two counter-rotating rings */}
{/* Grade label */}

Grade {gradeNum} · Ranked

{/* Heading */}

Searching for opponent{dots}

{/* Players online */}

{playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'}

{/* Elapsed timer — large gold */}
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
{/* Back button */}
); } export default function Queue() { const navigate = useNavigate(); const { state } = useLocation(); const grade = state?.grade ?? null; const [myName, setMyName] = useState(() => getNameFromToken()); const [myAvatar, setMyAvatar] = useState(() => getAvatarFromToken()); const [queueElapsed, setQueueElapsed] = useState(0); const [matchData, setMatchData] = useState(null); const pollRef = useRef(null); const joinedRef = useRef(false); const completedRef = useRef(false); const leaveTimerRef = useRef(null); const leavingRef = useRef(false); // Elapsed timer — stop once matched useEffect(() => { if (matchData) return; const id = setInterval(() => setQueueElapsed(e => e + 1), 1000); return () => clearInterval(id); }, [matchData]); // Fetch own user info useEffect(() => { fetch(`${API}/auth/me`, { headers: authHeaders() }) .then(r => r.ok ? r.json() : null) .then(data => { if (!data) return; setMyName(data.username ?? data.displayName ?? 'You'); setMyAvatar(data.avatarUrl ?? null); }) .catch(() => {}); }, []); // Join queue on mount useEffect(() => { // Cancel any pending leave from a previous mount (handles StrictMode double-invoke) if (leaveTimerRef.current) { clearTimeout(leaveTimerRef.current); leaveTimerRef.current = null; } // Reset leaving flag so callbacks work on this mount leavingRef.current = false; joinedRef.current = false; completedRef.current = false; fetch(`${API}/queue/join`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ grade }), }) .then(r => { if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } return r.json(); }) .then(data => { if (!data || leavingRef.current) return; joinedRef.current = true; if (data.status === 'waiting') { pollQueue(); } else if (data.status === 'matched') { fetchMatchData(); } }) .catch(() => {}); return () => { leavingRef.current = true; clearInterval(pollRef.current); if (joinedRef.current && !completedRef.current) { leaveTimerRef.current = setTimeout(() => { fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {}); }, 0); } }; }, [grade, navigate]); function pollQueue() { pollRef.current = setInterval(() => { fetch(`${API}/queue/status`, { headers: authHeaders() }) .then(r => { if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } return r.json(); }) .then(data => { if (!data || leavingRef.current) return; if (data.status === 'matched') { clearInterval(pollRef.current); showMatchup(data); } else if (data.status === 'not_in_queue') { clearInterval(pollRef.current); fetch(`${API}/queue/join`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ grade }), }) .then(r => { if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } return r.json(); }) .then(d => { if (!d || leavingRef.current) return; if (d.status === 'waiting') pollQueue(); else if (d.status === 'matched') showMatchup(d); }) .catch(() => {}); } }) .catch(() => {}); }, 1000); } function fetchMatchData() { fetch(`${API}/queue/status`, { headers: authHeaders() }) .then(r => r.json()) .then(data => { if (!leavingRef.current && data.status === 'matched') { showMatchup(data); } }) .catch(() => {}); } function showMatchup(data) { clearInterval(pollRef.current); completedRef.current = true; setMatchData(data); } const goToGame = useCallback(() => { navigate('/ranked-game', { state: { gameId: matchData.gameId, questions: matchData.questions, opponentName: matchData.opponent.username ?? matchData.opponent.displayName, opponentAvatar: matchData.opponent.avatarUrl, myName, myAvatar, grade, }, }); }, [matchData, myName, myAvatar, grade, navigate]); if (!VALID_GRADES.has(grade)) return ; if (matchData) { return ( ); } return navigate('/dashboard')} />; }