fixed so the code compiles
This commit is contained in:
+50
-383
@@ -2,31 +2,14 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
|
||||
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
function OpponentBar({ progress, total, name }) {
|
||||
const pct = (progress / total) * 100;
|
||||
return (
|
||||
<div style={{ marginTop: 8, padding: '8px 12px', background: '#fef9f0', borderRadius: 8, border: '1px solid #fce4c8', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', whiteSpace: 'nowrap', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.02em' }}>{name}</span>
|
||||
<div style={{ flex: 1, height: 8, borderRadius: 4, background: '#f5e6d3', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, #f39c12, #e67e22)', borderRadius: 4, transition: 'width 0.6s ease' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', minWidth: 36, textAlign: 'right', fontFamily: 'system-ui,sans-serif' }}>{Math.min(Math.round(progress), total)}/{total}</span>
|
||||
</div>
|
||||
);
|
||||
function authHeaders() {
|
||||
const token = sessionStorage.getItem('ranked_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
|
||||
function FindingScreen({ elapsed, onBack }) {
|
||||
const [dots, setDots] = useState('');
|
||||
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
@@ -40,165 +23,11 @@ function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
|
||||
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
|
||||
</div>
|
||||
<button onClick={onBack} style={{ marginTop: 8, padding: '10px 32px', fontSize: 15, fontWeight: 600, background: '#f5f5f5', color: '#555', border: '1px solid #ccc', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>‹ Back to Dashboard</button>
|
||||
{showForceMatch && (
|
||||
<button onClick={onForceMatch} style={{ padding: '14px 36px', fontSize: 16, fontWeight: 700, background: '#ff69b4', color: '#000', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif', boxShadow: '0 0 8px #ff69b4aa' }}>★ Force Match</button>
|
||||
)}
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerChip({ name, avatar, reverse }) {
|
||||
const pic = avatar
|
||||
? <img src={avatar} alt="" style={{ width: 56, height: 56, borderRadius: '50%', flexShrink: 0 }} referrerPolicy="no-referrer" />
|
||||
: <div style={{ width: 56, height: 56, borderRadius: '50%', background: '#1a73e8', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, fontWeight: 700, flexShrink: 0 }}>{name?.[0]?.toUpperCase() ?? '?'}</div>;
|
||||
const label = <span style={{ fontSize: 17, fontWeight: 700, color: '#222', fontFamily: 'system-ui,sans-serif' }}>{name}</span>;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: reverse ? 'row-reverse' : 'row', alignItems: 'center', gap: 12 }}>
|
||||
{pic}{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 28 }}>
|
||||
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Ranked Match</p>
|
||||
<h1 style={{ fontSize: 'clamp(30px,5vw,52px)', fontWeight: 800, color: '#111', margin: 0, textAlign: 'center' }}>EQAO Grade {gradeNum}</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<PlayerChip name={myName} avatar={myAvatar} reverse={false} />
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: '#aaa', letterSpacing: 2 }}>vs</span>
|
||||
<PlayerChip name={opponentName} avatar={opponentAvatar} reverse={true} />
|
||||
</div>
|
||||
<p style={{ fontSize: 14, color: '#aaa', margin: 0 }}>First to finish all {STAGE_SIZE} questions wins.</p>
|
||||
<div style={{ fontSize: 'clamp(88px,18vw,144px)', fontWeight: 900, color: '#e53935', lineHeight: 1, fontVariantNumeric: 'tabular-nums', minWidth: 120, textAlign: 'center' }}>{count}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<button key={`b-${qi}`} onClick={() => onJump && onJump(qi)} style={{ width: 36, height: 36, borderRadius: '50%', border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`, background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff', color: filled ? '#fff' : active ? '#1a73e8' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</button>
|
||||
);
|
||||
if (qi < total - 1) items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }} />);
|
||||
}
|
||||
return <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>{items}</div>;
|
||||
}
|
||||
|
||||
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName }) {
|
||||
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 (
|
||||
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
|
||||
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '20px 32px 16px' }}>
|
||||
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 14px 0', lineHeight: 1 }}>Question {qIdx + 1} of {STAGE_SIZE}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#1a73e8', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>You</span>
|
||||
<div style={{ flex: 1 }}><ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} /></div>
|
||||
</div>
|
||||
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
|
||||
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>{q.question}</p>
|
||||
<div style={q.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } : { display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{q.choices.map((choice, i) => {
|
||||
const sel = selected === i;
|
||||
return (
|
||||
<button key={i} onClick={() => onSelect(i)} style={{ minHeight: 64, padding: '18px 24px', background: sel ? '#e8f0fe' : '#f7f8fa', border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, borderRadius: 12, fontSize: 20, color: sel ? '#1a73e8' : '#222', textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, transition: 'background 0.12s, border-color 0.12s' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>{i + 1}</span>{choice}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
|
||||
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
||||
{qIdx === STAGE_SIZE - 1 ? (
|
||||
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>Submit ★</button>
|
||||
) : (
|
||||
<button style={NAV_BTN} onClick={onNext}>Next ›</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefeatOverlay({ opponentName, onViewResults }) {
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }}>
|
||||
<div style={{ background: '#fff', borderRadius: 16, padding: '40px 48px', textAlign: 'center', maxWidth: 380, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ fontSize: 64, marginBottom: 12 }}>💀</div>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Defeated!</h1>
|
||||
<p style={{ fontSize: 16, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>{opponentName} finished before you.</p>
|
||||
<button onClick={onViewResults} style={{ padding: '12px 36px', fontSize: 16, fontWeight: 600, background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>See Results</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingScreen({ opponentName, opponentProgress }) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#ff69b4', animation: 'spin 0.6s linear infinite' }} />
|
||||
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Waiting for {opponentName}...</h2>
|
||||
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>You finished! Now waiting for your opponent to complete.</p>
|
||||
<div style={{ width: 300, marginTop: 8 }}>
|
||||
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompleteScreen({ won, opponentName, duration, eloChange, onDashboard }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
||||
<div style={{ fontSize: 72 }}>{won ? '🏆' : '💀'}</div>
|
||||
<h1 style={{ fontSize: 36, fontWeight: 800, color: won ? '#1b8a2d' : '#c62828', margin: 0 }}>{won ? 'Victory!' : 'Defeated!'}</h1>
|
||||
<p style={{ fontSize: 17, color: '#555', maxWidth: 400, margin: 0 }}>{won ? `You beat ${opponentName}! Well played.` : `${opponentName} beat you. Better luck next time.`}</p>
|
||||
<div style={{ padding: '14px 40px', background: won ? '#f0faf0' : '#fef0f0', border: `2px solid ${won ? '#a5d6a7' : '#ef9a9a'}`, borderRadius: 12, fontSize: 24, fontWeight: 700, color: won ? '#1b8a2d' : '#c62828' }}>
|
||||
⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')}
|
||||
</div>
|
||||
{eloChange !== 0 && <div style={{ padding: '10px 24px', borderRadius: 8, fontSize: 18, fontWeight: 700, fontFamily: 'system-ui,sans-serif', color: eloChange > 0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}</div>}
|
||||
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Back to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = sessionStorage.getItem('ranked_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export default function Queue() {
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
@@ -206,73 +35,19 @@ export default function Queue() {
|
||||
|
||||
const [myName, setMyName] = useState('');
|
||||
const [myAvatar, setMyAvatar] = useState(null);
|
||||
|
||||
const [phase, setPhase] = useState('finding');
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [qIdx, setQIdx] = useState(0);
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [opponentProgress, setOpponentProgress] = useState(0);
|
||||
const [opponentName, setOpponentName] = useState('');
|
||||
const [opponentAvatar, setOpponentAvatar] = useState(null);
|
||||
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 [queueElapsed, setQueueElapsed] = useState(0);
|
||||
const [showForceMatch, setShowForceMatch] = useState(false);
|
||||
|
||||
const gameIdRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const completedRef = useRef(false);
|
||||
const submittedRef = useRef(false);
|
||||
const joinedRef = useRef(false);
|
||||
const completedRef = useRef(false);
|
||||
const leaveTimerRef = useRef(null);
|
||||
const leavingRef = useRef(false);
|
||||
|
||||
// Elapsed timer while finding
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (phase !== 'finding') return;
|
||||
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [phase]);
|
||||
|
||||
// Show force match after 15s
|
||||
useEffect(() => {
|
||||
if (phase !== 'finding') return;
|
||||
const id = setTimeout(() => setShowForceMatch(true), 15000);
|
||||
return () => clearTimeout(id);
|
||||
}, [phase]);
|
||||
|
||||
async function forceMatch() {
|
||||
clearInterval(pollRef.current);
|
||||
console.log('[queue] force match requested');
|
||||
try {
|
||||
const r = await fetch(`${API}/queue/debug`, { headers: authHeaders() });
|
||||
const debug = await r.json();
|
||||
console.log('[queue] debug state:', JSON.stringify(debug));
|
||||
} catch {}
|
||||
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 => {
|
||||
console.log('[queue] force match result:', JSON.stringify(data));
|
||||
if (!data) return;
|
||||
joinedRef.current = true;
|
||||
if (data.status === 'waiting') {
|
||||
pollQueue();
|
||||
} else if (data.status === 'matched') {
|
||||
fetchMatchData(data.gameId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch own user info
|
||||
useEffect(() => {
|
||||
@@ -288,43 +63,43 @@ export default function Queue() {
|
||||
|
||||
// Join queue on mount
|
||||
useEffect(() => {
|
||||
// Cancel any pending leave from a previous unmount (Strict Mode guard)
|
||||
// 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;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
console.log('[queue] joining with grade', grade);
|
||||
fetch(`${API}/queue/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ grade }),
|
||||
})
|
||||
.then(r => {
|
||||
if (r.status === 401) { console.log('[queue] 401 on join'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('[queue] join response:', JSON.stringify(data));
|
||||
if (!data || cancelled) return;
|
||||
if (!data || leavingRef.current) return;
|
||||
joinedRef.current = true;
|
||||
if (data.status === 'waiting') {
|
||||
pollQueue();
|
||||
} else if (data.status === 'matched') {
|
||||
fetchMatchData(data.gameId);
|
||||
fetchMatchData();
|
||||
}
|
||||
})
|
||||
.catch(e => console.log('[queue] join error:', e));
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
leavingRef.current = true;
|
||||
clearInterval(pollRef.current);
|
||||
if (joinedRef.current && !gameIdRef.current && !completedRef.current) {
|
||||
if (joinedRef.current && !completedRef.current) {
|
||||
leaveTimerRef.current = setTimeout(() => {
|
||||
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
|
||||
}, 300);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
}, [grade, navigate]);
|
||||
@@ -333,21 +108,15 @@ export default function Queue() {
|
||||
pollRef.current = setInterval(() => {
|
||||
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
||||
.then(r => {
|
||||
if (r.status === 401) { console.log('[queue] 401 on poll'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
if (!data || leavingRef.current) return;
|
||||
if (data.status === 'matched') {
|
||||
console.log('[queue] poll found match!');
|
||||
clearInterval(pollRef.current);
|
||||
gameIdRef.current = data.gameId;
|
||||
setQuestions(data.questions);
|
||||
setOpponentName(data.opponent.username ?? data.opponent.displayName);
|
||||
setOpponentAvatar(data.opponent.avatarUrl);
|
||||
setPhase('intro');
|
||||
navigateToGame(data);
|
||||
} else if (data.status === 'not_in_queue') {
|
||||
console.log('[queue] poll got not_in_queue, rejoining');
|
||||
clearInterval(pollRef.current);
|
||||
fetch(`${API}/queue/join`, {
|
||||
method: 'POST',
|
||||
@@ -355,151 +124,49 @@ export default function Queue() {
|
||||
body: JSON.stringify({ grade }),
|
||||
})
|
||||
.then(r => {
|
||||
if (r.status === 401) { console.log('[queue] 401 on rejoin'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(d => {
|
||||
console.log('[queue] rejoin response:', JSON.stringify(d));
|
||||
if (!d) return;
|
||||
if (!d || leavingRef.current) return;
|
||||
if (d.status === 'waiting') pollQueue();
|
||||
else if (d.status === 'matched') fetchMatchData(d.gameId);
|
||||
else if (d.status === 'matched') navigateToGame(d);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function fetchMatchData(gameId) {
|
||||
gameIdRef.current = gameId;
|
||||
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'matched') {
|
||||
setQuestions(data.questions);
|
||||
setOpponentName(data.opponent.username ?? data.opponent.displayName);
|
||||
setOpponentAvatar(data.opponent.avatarUrl);
|
||||
setPhase('intro');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
|
||||
fetch(`${API}/game/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ gameId: gameIdRef.current, currentQuestion, correctAnswers, finished, timeSpentMs }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function pollGameStatus() {
|
||||
const id = gameIdRef.current;
|
||||
if (!id) return;
|
||||
|
||||
pollRef.current = setInterval(() => {
|
||||
if (completedRef.current) { clearInterval(pollRef.current); return; }
|
||||
|
||||
fetch(`${API}/game/status/${id}`, { headers: authHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
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;
|
||||
if (opp.username) setOpponentName(opp.username);
|
||||
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 fetchMatchData() {
|
||||
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!leavingRef.current && data.status === 'matched') {
|
||||
navigateToGame(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
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);
|
||||
function navigateToGame(data) {
|
||||
clearInterval(pollRef.current);
|
||||
completedRef.current = true;
|
||||
navigate('/ranked-game', {
|
||||
state: {
|
||||
gameId: data.gameId,
|
||||
questions: data.questions,
|
||||
opponentName: data.opponent.username ?? data.opponent.displayName,
|
||||
opponentAvatar: data.opponent.avatarUrl,
|
||||
myName,
|
||||
myAvatar,
|
||||
grade,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
|
||||
|
||||
if (phase === 'finding')
|
||||
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} showForceMatch={showForceMatch} onForceMatch={forceMatch} />;
|
||||
|
||||
if (phase === 'intro')
|
||||
return <StageIntro grade={grade} myName={myName} myAvatar={myAvatar} opponentName={opponentName} opponentAvatar={opponentAvatar} onNext={() => { setPhase('question'); pollGameStatus(); }} />;
|
||||
|
||||
if (phase === 'question')
|
||||
return (
|
||||
<>
|
||||
<QuestionScreen
|
||||
question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected}
|
||||
onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump}
|
||||
opponentProgress={opponentProgress} opponentName={opponentName}
|
||||
/>
|
||||
{showDefeatOverlay && (
|
||||
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (phase === 'waiting')
|
||||
return (
|
||||
<>
|
||||
<WaitingScreen opponentName={opponentName} opponentProgress={opponentProgress} />
|
||||
{showDefeatOverlay && (
|
||||
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (phase === 'complete')
|
||||
return (
|
||||
<CompleteScreen
|
||||
won={playerWon} opponentName={opponentName} duration={duration}
|
||||
eloChange={eloChange} onDashboard={() => navigate('/dashboard')}
|
||||
/>
|
||||
);
|
||||
|
||||
return null;
|
||||
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user