finished many ui changes + polishing dopamine hits
This commit is contained in:
@@ -6,6 +6,8 @@ import Game from './pages/Game';
|
||||
import Queue from './pages/Queue';
|
||||
import RankedGame from './pages/RankedGame';
|
||||
import Settings from './pages/Settings';
|
||||
import About from './pages/About';
|
||||
import FAQ from './pages/FAQ';
|
||||
|
||||
function getValidStoredToken() {
|
||||
const token = sessionStorage.getItem('ranked_token');
|
||||
@@ -41,6 +43,8 @@ export default function App() {
|
||||
<Route path="/queue" element={(loggedIn || hasUrlToken()) ? <Queue /> : <Navigate to="/" replace />} />
|
||||
<Route path="/ranked-game" element={(loggedIn || hasUrlToken()) ? <RankedGame /> : <Navigate to="/" replace />} />
|
||||
<Route path="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/faq" element={<FAQ />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
|
||||
+44
-2
@@ -650,6 +650,19 @@ a { color: inherit; text-decoration: none; }
|
||||
background: var(--gold);
|
||||
color: #07080f;
|
||||
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.db-btn--gold::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.35) 50%, transparent 70%);
|
||||
background-size: 300% 100%;
|
||||
background-position: -200% center;
|
||||
animation: shimmerSweep 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.db-btn--gold:hover {
|
||||
@@ -995,11 +1008,15 @@ a { color: inherit; text-decoration: none; }
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.match-row:last-child { border-bottom: none; }
|
||||
.match-row:hover { background: rgba(255,255,255,0.02); }
|
||||
.match-row:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.match-row--win { border-left: 2px solid #22c55e; }
|
||||
.match-row--loss { border-left: 2px solid #ef4444; }
|
||||
@@ -2177,4 +2194,29 @@ a { color: inherit; text-decoration: none; }
|
||||
.dash-grade-tab { padding: 8px 14px; font-size: 12px; }
|
||||
.db-hero__name { font-size: clamp(38px, 13vw, 72px); }
|
||||
.db-grades { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Quiz nav button press feel */
|
||||
.quiz-nav-btn {
|
||||
transition: transform 0.1s ease !important;
|
||||
}
|
||||
.quiz-nav-btn:active {
|
||||
transform: scale(0.96) !important;
|
||||
}
|
||||
|
||||
@keyframes shimmerSweep {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
@keyframes rowFadeUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes gradeTabBounce {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(0.92); }
|
||||
70% { transform: scale(1.04); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@@ -149,10 +149,11 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) {
|
||||
<span className="lb-header__player">Player</span>
|
||||
<span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
|
||||
</div>
|
||||
{padded.map(entry => {
|
||||
{padded.map((entry, idx) => {
|
||||
const rowAnim = { animation: 'rowFadeUp 0.3s ease-out both', animationDelay: `${idx * 40}ms` };
|
||||
if (entry.ghost) {
|
||||
return (
|
||||
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost">
|
||||
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost" style={rowAnim}>
|
||||
<span className="lb-rank lb-rank--num">{entry.rank}</span>
|
||||
<div className="lb-user">
|
||||
<div className="lb-avatar lb-avatar--ghost" />
|
||||
@@ -167,7 +168,7 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) {
|
||||
const playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
|
||||
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
|
||||
return (
|
||||
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}>
|
||||
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`} style={rowAnim}>
|
||||
<span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
|
||||
{entry.rank}
|
||||
</span>
|
||||
@@ -193,40 +194,69 @@ function MatchHistory({ matches, loading }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}>
|
||||
{[0,1,2].map(i => <div key={i} className="skel-pill" style={{ height: 52, animationDelay: `${i*0.07}s` }} />)}
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="skel-pill" style={{ height: 60, animationDelay: `${i * 0.07}s` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!matches.length) {
|
||||
return (
|
||||
<div className="dash-empty">
|
||||
<div className="dash-empty__icon">⚔️</div>
|
||||
<span className="dash-empty__text">No ranked matches yet</span>
|
||||
<div className="dash-empty__icon">🕐</div>
|
||||
<span className="dash-empty__text">No games played yet</span>
|
||||
<span className="dash-empty__sub">Queue up to start climbing</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="match-history">
|
||||
{matches.map((m, i) => {
|
||||
{matches.slice(0, 5).map((m, i) => {
|
||||
const sign = m.eloChange >= 0 ? '+' : '';
|
||||
const eloDeltaColor = m.eloChange >= 0 ? '#1b8a2d' : '#c62828';
|
||||
return (
|
||||
<div key={m.id ?? i} className={`match-row${m.won ? ' match-row--win' : ' match-row--loss'}`}>
|
||||
<div className={`match-chip${m.won ? ' match-chip--win' : ' match-chip--loss'}`}>
|
||||
{m.won ? 'W' : 'L'}
|
||||
{/* Win/loss emoji icon */}
|
||||
<div
|
||||
className={`match-chip${m.won ? ' match-chip--win' : ' match-chip--loss'}`}
|
||||
style={{ fontSize: 20, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{m.won ? '🏆' : '💀'}
|
||||
</div>
|
||||
<div className="match-info">
|
||||
<span className="match-elo-after">{m.eloAfter.toLocaleString()} ELO</span>
|
||||
{m.timeSpentMs != null && (
|
||||
<span className="match-time">{fmtTime(m.timeSpentMs)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="match-right">
|
||||
<span className={`match-elo-delta${m.eloChange >= 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}>
|
||||
{sign}{m.eloChange}
|
||||
</span>
|
||||
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
|
||||
|
||||
{/* Center: grade badge · label · ELO delta · time · ago */}
|
||||
<div className="match-info" style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
|
||||
background: '#f0f4ff', color: '#1a73e8',
|
||||
letterSpacing: '0.04em', fontFamily: 'system-ui,sans-serif',
|
||||
}}>
|
||||
{m.grade}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: 700,
|
||||
color: m.won ? '#1b8a2d' : '#c62828',
|
||||
fontFamily: 'system-ui,sans-serif',
|
||||
}}>
|
||||
{m.won ? 'Victory' : 'Defeat'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 700, color: eloDeltaColor,
|
||||
fontFamily: 'system-ui,sans-serif',
|
||||
}}>
|
||||
{sign}{m.eloChange} ELO
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: ELO after */}
|
||||
<span className="match-elo-after" style={{ whiteSpace: 'nowrap' }}>
|
||||
{m.eloAfter?.toLocaleString()} ELO
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -273,6 +303,8 @@ export default function Dashboard() {
|
||||
const [matchLoading, setMatchLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [statsReady, setStatsReady] = useState(false);
|
||||
const [bouncingGrade, setBouncingGrade] = useState(null);
|
||||
const [onlineCount, setOnlineCount] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -327,6 +359,18 @@ export default function Dashboard() {
|
||||
.finally(() => setMatchLoading(false));
|
||||
}, [token, selectedGrade]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOnline = () => {
|
||||
fetch(`${API}/online-count`)
|
||||
.then(r => r.json())
|
||||
.then(d => setOnlineCount(d.online ?? 0))
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchOnline();
|
||||
const id = setInterval(fetchOnline, 30000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
|
||||
|
||||
const currentEntry = stats.find(e => e.grade === selectedGrade);
|
||||
@@ -402,7 +446,12 @@ export default function Dashboard() {
|
||||
<button
|
||||
key={g.id}
|
||||
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`}
|
||||
onClick={() => setSelectedGrade(g.id)}
|
||||
style={bouncingGrade === g.id ? { animation: 'gradeTabBounce 0.2s ease-out' } : {}}
|
||||
onClick={() => {
|
||||
setSelectedGrade(g.id);
|
||||
setBouncingGrade(g.id);
|
||||
setTimeout(() => setBouncingGrade(null), 200);
|
||||
}}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
@@ -420,6 +469,25 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Online count ── */}
|
||||
{onlineCount !== null && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 600, color: 'rgba(255,255,255,0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
background: '#4caf50',
|
||||
boxShadow: '0 0 6px rgba(76,175,80,0.8)',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
{onlineCount} {onlineCount === 1 ? 'player' : 'players'} online
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ELO + Tier progress ── */}
|
||||
<div className="db-elo-block">
|
||||
<div className="db-elo-block__inner">
|
||||
|
||||
+157
-32
@@ -10,16 +10,48 @@ function fmtDuration(ms) {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function GlobalStyles() {
|
||||
return (
|
||||
<style>{`
|
||||
@keyframes questionEnter { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
|
||||
@keyframes answerPulse { 0%{transform:scale(1)} 50%{transform:scale(0.97)} 100%{transform:scale(1)} }
|
||||
@keyframes submitGlow { from{box-shadow:0 0 8px #ff69b4aa} to{box-shadow:0 0 22px #ff69b4,0 0 40px #ff69b466} }
|
||||
@keyframes bubblePop { 0%{transform:scale(0.7)} 70%{transform:scale(1.1)} 100%{transform:scale(1)} }
|
||||
@keyframes titleFadeDown { from{opacity:0;transform:translateY(-16px)} to{opacity:1;transform:translateY(0)} }
|
||||
@keyframes statsFadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
|
||||
@keyframes spin { to{transform:rotate(360deg);} }
|
||||
`}</style>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProgressStrip ──────────────────────────────────────────────
|
||||
function ProgressStrip({ total, current, answers, onJump }) {
|
||||
const [fillCounts, setFillCounts] = useState({});
|
||||
const prevAnswersRef = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
const updates = {};
|
||||
for (let qi = 0; qi < total; qi++) {
|
||||
if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setFillCounts(prev => {
|
||||
const next = { ...prev };
|
||||
for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
prevAnswersRef.current = answers;
|
||||
}, [answers, total]);
|
||||
|
||||
const items = [];
|
||||
for (let qi = 0; qi < total; qi++) {
|
||||
const active = qi === current;
|
||||
const answered = answers[qi] != null;
|
||||
const filled = answered;
|
||||
const active = qi === current;
|
||||
const filled = answers[qi] != null;
|
||||
const popCount = fillCounts[qi] || 0;
|
||||
items.push(
|
||||
<button
|
||||
key={`b-${qi}`}
|
||||
key={`b-${qi}-${popCount}`}
|
||||
onClick={() => onJump && onJump(qi)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
@@ -30,6 +62,7 @@ function ProgressStrip({ total, current, answers, onJump }) {
|
||||
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',
|
||||
animation: popCount > 0 ? 'bubblePop 0.25s ease-out' : 'none',
|
||||
}}
|
||||
>
|
||||
{qi + 1}
|
||||
@@ -95,7 +128,10 @@ function StageIntro({ stage, onNext }) {
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit }) {
|
||||
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit, grade }) {
|
||||
const [pulsingIdx, setPulsingIdx] = useState(null);
|
||||
const [hoverIdx, setHoverIdx] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const i = +e.key - 1;
|
||||
@@ -108,16 +144,24 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
||||
}, [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={{ background: '#07080f', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
|
||||
<div className="arena-bg"><div className="arena-grain" /></div>
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 1, borderTop: '3px solid #1a73e8', boxShadow: '0 0 60px rgba(26,115,232,0.08), 0 0 40px rgba(0,60,120,0.10)' }}>
|
||||
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '24px 32px 20px' }}>
|
||||
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 20px 0', lineHeight: 1 }}>
|
||||
Question {qIdx + 1} of {STAGE_SIZE}
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px 0' }}>
|
||||
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: 0, lineHeight: 1, fontFamily: "'Barlow Condensed', system-ui, sans-serif", letterSpacing: '0.01em' }}>
|
||||
Question {qIdx + 1} of {STAGE_SIZE}
|
||||
</p>
|
||||
{grade && (
|
||||
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4, background: 'rgba(26,115,232,0.08)', border: '1px solid rgba(26,115,232,0.25)', color: '#1a73e8', letterSpacing: '0.06em', fontFamily: 'system-ui,sans-serif', flexShrink: 0 }}>
|
||||
{grade}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
|
||||
<div key={qIdx} style={{ flex: 1, padding: '28px 32px 24px', animation: 'questionEnter 0.2s ease-out' }}>
|
||||
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>
|
||||
{q.question}
|
||||
</p>
|
||||
@@ -127,20 +171,28 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
||||
}>
|
||||
{q.choices.map((choice, i) => {
|
||||
const sel = selected === i;
|
||||
const hov = hoverIdx === i && !sel;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(i)}
|
||||
onClick={() => {
|
||||
onSelect(i);
|
||||
setPulsingIdx(i);
|
||||
setTimeout(() => setPulsingIdx(p => p === i ? null : p), 150);
|
||||
}}
|
||||
onMouseEnter={() => setHoverIdx(i)}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
style={{
|
||||
minHeight: 64, padding: '18px 24px',
|
||||
background: sel ? '#e8f0fe' : '#f7f8fa',
|
||||
border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`,
|
||||
background: sel ? '#e8f0fe' : hov ? '#eef2ff' : '#f7f8fa',
|
||||
border: `2px solid ${sel ? '#1a73e8' : hov ? '#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',
|
||||
animation: pulsingIdx === i ? 'answerPulse 0.15s ease-out' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>
|
||||
@@ -156,13 +208,17 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
||||
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'space-between', gap: 14 }}>
|
||||
<button style={QUIT_BTN} onClick={onQuit}>Quit</button>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
||||
{qIdx > 0 && <button className="quiz-nav-btn" 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}>
|
||||
<button
|
||||
className="quiz-nav-btn"
|
||||
style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', animation: 'submitGlow 1.5s ease-in-out infinite alternate' }}
|
||||
onClick={onNext}
|
||||
>
|
||||
Submit ★
|
||||
</button>
|
||||
) : (
|
||||
<button style={NAV_BTN} onClick={onNext}>Next ›</button>
|
||||
<button className="quiz-nav-btn" style={NAV_BTN} onClick={onNext}>Next ›</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,22 +323,65 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on
|
||||
|
||||
function CompleteScreen({ duration, isNewBest, onDashboard }) {
|
||||
return (
|
||||
<div style={WRAP}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
||||
<div style={{ fontSize: 60 }}>🎉</div>
|
||||
<h1 style={{ fontSize: 30, fontWeight: 800, color: '#111' }}>Quiz Complete!</h1>
|
||||
<p style={{ fontSize: 17, color: '#555' }}>You completed both stages of the Mathematics quiz.</p>
|
||||
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
|
||||
⏱ {fmtDuration(duration)}
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'radial-gradient(ellipse at 50% 0%, rgba(245,197,24,0.12) 0%, transparent 60%), #07080f',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: 'system-ui,sans-serif', position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: 480, padding: '0 24px', textAlign: 'center', boxSizing: 'border-box' }}>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase',
|
||||
color: 'rgba(245,197,24,0.45)', marginBottom: 16,
|
||||
}}>
|
||||
Mathematics
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 'clamp(64px, 10vw, 96px)', fontWeight: 900,
|
||||
color: '#f5c518', letterSpacing: '0.05em', lineHeight: 1,
|
||||
fontFamily: '"Russo One", system-ui, sans-serif',
|
||||
animation: 'titleFadeDown 0.5s ease-out both',
|
||||
}}>
|
||||
COMPLETE
|
||||
</div>
|
||||
<div style={{ width: 80, height: 2, background: '#f5c518', margin: '16px auto 24px' }} />
|
||||
<div style={{
|
||||
fontSize: 'clamp(36px, 6vw, 56px)', fontWeight: 700, color: '#fff',
|
||||
fontFamily: 'monospace', letterSpacing: '0.08em',
|
||||
animation: 'statsFadeUp 0.4s ease-out 0.3s both',
|
||||
}}>
|
||||
{fmtDuration(duration)}
|
||||
</div>
|
||||
{isNewBest && (
|
||||
<div style={{ padding: '10px 28px', background: 'rgba(245,197,24,0.12)', border: '1.5px solid rgba(245,197,24,0.5)', borderRadius: 10, fontSize: 15, fontWeight: 700, color: '#b8860b', letterSpacing: '0.04em', boxShadow: '0 0 12px rgba(245,197,24,0.45)' }}>
|
||||
🏆 New Personal Best!
|
||||
<div style={{
|
||||
display: 'inline-block', marginTop: 16,
|
||||
padding: '8px 24px',
|
||||
background: 'rgba(245,197,24,0.12)',
|
||||
border: '1.5px solid rgba(245,197,24,0.5)',
|
||||
borderRadius: 999, fontSize: 14, fontWeight: 700,
|
||||
color: '#f5c518', letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
boxShadow: '0 0 12px rgba(245,197,24,0.35)',
|
||||
animation: 'statsFadeUp 0.4s ease-out 0.45s both',
|
||||
}}>
|
||||
New Personal Best!
|
||||
</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 style={{ marginTop: 32, animation: 'statsFadeUp 0.4s ease-out 0.55s both' }}>
|
||||
<button
|
||||
onClick={onDashboard}
|
||||
style={{
|
||||
padding: '14px 48px', borderRadius: 6, fontSize: 15, fontWeight: 700,
|
||||
letterSpacing: '0.08em', textTransform: 'uppercase', cursor: 'pointer',
|
||||
border: '1px solid rgba(245,197,24,0.5)',
|
||||
background: 'rgba(245,197,24,0.1)', color: '#f5c518',
|
||||
fontFamily: 'system-ui,sans-serif',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,0.2)'; e.currentTarget.style.borderColor = '#f5c518'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(245,197,24,0.1)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,0.5)'; }}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -295,6 +394,11 @@ export default function Game() {
|
||||
const { state } = useLocation();
|
||||
const grade = state?.grade ?? null;
|
||||
|
||||
if (!state) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const [phase, setPhase] = useState('loading');
|
||||
const [stage, setStage] = useState(1);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
@@ -306,6 +410,7 @@ export default function Game() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const [isNewBest, setIsNewBest] = useState(false);
|
||||
const [stageFlash, setStageFlash] = useState(false);
|
||||
|
||||
const timerStartRef = useRef(null);
|
||||
const intervalRef = useRef(null);
|
||||
@@ -422,12 +527,17 @@ export default function Game() {
|
||||
async function handleResultNext() {
|
||||
if (stage === 1) {
|
||||
s1CorrectRef.current = STAGE_SIZE - wrongCount;
|
||||
loadStage(2);
|
||||
setStageFlash(true);
|
||||
setTimeout(() => {
|
||||
setStageFlash(false);
|
||||
loadStage(2);
|
||||
}, 800);
|
||||
} else {
|
||||
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
|
||||
const token = sessionStorage.getItem('ranked_token');
|
||||
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
|
||||
stopTimer();
|
||||
console.log('Submitting best time:', grade, timeMs);
|
||||
let isNewBest = false;
|
||||
try {
|
||||
const res = await fetch(`${API}/me/best-time`, {
|
||||
@@ -454,11 +564,10 @@ export default function Game() {
|
||||
if (phase === 'loading') return (
|
||||
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />;
|
||||
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} onQuit={handleQuit} />;
|
||||
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} onQuit={handleQuit} grade={grade} />;
|
||||
if (phase === 'result') return <StageResult stage={stage} wrongCount={wrongCount} questions={questions} answers={answers} onRetry={handleRetry} onNext={handleResultNext} />;
|
||||
if (phase === 'complete') return <CompleteScreen duration={duration} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
|
||||
return null;
|
||||
@@ -466,6 +575,7 @@ export default function Game() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
{timerRunning && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 18, right: 24, zIndex: 200,
|
||||
@@ -480,6 +590,21 @@ export default function Game() {
|
||||
</div>
|
||||
)}
|
||||
{renderPhase()}
|
||||
{stageFlash && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: '#07080f',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 'clamp(28px, 5vw, 48px)', fontWeight: 900, color: '#f5c518',
|
||||
fontFamily: '"Russo One", system-ui, sans-serif',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
Stage 1 Complete.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+743
-117
@@ -1,139 +1,777 @@
|
||||
import { useState } from 'react';
|
||||
import LeaderboardModal from '../components/LeaderboardModal';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const API = import.meta.env.VITE_API_URL;
|
||||
|
||||
/* Animated background shapes — generated once, stable positions */
|
||||
const DIAMONDS = [
|
||||
{ left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' },
|
||||
{ left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' },
|
||||
{ left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' },
|
||||
{ left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' },
|
||||
{ left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' },
|
||||
{ left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' },
|
||||
{ left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' },
|
||||
{ left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' },
|
||||
{ left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' },
|
||||
{ left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' },
|
||||
{ left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' },
|
||||
{ left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' },
|
||||
{ left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' },
|
||||
{ left: '82%', top: '30%', size: 13, dur: '14s', delay: '1s' },
|
||||
{ left: '90%', top: '75%', size: 9, dur: '9s', delay: '4s' },
|
||||
{ left: '38%', top: '45%', size: 6, dur: '15s', delay: '2.5s' },
|
||||
{ left: '62%', top: '88%', size: 11, dur: '11s', delay: '0.3s' },
|
||||
{ left: '12%', top: '82%', size: 8, dur: '13s', delay: '1.8s' },
|
||||
{ left: '76%', top: '10%', size: 12, dur: '10s', delay: '3.5s' },
|
||||
];
|
||||
|
||||
const RINGS = [
|
||||
{ left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' },
|
||||
{ left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' },
|
||||
{ left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' },
|
||||
{ left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' },
|
||||
{ left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' },
|
||||
{ left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' },
|
||||
{ left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' },
|
||||
];
|
||||
|
||||
const SCANLINES = [
|
||||
{ top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
|
||||
{ top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' },
|
||||
{ top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' },
|
||||
{ top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
|
||||
{ top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' },
|
||||
{ top: '18%', width: '200px', left: '38%', dur: '12s', delay: '1.5s' },
|
||||
{ top: '75%', width: '140px', left: '20%', dur: '9s', delay: '2s' },
|
||||
];
|
||||
|
||||
const INPUT_STYLE = {
|
||||
width: '100%', padding: '12px 16px', background: 'rgba(255,255,255,0.07)',
|
||||
border: '1px solid rgba(255,255,255,0.18)', borderRadius: 10, color: '#fff',
|
||||
fontSize: 15, fontFamily: 'system-ui,sans-serif', outline: 'none', boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const ERROR_MSGS = {
|
||||
email_taken: 'An account with this email already exists.',
|
||||
username_taken: 'That username is already taken.',
|
||||
use_google: 'This account uses Google sign-in — use the Google button above.',
|
||||
invalid_credentials: 'Incorrect email or password.',
|
||||
invalid_email: 'Please enter a valid email address.',
|
||||
password_too_short: 'Password must be at least 8 characters.',
|
||||
invalid_username: 'Username must be 3–20 characters: letters, numbers, underscores only.',
|
||||
too_many_attempts: 'Too many attempts — please wait a few minutes and try again.',
|
||||
};
|
||||
|
||||
const RANK_COLORS = ['#f5c518', '#c0ccda', '#e0915a'];
|
||||
|
||||
function fmtTime(ms) {
|
||||
if (ms == null) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const LANDING_CSS = `
|
||||
@keyframes heroPulse {
|
||||
0%,100% { opacity:.2; transform:translate(-50%,-50%) scale(1); }
|
||||
50% { opacity:.38; transform:translate(-50%,-50%) scale(1.12); }
|
||||
}
|
||||
@keyframes wordReveal {
|
||||
from { opacity:0; transform:translateY(28px); }
|
||||
to { opacity:1; transform:translateY(0); }
|
||||
}
|
||||
@keyframes fadeUp {
|
||||
from { opacity:0; transform:translateY(16px); }
|
||||
to { opacity:1; transform:translateY(0); }
|
||||
}
|
||||
@keyframes scrollBounce {
|
||||
0%,100% { transform:translateY(0); }
|
||||
50% { transform:translateY(5px); }
|
||||
}
|
||||
.lp-hero-orb {
|
||||
position:absolute; top:50%; left:50%;
|
||||
width:1100px; height:700px;
|
||||
background:radial-gradient(ellipse at center,
|
||||
rgba(245,197,24,.07) 0%,
|
||||
rgba(37,99,235,.05) 45%,
|
||||
transparent 70%);
|
||||
border-radius:50%;
|
||||
pointer-events:none;
|
||||
animation:heroPulse 10s ease-in-out infinite;
|
||||
}
|
||||
.lp-word { display:block; animation:wordReveal .7s cubic-bezier(.16,1,.3,1) both; }
|
||||
.lp-word-1 { animation-delay:.15s; }
|
||||
.lp-word-2 { animation-delay:.3s; }
|
||||
.lp-word-3 { animation-delay:.45s; }
|
||||
.lp-nav {
|
||||
position:fixed; top:0; left:0; right:0; z-index:100;
|
||||
height:64px; display:flex; align-items:center;
|
||||
justify-content:space-between; padding:0 40px;
|
||||
background:rgba(7,8,15,.85);
|
||||
backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px);
|
||||
border-bottom:1px solid rgba(255,255,255,.07);
|
||||
}
|
||||
.lp-nav__logo {
|
||||
font-family:'Russo One',sans-serif; font-size:19px;
|
||||
letter-spacing:.04em; text-transform:uppercase;
|
||||
color:#eceef8; background:none; border:none; cursor:pointer; padding:0;
|
||||
}
|
||||
.lp-nav__logo span { color:#f5c518; }
|
||||
.lp-nav__links { display:flex; align-items:center; gap:28px; }
|
||||
.lp-nav-link {
|
||||
font-family:'Barlow Condensed',sans-serif; font-size:13px;
|
||||
font-weight:600; letter-spacing:.1em; text-transform:uppercase;
|
||||
color:#7a85a8; text-decoration:none; cursor:pointer;
|
||||
background:none; border:none; padding:0; transition:color .2s;
|
||||
}
|
||||
.lp-nav-link:hover { color:#eceef8; }
|
||||
.lp-nav-signin {
|
||||
padding:8px 18px;
|
||||
background:rgba(245,197,24,.08); border:1px solid rgba(245,197,24,.35);
|
||||
border-radius:6px; font-family:'Barlow Condensed',sans-serif;
|
||||
font-size:13px; font-weight:700; letter-spacing:.1em; text-transform:uppercase;
|
||||
color:#f5c518; cursor:pointer; transition:background .2s, border-color .2s;
|
||||
}
|
||||
.lp-nav-signin:hover { background:rgba(245,197,24,.16); border-color:rgba(245,197,24,.6); }
|
||||
.lp-container { width:100%; max-width:1100px; margin:0 auto; padding:0 24px; }
|
||||
.lp-eyebrow {
|
||||
font-family:'Barlow Condensed',sans-serif; font-size:11px;
|
||||
font-weight:700; letter-spacing:.3em; text-transform:uppercase;
|
||||
color:#f5c518; display:flex; align-items:center; justify-content:center;
|
||||
gap:12px; margin-bottom:14px;
|
||||
}
|
||||
.lp-eyebrow::before,.lp-eyebrow::after {
|
||||
content:''; display:block; width:36px; height:1px;
|
||||
background:rgba(245,197,24,.4);
|
||||
}
|
||||
.lp-section-title {
|
||||
font-family:'Russo One',sans-serif;
|
||||
font-size:clamp(30px,5vw,48px); text-transform:uppercase;
|
||||
letter-spacing:.02em; color:#eceef8;
|
||||
text-align:center; margin-bottom:56px;
|
||||
}
|
||||
.lp-step-card {
|
||||
background:#111426; border:1px solid rgba(255,255,255,.07);
|
||||
border-radius:16px; padding:36px 28px;
|
||||
position:relative; overflow:hidden; flex:1; min-width:260px;
|
||||
transition:border-color .25s, transform .25s;
|
||||
}
|
||||
.lp-step-card::before {
|
||||
content:''; position:absolute; top:0; left:0; right:0; height:2px;
|
||||
background:linear-gradient(to right,transparent,rgba(245,197,24,.65),transparent);
|
||||
}
|
||||
.lp-step-card:hover { border-color:rgba(245,197,24,.22); transform:translateY(-4px); }
|
||||
.lp-feature-card {
|
||||
background:#111426; border:1px solid rgba(255,255,255,.07);
|
||||
border-radius:12px; padding:28px 24px;
|
||||
transition:border-color .25s, transform .25s;
|
||||
}
|
||||
.lp-feature-card:hover { border-color:rgba(245,197,24,.2); transform:translateY(-3px); }
|
||||
.lp-lb-card {
|
||||
background:#111426; border:1px solid rgba(255,255,255,.07);
|
||||
border-radius:16px; overflow:hidden; max-width:580px; margin:0 auto;
|
||||
}
|
||||
.lp-lb-row {
|
||||
display:flex; align-items:center; gap:14px; padding:13px 20px;
|
||||
border-bottom:1px solid rgba(255,255,255,.06); transition:background .15s;
|
||||
}
|
||||
.lp-lb-row:last-child { border-bottom:none; }
|
||||
.lp-lb-row:hover { background:rgba(255,255,255,.02); }
|
||||
.lp-signin-card {
|
||||
background:rgba(17,20,38,.96); border:1px solid rgba(255,255,255,.1);
|
||||
border-radius:20px; padding:40px 36px; max-width:440px; margin:0 auto;
|
||||
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
|
||||
}
|
||||
@media (max-width:768px) {
|
||||
.lp-nav { padding:0 20px; }
|
||||
.lp-nav-link.hide-mobile { display:none; }
|
||||
.lp-signin-card { padding:28px 20px; }
|
||||
.lp-step-card { min-width:100%; }
|
||||
}
|
||||
@media (max-width:500px) {
|
||||
.lp-nav__links { gap:12px; }
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Login() {
|
||||
const [lbOpen, setLbOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed';
|
||||
|
||||
const [authMode, setAuthMode] = useState('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const [lbGrade, setLbGrade] = useState('G3');
|
||||
const [lbEntries, setLbEntries] = useState([]);
|
||||
const [lbLoading, setLbLoading] = useState(true);
|
||||
|
||||
function resetForm() { setEmail(''); setPassword(''); setUsername(''); setFormError(''); }
|
||||
|
||||
async function handleEmailAuth(e) {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const isRegister = authMode === 'register';
|
||||
const body = isRegister
|
||||
? { email, password, ...(username.trim() ? { username: username.trim() } : {}) }
|
||||
: { email, password };
|
||||
const res = await fetch(`${API}/auth/${isRegister ? 'register' : 'login'}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setFormError(ERROR_MSGS[data.error] ?? 'Something went wrong. Please try again.'); return; }
|
||||
sessionStorage.setItem('ranked_token', data.token);
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch {
|
||||
setFormError('Something went wrong. Check your connection.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLbLoading(true);
|
||||
fetch(`${API}/leaderboard?grade=${lbGrade}&type=time`)
|
||||
.then(r => r.json())
|
||||
.then(d => setLbEntries(Array.isArray(d) ? d.slice(0, 5) : []))
|
||||
.catch(() => setLbEntries([]))
|
||||
.finally(() => setLbLoading(false));
|
||||
}, [lbGrade]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authError) setTimeout(() => scrollTo('signin'), 400);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function scrollTo(id) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeaderboardModal isOpen={lbOpen} onClose={() => setLbOpen(false)} />
|
||||
<style>{LANDING_CSS}</style>
|
||||
|
||||
{/* Animated background */}
|
||||
{/* Fixed animated background */}
|
||||
<div className="arena-bg">
|
||||
<div className="arena-grain" />
|
||||
<div className="arena-shapes">
|
||||
{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,
|
||||
}}
|
||||
/>
|
||||
<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 }} />
|
||||
))}
|
||||
{RINGS.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="shape shape--ring"
|
||||
style={{
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
width: r.size,
|
||||
height: r.size,
|
||||
animationDuration: r.dur,
|
||||
animationDelay: r.delay,
|
||||
}}
|
||||
/>
|
||||
<div key={i} className="shape shape--ring" style={{ left: r.left, top: r.top, width: r.size, height: r.size, animationDuration: r.dur, animationDelay: r.delay }} />
|
||||
))}
|
||||
{SCANLINES.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="shape shape--line"
|
||||
style={{
|
||||
top: s.top,
|
||||
left: s.left,
|
||||
width: s.width,
|
||||
animationDuration: s.dur,
|
||||
animationDelay: s.delay,
|
||||
}}
|
||||
/>
|
||||
<div key={i} className="shape shape--line" style={{ top: s.top, left: s.left, width: s.width, animationDuration: s.dur, animationDelay: s.delay }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="page login">
|
||||
<p className="login__eyebrow">Ontario Standardized Tests</p>
|
||||
|
||||
<div className="login__hero">
|
||||
<h1 className="login__title">
|
||||
<span>Ranked</span>
|
||||
EQAO
|
||||
</h1>
|
||||
<p className="login__tagline">
|
||||
Compete. Practice. <em>Dominate</em> your grade.
|
||||
</p>
|
||||
{/* ── Sticky Nav ─────────────────────────────────────── */}
|
||||
<nav className="lp-nav">
|
||||
<button className="lp-nav__logo" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
|
||||
RANKED<span>EQAO</span>
|
||||
</button>
|
||||
<div className="lp-nav__links">
|
||||
<a href="#how-it-works" className="lp-nav-link hide-mobile">How It Works</a>
|
||||
<a href="#leaderboard" className="lp-nav-link hide-mobile">Leaderboard</a>
|
||||
<a href="/about" className="lp-nav-link hide-mobile">About</a>
|
||||
<a href="/faq" className="lp-nav-link hide-mobile">FAQ</a>
|
||||
<button className="lp-nav-signin" onClick={() => scrollTo('signin')}>Sign In</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="grade-badges">
|
||||
{[
|
||||
{ id: 'G3', label: 'Grade 3' },
|
||||
{ id: 'G6', label: 'Grade 6' },
|
||||
{ id: 'G9', label: 'Grade 9' },
|
||||
].map(({ id, label }) => (
|
||||
<div key={id} className="grade-badge">
|
||||
<span className="grade-badge__rank">{id}</span>
|
||||
<span className="grade-badge__label">{label}</span>
|
||||
{/* ── Page ───────────────────────────────────────────── */}
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
|
||||
{/* ── HERO ─────────────────────────────────────────── */}
|
||||
<section style={{
|
||||
minHeight: '100vh', display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: '100px 0 80px', textAlign: 'center',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div className="lp-hero-orb" />
|
||||
|
||||
<div className="lp-container" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<p style={{
|
||||
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: '0.3em', textTransform: 'uppercase', color: '#f5c518',
|
||||
marginBottom: 28, opacity: 0, animation: 'fadeUp .6s .05s ease both',
|
||||
}}>
|
||||
Ontario Standardized Tests
|
||||
</p>
|
||||
|
||||
<h1 style={{
|
||||
fontFamily: "'Russo One',sans-serif",
|
||||
fontSize: 'clamp(68px,14vw,148px)',
|
||||
lineHeight: 0.9, letterSpacing: '-0.01em',
|
||||
textTransform: 'uppercase', marginBottom: 36,
|
||||
}}>
|
||||
<span className="lp-word lp-word-1" style={{ color: '#eceef8' }}>COMPETE.</span>
|
||||
<span className="lp-word lp-word-2" style={{ color: '#eceef8' }}>PRACTICE.</span>
|
||||
<span className="lp-word lp-word-3" style={{ color: '#f5c518', textShadow: '0 0 60px rgba(245,197,24,.4)' }}>DOMINATE.</span>
|
||||
</h1>
|
||||
|
||||
<p style={{
|
||||
fontFamily: "'Barlow',sans-serif", fontSize: 'clamp(15px,2.4vw,19px)',
|
||||
color: '#9ba5c4', maxWidth: 560, margin: '0 auto 44px', lineHeight: 1.65,
|
||||
opacity: 0, animation: 'fadeUp .6s .6s ease both',
|
||||
}}>
|
||||
The ranked EQAO quiz platform for Ontario students. Practice Grade 3, 6, and 9 math — then go head-to-head against real opponents.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap',
|
||||
marginBottom: 56, opacity: 0, animation: 'fadeUp .6s .75s ease both',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => scrollTo('signin')}
|
||||
style={{
|
||||
padding: '15px 38px', background: '#f5c518', color: '#07080f',
|
||||
border: 'none', borderRadius: 10,
|
||||
fontFamily: "'Russo One',sans-serif", fontSize: 17,
|
||||
letterSpacing: '0.04em', textTransform: 'uppercase', cursor: 'pointer',
|
||||
boxShadow: '0 0 32px rgba(245,197,24,.35), 0 4px 16px rgba(0,0,0,.4)',
|
||||
transition: 'transform .15s, box-shadow .2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 0 52px rgba(245,197,24,.5), 0 8px 24px rgba(0,0,0,.5)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 0 32px rgba(245,197,24,.35), 0 4px 16px rgba(0,0,0,.4)'; }}
|
||||
>
|
||||
Start Playing
|
||||
</button>
|
||||
<a
|
||||
href="#how-it-works"
|
||||
style={{
|
||||
padding: '15px 38px', background: 'transparent', color: '#eceef8',
|
||||
border: '1.5px solid rgba(255,255,255,.22)', borderRadius: 10,
|
||||
fontFamily: "'Russo One',sans-serif", fontSize: 17,
|
||||
letterSpacing: '0.04em', textTransform: 'uppercase',
|
||||
textDecoration: 'none', display: 'inline-flex', alignItems: 'center',
|
||||
transition: 'border-color .2s, color .2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.5)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.22)'; }}
|
||||
>
|
||||
How It Works
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="login__cta">
|
||||
{authError && (
|
||||
<p className="login__error">Sign-in failed — please try again.</p>
|
||||
)}
|
||||
<a href={`${API}/auth/google`} className="btn-google">
|
||||
<GoogleIcon />
|
||||
Sign in with Google
|
||||
</a>
|
||||
<p className="login__fine">Free · No credit card required</p>
|
||||
</div>
|
||||
{/* Grade badges */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap',
|
||||
opacity: 0, animation: 'fadeUp .6s .9s ease both',
|
||||
}}>
|
||||
{[{ id: 'G3', label: 'Grade 3' }, { id: 'G6', label: 'Grade 6' }, { id: 'G9', label: 'Grade 9' }].map(({ id, label }) => (
|
||||
<div key={id} className="grade-badge">
|
||||
<span className="grade-badge__rank">{id}</span>
|
||||
<span className="grade-badge__label">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="login__footer">
|
||||
Ranked EQAO · Built for Ontario Students
|
||||
{/* Scroll hint */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 32, left: '50%', transform: 'translateX(-50%)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
|
||||
opacity: 0, animation: 'fadeUp .6s 1.2s ease both',
|
||||
}}>
|
||||
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 10, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#7a85a8' }}>Scroll</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(122,133,168,.55)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ animation: 'scrollBounce 2s ease-in-out infinite' }}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── HOW IT WORKS ─────────────────────────────────── */}
|
||||
<section id="how-it-works" style={{
|
||||
padding: '100px 0',
|
||||
background: 'rgba(13,15,28,.78)',
|
||||
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
|
||||
}}>
|
||||
<div className="lp-container">
|
||||
<p className="lp-eyebrow">Get Started</p>
|
||||
<h2 className="lp-section-title">How It Works</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{
|
||||
num: '01',
|
||||
icon: (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Sign In',
|
||||
desc: "Create a free account with Google or email. Pick your grade — G3, G6, or G9 — and you're ready to start.",
|
||||
},
|
||||
{
|
||||
num: '02',
|
||||
icon: (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Practice',
|
||||
desc: 'Answer 22 EQAO-style math questions across two stages. Every question must be correct to advance — no partial credit, no shortcuts.',
|
||||
},
|
||||
{
|
||||
num: '03',
|
||||
icon: (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Compete',
|
||||
desc: 'Join the ranked queue and race against a real opponent. Your ELO rises or falls on the result. Climb from Bronze all the way to Diamond.',
|
||||
},
|
||||
].map((step, i) => (
|
||||
<div key={i} className="lp-step-card">
|
||||
<span style={{
|
||||
fontFamily: "'Russo One',sans-serif", fontSize: 64, lineHeight: 1,
|
||||
color: 'rgba(245,197,24,.1)', position: 'absolute', top: 16, right: 22,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{step.num}
|
||||
</span>
|
||||
<div style={{
|
||||
width: 52, height: 52,
|
||||
background: 'rgba(245,197,24,.07)', border: '1px solid rgba(245,197,24,.2)',
|
||||
borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
{step.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 22, fontWeight: 700,
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 10,
|
||||
}}>
|
||||
{step.title}
|
||||
</h3>
|
||||
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 15, color: '#7a85a8', lineHeight: 1.65 }}>
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FEATURES ─────────────────────────────────────── */}
|
||||
<section id="features" style={{ padding: '100px 0' }}>
|
||||
<div className="lp-container">
|
||||
<p className="lp-eyebrow">The Platform</p>
|
||||
<h2 className="lp-section-title">Why Ranked EQAO?</h2>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(290px,1fr))', gap: 16 }}>
|
||||
{[
|
||||
{
|
||||
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M9 7h6M9 11h6M9 15h4"/></svg>,
|
||||
title: 'Real EQAO-Style Questions',
|
||||
desc: 'Randomized values on every attempt — same problem structure, different numbers. You can\'t just memorize answers.',
|
||||
},
|
||||
{
|
||||
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>,
|
||||
title: 'Live 1v1 Matchmaking',
|
||||
desc: 'Race against real students in real time. First to correctly complete all questions wins the match and earns ELO.',
|
||||
},
|
||||
{
|
||||
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>,
|
||||
title: 'ELO Ranking System',
|
||||
desc: 'Climb from Bronze through Silver, Gold, Platinum, and Diamond. Your rating reflects actual performance against real opponents.',
|
||||
},
|
||||
{
|
||||
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
|
||||
title: 'Best Time Leaderboard',
|
||||
desc: 'Every second counts. Your fastest completion time is tracked separately on the global leaderboard.',
|
||||
},
|
||||
{
|
||||
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
|
||||
title: 'Grade-Specific Content',
|
||||
desc: 'Separate queues, leaderboards, and ELO ratings for Grade 3, 6, and 9. Difficulty scales with each grade level.',
|
||||
},
|
||||
{
|
||||
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>,
|
||||
title: '100% Free',
|
||||
desc: 'No ads, no paywalls, no subscriptions. Ranked EQAO is completely free for every Ontario student.',
|
||||
},
|
||||
].map((f, i) => (
|
||||
<div key={i} className="lp-feature-card">
|
||||
<div style={{
|
||||
width: 44, height: 44,
|
||||
background: 'rgba(245,197,24,.07)', border: '1px solid rgba(245,197,24,.18)',
|
||||
borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 18, fontWeight: 700,
|
||||
letterSpacing: '0.05em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 8,
|
||||
}}>
|
||||
{f.title}
|
||||
</h3>
|
||||
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 14, color: '#7a85a8', lineHeight: 1.65 }}>
|
||||
{f.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── LEADERBOARD PREVIEW ───────────────────────────── */}
|
||||
<section id="leaderboard" style={{
|
||||
padding: '100px 0',
|
||||
background: 'rgba(13,15,28,.78)',
|
||||
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
|
||||
}}>
|
||||
<div className="lp-container">
|
||||
<p className="lp-eyebrow">Rankings</p>
|
||||
<h2 className="lp-section-title">Top Players</h2>
|
||||
|
||||
{/* Grade tabs */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 28 }}>
|
||||
{['G3', 'G6', 'G9'].map(g => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setLbGrade(g)}
|
||||
style={{
|
||||
padding: '7px 24px',
|
||||
background: lbGrade === g ? 'rgba(245,197,24,.12)' : 'rgba(17,20,38,.8)',
|
||||
border: `1px solid ${lbGrade === g ? 'rgba(245,197,24,.5)' : 'rgba(255,255,255,.1)'}`,
|
||||
borderRadius: 8,
|
||||
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, fontWeight: 700,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
color: lbGrade === g ? '#f5c518' : '#7a85a8',
|
||||
cursor: 'pointer', transition: 'all .15s',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lp-lb-card">
|
||||
{/* Table header */}
|
||||
<div style={{ display: 'flex', gap: 14, padding: '10px 20px', borderBottom: '1px solid rgba(255,255,255,.07)' }}>
|
||||
{['#', 'Player', 'Best Time'].map((h, i) => (
|
||||
<span key={h} style={{
|
||||
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 10, fontWeight: 700,
|
||||
letterSpacing: '0.16em', textTransform: 'uppercase', color: '#7a85a8',
|
||||
minWidth: i === 0 ? 36 : undefined, textAlign: i === 0 ? 'center' : undefined,
|
||||
flex: i === 1 ? 1 : undefined,
|
||||
}}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{lbLoading ? (
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="skel-pill" style={{ height: 44, animationDelay: `${i * 0.06}s` }} />
|
||||
))}
|
||||
</div>
|
||||
) : lbEntries.length === 0 ? (
|
||||
<div style={{ padding: '48px 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 12, opacity: 0.4 }}>🏆</div>
|
||||
<p style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 15, color: '#7a85a8' }}>No scores yet — be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
lbEntries.map((entry, i) => {
|
||||
const rc = i < 3 ? RANK_COLORS[i] : '#7a85a8';
|
||||
const playerLabel = entry.username ? `@${entry.username}` : (entry.displayName ?? 'Player');
|
||||
const initial = playerLabel[0]?.toUpperCase() ?? '?';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="lp-lb-row"
|
||||
style={i === 0 ? { borderLeft: '2px solid #f5c518', paddingLeft: 18 } : {}}
|
||||
>
|
||||
<span style={{
|
||||
fontFamily: "'Russo One',sans-serif", fontSize: 15, color: rc,
|
||||
textShadow: i < 3 ? `0 0 12px ${rc}60` : 'none',
|
||||
minWidth: 36, textAlign: 'center',
|
||||
}}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
||||
{entry.avatarUrl
|
||||
? <img src={entry.avatarUrl} alt={playerLabel} referrerPolicy="no-referrer" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, border: i < 3 ? `1.5px solid ${rc}55` : '1px solid rgba(255,255,255,.1)' }} />
|
||||
: <div style={{ width: 28, height: 28, borderRadius: '50%', background: '#181c32', border: '1px solid rgba(255,255,255,.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: "'Russo One',sans-serif", fontSize: 11, color: '#9ba5c4', flexShrink: 0 }}>{initial}</div>
|
||||
}
|
||||
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 15, fontWeight: 600, color: '#eceef8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{playerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 16, fontWeight: 700,
|
||||
letterSpacing: '0.05em', color: i === 0 ? '#f5c518' : '#eceef8',
|
||||
}}>
|
||||
{fmtTime(entry.bestTime)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
||||
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 15, color: '#7a85a8', marginBottom: 16 }}>
|
||||
Think you can crack the top 5?
|
||||
</p>
|
||||
<button
|
||||
onClick={() => scrollTo('signin')}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
background: 'rgba(245,197,24,.08)', border: '1px solid rgba(245,197,24,.35)',
|
||||
borderRadius: 8, fontFamily: "'Barlow Condensed',sans-serif",
|
||||
fontSize: 14, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
color: '#f5c518', cursor: 'pointer', transition: 'background .2s, border-color .2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,.17)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(245,197,24,.08)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,.35)'; }}
|
||||
>
|
||||
Sign In to Compete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── SIGN IN ───────────────────────────────────────── */}
|
||||
<section id="signin" style={{ padding: '100px 24px 120px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 44 }}>
|
||||
<p className="lp-eyebrow">Join the Arena</p>
|
||||
<h2 style={{
|
||||
fontFamily: "'Russo One',sans-serif",
|
||||
fontSize: 'clamp(30px,5vw,48px)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.02em', color: '#eceef8',
|
||||
}}>
|
||||
Ready to Play?
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="lp-signin-card">
|
||||
<div className="login__cta" style={{ marginTop: 0 }}>
|
||||
|
||||
{authError && <p className="login__error">Sign-in failed — please try again.</p>}
|
||||
|
||||
{/* Google button */}
|
||||
<a href={`${API}/auth/google`} className="btn-google" style={{ width: '100%', justifyContent: 'center', boxSizing: 'border-box' }}>
|
||||
<GoogleIcon />Sign in with Google
|
||||
</a>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,.12)' }} />
|
||||
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, color: 'rgba(255,255,255,.35)', letterSpacing: '0.05em' }}>— or —</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,.12)' }} />
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div style={{ display: 'flex', width: '100%', background: 'rgba(255,255,255,.05)', borderRadius: 10, padding: 3, gap: 3, boxSizing: 'border-box' }}>
|
||||
{['login', 'register'].map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => { resetForm(); setAuthMode(mode); }}
|
||||
style={{
|
||||
flex: 1, padding: '9px 0', border: 'none', borderRadius: 8,
|
||||
fontSize: 14, fontWeight: 600,
|
||||
fontFamily: "'Barlow Condensed',sans-serif", letterSpacing: '0.06em',
|
||||
cursor: 'pointer', transition: 'all .15s',
|
||||
background: authMode === mode ? 'rgba(255,255,255,.11)' : 'transparent',
|
||||
color: authMode === mode ? '#fff' : 'rgba(255,255,255,.4)',
|
||||
}}
|
||||
>
|
||||
{mode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Email form */}
|
||||
<form onSubmit={handleEmailAuth} style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<input type="email" required placeholder="Email address" value={email} onChange={e => setEmail(e.target.value)} style={INPUT_STYLE} autoComplete="email" />
|
||||
<input type="password" required placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} style={INPUT_STYLE} autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} />
|
||||
{authMode === 'register' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username (3–20 chars, letters/numbers/_)"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
pattern="^[a-zA-Z0-9_]{3,20}$"
|
||||
title="3–20 characters: letters, numbers, underscores only"
|
||||
style={INPUT_STYLE}
|
||||
autoComplete="username"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{
|
||||
width: '100%', padding: '13px',
|
||||
background: '#1a73e8', color: '#fff',
|
||||
border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 700,
|
||||
fontFamily: "'Barlow Condensed',sans-serif", letterSpacing: '0.06em',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
opacity: submitting ? 0.7 : 1, transition: 'opacity .2s',
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
{formError && <p className="login__error" style={{ margin: 0 }}>{formError}</p>}
|
||||
</form>
|
||||
|
||||
<p style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 12, letterSpacing: '0.05em', color: 'rgba(255,255,255,.3)', textAlign: 'center' }}>
|
||||
Free · No credit card required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FOOTER ───────────────────────────────────────── */}
|
||||
<footer style={{
|
||||
background: 'rgba(7,8,15,.95)',
|
||||
borderTop: '1px solid rgba(255,255,255,.07)',
|
||||
padding: '40px 24px',
|
||||
}}>
|
||||
<div className="lp-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18 }}>
|
||||
<span style={{ fontFamily: "'Russo One',sans-serif", fontSize: 16, letterSpacing: '0.04em', color: '#eceef8' }}>
|
||||
RANKED<span style={{ color: '#f5c518' }}>EQAO</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{[
|
||||
{ label: 'How It Works', href: '#how-it-works' },
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Sign In', href: null, onClick: () => scrollTo('signin') },
|
||||
].map(({ label, href, onClick }) =>
|
||||
href ? (
|
||||
<a
|
||||
key={label} href={href}
|
||||
style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', color: '#7a85a8', textDecoration: 'none', transition: 'color .2s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#eceef8'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#7a85a8'}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
key={label} onClick={onClick}
|
||||
style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', color: '#7a85a8', background: 'none', border: 'none', cursor: 'pointer', padding: 0, transition: 'color .2s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#eceef8'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#7a85a8'}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 11, color: 'rgba(122,133,168,.45)', letterSpacing: '0.04em', textAlign: 'center' }}>
|
||||
Ranked EQAO — Built for Ontario students · Not affiliated with EQAO or the Ontario government.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<button className="lb-trophy-btn" onClick={() => setLbOpen(true)} aria-label="Leaderboard">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -141,22 +779,10 @@ export default function Login() {
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+403
-25
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
|
||||
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||
@@ -9,21 +9,380 @@ function authHeaders() {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function FindingScreen({ elapsed, onBack }) {
|
||||
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('');
|
||||
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
|
||||
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', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
|
||||
<div style={{ width: 64, height: 64, borderRadius: '50%', border: '4px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
|
||||
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Searching for opponent{dots}</h2>
|
||||
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>Searching all players...</p>
|
||||
<div style={{ fontSize: 14, color: '#666', fontFamily: 'system-ui,sans-serif', fontWeight: 500, letterSpacing: '0.02em' }}>
|
||||
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
|
||||
<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>
|
||||
<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>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,9 +392,10 @@ export default function Queue() {
|
||||
const { state } = useLocation();
|
||||
const grade = state?.grade ?? null;
|
||||
|
||||
const [myName, setMyName] = useState('');
|
||||
const [myAvatar, setMyAvatar] = useState(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);
|
||||
@@ -43,11 +403,12 @@ export default function Queue() {
|
||||
const leaveTimerRef = useRef(null);
|
||||
const leavingRef = useRef(false);
|
||||
|
||||
// Elapsed timer
|
||||
// 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(() => {
|
||||
@@ -115,7 +476,7 @@ export default function Queue() {
|
||||
if (!data || leavingRef.current) return;
|
||||
if (data.status === 'matched') {
|
||||
clearInterval(pollRef.current);
|
||||
navigateToGame(data);
|
||||
showMatchup(data);
|
||||
} else if (data.status === 'not_in_queue') {
|
||||
clearInterval(pollRef.current);
|
||||
fetch(`${API}/queue/join`, {
|
||||
@@ -130,7 +491,7 @@ export default function Queue() {
|
||||
.then(d => {
|
||||
if (!d || leavingRef.current) return;
|
||||
if (d.status === 'waiting') pollQueue();
|
||||
else if (d.status === 'matched') navigateToGame(d);
|
||||
else if (d.status === 'matched') showMatchup(d);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -144,29 +505,46 @@ export default function Queue() {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!leavingRef.current && data.status === 'matched') {
|
||||
navigateToGame(data);
|
||||
showMatchup(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function navigateToGame(data) {
|
||||
function showMatchup(data) {
|
||||
clearInterval(pollRef.current);
|
||||
completedRef.current = true;
|
||||
setMatchData(data);
|
||||
}
|
||||
|
||||
const goToGame = useCallback(() => {
|
||||
navigate('/ranked-game', {
|
||||
state: {
|
||||
gameId: data.gameId,
|
||||
questions: data.questions,
|
||||
opponentName: data.opponent.username ?? data.opponent.displayName,
|
||||
opponentAvatar: data.opponent.avatarUrl,
|
||||
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 />;
|
||||
|
||||
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} />;
|
||||
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')} />;
|
||||
}
|
||||
|
||||
+1138
-138
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user