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 */}
{/* Header */}
Match Found
{/* Players row */}
{/* You */}
{myAvatar
?

:
{(myName?.[0] ?? '?').toUpperCase()}
}
{myName}
You
{/* VS + countdown */}
VS
{phase === 'countdown' && (
{/* pulse rings */}
{count}
)}
{/* Opponent */}
{opponentAvatar
?

:
{(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')} />;
}