551 lines
21 KiB
React
551 lines
21 KiB
React
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 (
|
||
<div style={{
|
||
minHeight: '100vh', display: 'flex', flexDirection: 'column',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
background: '#07090f',
|
||
fontFamily: "'system-ui',sans-serif", overflow: 'hidden', position: 'relative',
|
||
}}>
|
||
<style>{`
|
||
@keyframes bg-scan {
|
||
0% { transform: translateY(-100%); opacity: 0.07; }
|
||
100% { transform: translateY(100vh); opacity: 0.03; }
|
||
}
|
||
@keyframes header-drop {
|
||
0% { transform: translateY(-40px) scaleX(0.6); opacity: 0; letter-spacing: 0.3em; }
|
||
60% { transform: translateY(4px) scaleX(1.02); opacity: 1; letter-spacing: 0.18em; }
|
||
100% { transform: translateY(0) scaleX(1); opacity: 1; letter-spacing: 0.18em; }
|
||
}
|
||
@keyframes player-slide-left {
|
||
0% { transform: translateX(-120px); opacity: 0; }
|
||
70% { transform: translateX(6px); opacity: 1; }
|
||
100% { transform: translateX(0); opacity: 1; }
|
||
}
|
||
@keyframes player-slide-right {
|
||
0% { transform: translateX(120px); opacity: 0; }
|
||
70% { transform: translateX(-6px); opacity: 1; }
|
||
100% { transform: translateX(0); opacity: 1; }
|
||
}
|
||
@keyframes vs-slam {
|
||
0% { transform: scale(3) rotate(-8deg); opacity: 0; }
|
||
55% { transform: scale(0.92) rotate(1deg); opacity: 1; }
|
||
75% { transform: scale(1.06) rotate(-0.5deg); }
|
||
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||
}
|
||
@keyframes divider-grow {
|
||
0% { width: 0; opacity: 0; }
|
||
100% { width: 100%; opacity: 1; }
|
||
}
|
||
@keyframes count-slam {
|
||
0% { transform: scale(2.2); opacity: 0; }
|
||
50% { transform: scale(0.88); opacity: 1; }
|
||
75% { transform: scale(1.08); }
|
||
100% { transform: scale(1); opacity: 1; }
|
||
}
|
||
@keyframes ring-pulse {
|
||
0% { transform: scale(1); opacity: 0.7; }
|
||
100% { transform: scale(2.2); opacity: 0; }
|
||
}
|
||
@keyframes avatar-glow-blue {
|
||
0%, 100% { box-shadow: 0 0 18px 4px #1a73e8aa, 0 0 0 3px #1a73e8; }
|
||
50% { box-shadow: 0 0 32px 10px #1a73e8cc, 0 0 0 3px #1a73e8; }
|
||
}
|
||
@keyframes avatar-glow-red {
|
||
0%, 100% { box-shadow: 0 0 18px 4px #ff3b30aa, 0 0 0 3px #ff3b30; }
|
||
50% { box-shadow: 0 0 32px 10px #ff3b30cc, 0 0 0 3px #ff3b30; }
|
||
}
|
||
@keyframes scan-line {
|
||
0% { top: 0%; }
|
||
100% { top: 100%; }
|
||
}
|
||
.player-left { animation: player-slide-left 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
|
||
.player-right { animation: player-slide-right 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
|
||
.vs-text { animation: vs-slam 0.5s cubic-bezier(.22,1,.36,1) 1.05s both; }
|
||
.header-text { animation: header-drop 0.6s cubic-bezier(.22,1,.36,1) 0.1s both; }
|
||
.count-slam { animation: count-slam 0.3s cubic-bezier(.22,1,.36,1) both; }
|
||
.ring { animation: ring-pulse 0.7s ease-out both; }
|
||
`}</style>
|
||
|
||
{/* Scanline overlay */}
|
||
<div style={{
|
||
position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 0,
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute', left: 0, right: 0, height: 2,
|
||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent)',
|
||
animation: 'scan-line 3s linear infinite',
|
||
}} />
|
||
{/* subtle grid */}
|
||
<div style={{
|
||
position: 'absolute', inset: 0,
|
||
backgroundImage: 'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)',
|
||
backgroundSize: '40px 40px',
|
||
}} />
|
||
</div>
|
||
|
||
{/* Header */}
|
||
<div className="header-text" style={{
|
||
fontSize: 13, fontWeight: 800, letterSpacing: '0.18em',
|
||
textTransform: 'uppercase', color: '#4fc3f7',
|
||
marginBottom: 48, zIndex: 1,
|
||
textShadow: '0 0 20px #4fc3f7aa',
|
||
}}>
|
||
Match Found
|
||
</div>
|
||
|
||
{/* Players row */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 48, zIndex: 1 }}>
|
||
|
||
{/* You */}
|
||
<div className="player-left" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||
<div style={{
|
||
width: 100, height: 100, borderRadius: '50%',
|
||
background: myAvatar ? 'transparent' : '#0d2a4a',
|
||
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
animation: 'avatar-glow-blue 2s ease-in-out infinite',
|
||
}}>
|
||
{myAvatar
|
||
? <img src={myAvatar} alt={myName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||
: <span style={{ fontSize: 38, color: '#4fc3f7', fontWeight: 800 }}>{(myName?.[0] ?? '?').toUpperCase()}</span>
|
||
}
|
||
</div>
|
||
<span style={{ fontSize: 15, fontWeight: 700, color: '#e8f4fd', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{myName}</span>
|
||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#1a73e8' }}>You</span>
|
||
</div>
|
||
|
||
{/* VS + countdown */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20 }}>
|
||
<span className="vs-text" style={{
|
||
fontSize: 40, fontWeight: 900, color: '#fff',
|
||
letterSpacing: '0.05em',
|
||
textShadow: '0 0 24px rgba(255,255,255,0.25)',
|
||
}}>VS</span>
|
||
|
||
{phase === 'countdown' && (
|
||
<div style={{ position: 'relative', width: 80, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
{/* pulse rings */}
|
||
<div key={`r1-${count}`} className="ring" style={{
|
||
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
|
||
border: `2px solid ${countColor}`,
|
||
}} />
|
||
<div key={`r2-${count}`} className="ring" style={{
|
||
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
|
||
border: `2px solid ${countColor}`,
|
||
animationDelay: '0.15s',
|
||
}} />
|
||
<div key={count} className="count-slam" style={{
|
||
width: 80, height: 80, borderRadius: '50%',
|
||
background: `radial-gradient(circle at 38% 38%, ${countColor}33, #0a0a1a)`,
|
||
border: `2px solid ${countColor}`,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 38, fontWeight: 900, color: countColor,
|
||
boxShadow: `0 0 28px ${countGlow}, inset 0 0 12px ${countColor}22`,
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
{count}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Opponent */}
|
||
<div className="player-right" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||
<div style={{
|
||
width: 100, height: 100, borderRadius: '50%',
|
||
background: opponentAvatar ? 'transparent' : '#2a0d0d',
|
||
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
animation: 'avatar-glow-red 2s ease-in-out infinite',
|
||
}}>
|
||
{opponentAvatar
|
||
? <img src={opponentAvatar} alt={opponentName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||
: <span style={{ fontSize: 38, color: '#ff6b6b', fontWeight: 800 }}>{(opponentName?.[0] ?? '?').toUpperCase()}</span>
|
||
}
|
||
</div>
|
||
<span style={{ fontSize: 15, fontWeight: 700, color: '#ffe8e8', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{opponentName}</span>
|
||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#ff3b30' }}>Opponent</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Divider line */}
|
||
<div style={{
|
||
marginTop: 52, height: 1, zIndex: 1,
|
||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent)',
|
||
animation: 'divider-grow 0.6s ease-out 1.4s both',
|
||
width: 340,
|
||
}} />
|
||
|
||
{/* Grade tag */}
|
||
<div style={{
|
||
marginTop: 20, fontSize: 11, fontWeight: 700, letterSpacing: '0.15em',
|
||
textTransform: 'uppercase', color: 'rgba(255,255,255,0.2)', zIndex: 1,
|
||
animation: 'header-drop 0.5s ease-out 1.5s both',
|
||
}}>
|
||
Ranked · Grade {grade?.replace('G', '') ?? '?'}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontFamily: 'system-ui,sans-serif', overflow: 'hidden' }}>
|
||
<style>{`
|
||
@keyframes spinCW { to { transform: rotate(360deg); } }
|
||
@keyframes spinCCW { to { transform: rotate(-360deg); } }
|
||
`}</style>
|
||
|
||
{/* Arena background */}
|
||
<div className="arena-bg">
|
||
<div className="arena-grain" />
|
||
<div className="arena-shapes">
|
||
{QUEUE_DIAMONDS.map((d, i) => (
|
||
<div
|
||
key={i}
|
||
className="shape shape--diamond"
|
||
style={{
|
||
left: d.left, top: d.top,
|
||
width: d.size, height: d.size,
|
||
animationDuration: d.dur, animationDelay: d.delay,
|
||
opacity: 0.35,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24 }}>
|
||
|
||
{/* Two counter-rotating rings */}
|
||
<div style={{ position: 'relative', width: 80, height: 80 }}>
|
||
<div style={{
|
||
position: 'absolute', inset: 0, borderRadius: '50%',
|
||
border: '3.5px solid rgba(245,197,24,0.12)',
|
||
borderTopColor: '#f5c518', borderRightColor: 'rgba(245,197,24,0.45)',
|
||
animation: 'spinCW 1.2s linear infinite',
|
||
}} />
|
||
<div style={{
|
||
position: 'absolute', inset: 13, borderRadius: '50%',
|
||
border: '3px solid rgba(79,195,247,0.12)',
|
||
borderTopColor: '#4fc3f7', borderLeftColor: 'rgba(79,195,247,0.45)',
|
||
animation: 'spinCCW 0.85s linear infinite',
|
||
}} />
|
||
<div style={{
|
||
position: 'absolute', top: '50%', left: '50%',
|
||
transform: 'translate(-50%,-50%)',
|
||
width: 7, height: 7, borderRadius: '50%',
|
||
background: '#f5c518',
|
||
boxShadow: '0 0 10px rgba(245,197,24,0.9)',
|
||
}} />
|
||
</div>
|
||
|
||
{/* Grade label */}
|
||
<p style={{
|
||
fontSize: 11, fontWeight: 700, letterSpacing: '0.22em',
|
||
textTransform: 'uppercase', color: 'rgba(245,197,24,0.45)',
|
||
margin: 0,
|
||
}}>
|
||
Grade {gradeNum} · Ranked
|
||
</p>
|
||
|
||
{/* Heading */}
|
||
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#eceef8', margin: 0, letterSpacing: '0.01em' }}>
|
||
Searching for opponent{dots}
|
||
</h2>
|
||
|
||
{/* Players online */}
|
||
<p style={{
|
||
fontSize: 13, color: 'rgba(245,197,24,0.55)',
|
||
margin: 0, letterSpacing: '0.04em',
|
||
}}>
|
||
{playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'}
|
||
</p>
|
||
|
||
{/* Elapsed timer — large gold */}
|
||
<div style={{
|
||
fontSize: 48, fontWeight: 700,
|
||
color: '#f5c518', fontFamily: 'monospace',
|
||
letterSpacing: '0.06em', lineHeight: 1,
|
||
textShadow: '0 0 28px rgba(245,197,24,0.35)',
|
||
}}>
|
||
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
|
||
</div>
|
||
|
||
{/* Back button */}
|
||
<button
|
||
onClick={onBack}
|
||
style={{
|
||
marginTop: 8, padding: '10px 32px',
|
||
fontSize: 14, fontWeight: 600,
|
||
background: 'rgba(255,255,255,0.05)',
|
||
color: 'rgba(255,255,255,0.45)',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
borderRadius: 999, cursor: 'pointer',
|
||
fontFamily: 'system-ui,sans-serif',
|
||
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
|
||
}}
|
||
onMouseEnter={e => {
|
||
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
|
||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.25)';
|
||
e.currentTarget.style.color = 'rgba(255,255,255,0.75)';
|
||
}}
|
||
onMouseLeave={e => {
|
||
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
|
||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
|
||
e.currentTarget.style.color = 'rgba(255,255,255,0.45)';
|
||
}}
|
||
>
|
||
‹ Back to Dashboard
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 <Navigate to="/dashboard" replace />;
|
||
|
||
if (matchData) {
|
||
return (
|
||
<MatchFoundScreen
|
||
myName={myName}
|
||
myAvatar={myAvatar}
|
||
opponentName={matchData.opponent.username ?? matchData.opponent.displayName}
|
||
opponentAvatar={matchData.opponent.avatarUrl}
|
||
grade={grade}
|
||
onDone={goToGame}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return <FindingScreen elapsed={queueElapsed} grade={grade} onBack={() => navigate('/dashboard')} />;
|
||
}
|