finished many ui changes + polishing dopamine hits

This commit is contained in:
2026-07-11 12:27:35 -04:00
parent 4b89a3b034
commit a8c41a6852
15 changed files with 2952 additions and 554 deletions
Vendored
BIN
View File
Binary file not shown.
-142
View File
@@ -1,142 +0,0 @@
# Ranked EQAO — Claude Code Guide
## Project Overview
A ranked quiz app based on EQAO (Ontario standardized tests).
Users sign in with Google, answer questions by grade and subject,
and compete on leaderboards.
## Tech Stack
- **Runtime:** Node.js + Express
- **Database:** PostgreSQL via Prisma ORM
- **Auth:** Google OAuth 2.0 (Passport.js) + JWT
- **AI:** Gemini API (passage + question generation for English; answer checking)
- **Frontend:** React (Vite) — `frontend/` directory
## Grades
Only three grades are supported: **3, 6, 9**
- In the schema these are the `Grade` enum: `G3`, `G6`, `G9`
- Never use raw integers for grade — always use the enum
## Subjects
Two sections per quiz: `MATH` and `ENGLISH` (reading + writing combined)
- The Prisma `Subject` enum currently has `MATH`, `READING`, `WRITING` — these remain as-is for DB storage
- In the UI and game logic, treat READING + WRITING as a single **English** section
## Project Structure
```
/
├── prisma/
│ └── schema.prisma # All DB models
├── src/
│ ├── db.js # Prisma singleton
│ ├── auth/
│ │ └── google.js # Google OAuth + JWT issuing
│ ├── middleware/
│ │ └── auth.js # JWT verification middleware
│ └── services/
│ └── leaderboard.js # Leaderboard upsert + query logic
├── frontend/
│ └── src/
│ ├── pages/
│ │ ├── Login.jsx # Pre-login landing page
│ │ ├── Dashboard.jsx # Post-login dashboard (to be built)
│ │ └── MathGame.jsx # SCRAPPED — delete this file
│ ├── App.jsx
│ ├── index.css
│ └── main.jsx
├── .env # Never commit this
├── CLAUDE.md
└── package.json
```
## Environment Variables
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `GOOGLE_CLIENT_ID` | Google OAuth app client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth app client secret |
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI |
| `JWT_SECRET` | Secret for signing JWTs |
| `GEMINI_API_KEY` | Gemini API key for English question/passage generation and answer checking |
| `PORT` | Express server port (default 3000) |
| `FRONTEND_URL` | Frontend origin for CORS |
## Key Conventions
- Always use `Grade` enum (`G3`, `G6`, `G9`) — never raw numbers
- Always use `Subject` enum (`MATH`, `READING`, `WRITING`)
- All routes that touch user data require the `auth.js` JWT middleware
- Leaderboard is scoped per `[userId, grade, subject]` — unique constraint enforced in DB
- Sessions track full answer history via `UserAnswer` for future analytics
## Database Commands
```bash
npx prisma migrate dev --name <migration_name> # new migration
npx prisma generate # regenerate client after schema change
npx prisma studio # visual DB browser
```
---
## Frontend Pages
### Login.jsx (Pre-login Landing Page) — KEEP, DO NOT ADD DEBUG BUTTON
- The current pre-login landing page and Google sign-in button are good — preserve this design
- The page will eventually be a longer scrollable page with a top navigation bar linking to info sections and other useful tabs (TBD before release)
- **DO NOT add any debug/bypass button that skips Google login** — all such buttons must be removed if found anywhere in the codebase
- For now, leave the landing page as-is; extended layout/nav is a pre-release task
### MathGame.jsx — SCRAPPED, DELETE THIS FILE
- `frontend/src/pages/MathGame.jsx` is fully scrapped — delete it
- Remove all routes, imports, and links pointing to MathGame anywhere in the codebase
- The actual EQAO quiz experience will be built fresh (see Quiz UI section below)
### Dashboard.jsx (Post-login) — TO BE BUILT
- Replaces the current "Coming Soon" placeholder
- Should be a rich, well-designed dashboard with multiple sections/widgets, for example:
- User greeting with avatar
- Stats summary (games played, best scores, rank per grade)
- Leaderboard preview
- Recent activity
- A **"Start Game"** button (prominent CTA) — the button exists in the UI but does NOT wire up to the quiz yet; the quiz flow is not being programmed at this stage
- The exact set of dashboard widgets/tabs is TBD — design it to look complete and polished even if most data is placeholder
---
## Quiz UI (To Be Built — Design Reference)
### General Flow
The quiz covers **two sections**: Math and English. The user completes them in order.
After finishing a section, the app checks answers and shows one of two outcomes:
- **100% correct** → proceed to the next section (or end the quiz if both are done)
- **At least 1 wrong** → must redo the section; keep retrying until 100% correct before advancing
There is no partial credit / moving on with mistakes — the player must get a perfect section to continue.
### Math Section
- Questions are **pre-generated** (not from Gemini) but with **randomised values each time** so the same question template produces different numbers on each attempt
- Questions should be creative and contextual — avoid trivial `a + b` style arithmetic; think word problems, patterns, multi-step reasoning appropriate to the grade level
- Multiple choice format (matching the original EQAO UI style — see design reference below)
### English Section
- **Gemini API** generates a reading passage appropriate for the grade
- Gemini also generates comprehension questions based on that passage
- Gemini checks free-response and paragraph writing answers (not just multiple choice)
- The section combines reading comprehension and a short writing component
### Quiz UI Design
- The actual in-quiz UI (question display, answer choices, progress) should stay **visually identical or very close to the original MathGame design** — same layout, card style, fonts, colour scheme
- Reference the existing `index.css` (`.math-game`, `.choice-btn`, `.grade-btn`, etc.) and replicate that look for the new quiz component
- Upload of reference screenshots is pending — when provided, match them closely
---
## What's Not Built Yet
- [ ] API routes (auth, sessions, leaderboard, questions)
- [ ] Gemini service for English passage + question generation and answer checking
- [BUILT] Math question templates with randomised values
- [BUILT] Quiz game component (replaces scrapped MathGame.jsx)
- [HALF-BUILT] Post-login Dashboard (rich version, not "Coming Soon" placeholder)
- [HALF-BUILT] Pre-login landing page extended layout + top nav (pre-release)
- [ ] Question seeding/storage strategy
- [ ] Frontend wiring for "Start Game" button
+4
View File
@@ -6,6 +6,8 @@ import Game from './pages/Game';
import Queue from './pages/Queue'; import Queue from './pages/Queue';
import RankedGame from './pages/RankedGame'; import RankedGame from './pages/RankedGame';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import About from './pages/About';
import FAQ from './pages/FAQ';
function getValidStoredToken() { function getValidStoredToken() {
const token = sessionStorage.getItem('ranked_token'); 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="/queue" element={(loggedIn || hasUrlToken()) ? <Queue /> : <Navigate to="/" replace />} />
<Route path="/ranked-game" element={(loggedIn || hasUrlToken()) ? <RankedGame /> : <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="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
<Route path="/about" element={<About />} />
<Route path="/faq" element={<FAQ />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>
+44 -2
View File
@@ -650,6 +650,19 @@ a { color: inherit; text-decoration: none; }
background: var(--gold); background: var(--gold);
color: #07080f; color: #07080f;
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4); 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 { .db-btn--gold:hover {
@@ -995,11 +1008,15 @@ a { color: inherit; text-decoration: none; }
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--border); 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: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--win { border-left: 2px solid #22c55e; }
.match-row--loss { border-left: 2px solid #ef4444; } .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; } .dash-grade-tab { padding: 8px 14px; font-size: 12px; }
.db-hero__name { font-size: clamp(38px, 13vw, 72px); } .db-hero__name { font-size: clamp(38px, 13vw, 72px); }
.db-grades { grid-template-columns: 1fr; } .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); }
} }
+89 -21
View File
@@ -149,10 +149,11 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) {
<span className="lb-header__player">Player</span> <span className="lb-header__player">Player</span>
<span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span> <span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
</div> </div>
{padded.map(entry => { {padded.map((entry, idx) => {
const rowAnim = { animation: 'rowFadeUp 0.3s ease-out both', animationDelay: `${idx * 40}ms` };
if (entry.ghost) { if (entry.ghost) {
return ( 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> <span className="lb-rank lb-rank--num">{entry.rank}</span>
<div className="lb-user"> <div className="lb-user">
<div className="lb-avatar lb-avatar--ghost" /> <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 playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?'; const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
return ( 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}> <span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
{entry.rank} {entry.rank}
</span> </span>
@@ -193,40 +194,69 @@ function MatchHistory({ matches, loading }) {
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}> <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> </div>
); );
} }
if (!matches.length) { if (!matches.length) {
return ( return (
<div className="dash-empty"> <div className="dash-empty">
<div className="dash-empty__icon"></div> <div className="dash-empty__icon">🕐</div>
<span className="dash-empty__text">No ranked matches yet</span> <span className="dash-empty__text">No games played yet</span>
<span className="dash-empty__sub">Queue up to start climbing</span> <span className="dash-empty__sub">Queue up to start climbing</span>
</div> </div>
); );
} }
return ( return (
<div className="match-history"> <div className="match-history">
{matches.map((m, i) => { {matches.slice(0, 5).map((m, i) => {
const sign = m.eloChange >= 0 ? '+' : ''; const sign = m.eloChange >= 0 ? '+' : '';
const eloDeltaColor = m.eloChange >= 0 ? '#1b8a2d' : '#c62828';
return ( return (
<div key={m.id ?? i} className={`match-row${m.won ? ' match-row--win' : ' match-row--loss'}`}> <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'}`}> {/* Win/loss emoji icon */}
{m.won ? 'W' : 'L'} <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>
<div className="match-info">
<span className="match-elo-after">{m.eloAfter.toLocaleString()} ELO</span> {/* Center: grade badge · label · ELO delta · time · ago */}
{m.timeSpentMs != null && ( <div className="match-info" style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
<span className="match-time">{fmtTime(m.timeSpentMs)}</span> <div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
)} <span style={{
</div> fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
<div className="match-right"> background: '#f0f4ff', color: '#1a73e8',
<span className={`match-elo-delta${m.eloChange >= 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}> letterSpacing: '0.04em', fontFamily: 'system-ui,sans-serif',
{sign}{m.eloChange} }}>
</span> {m.grade}
<span className="match-ago">{fmtRelative(m.playedAt)}</span> </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> </div>
{/* Right: ELO after */}
<span className="match-elo-after" style={{ whiteSpace: 'nowrap' }}>
{m.eloAfter?.toLocaleString()} ELO
</span>
</div> </div>
); );
})} })}
@@ -273,6 +303,8 @@ export default function Dashboard() {
const [matchLoading, setMatchLoading] = useState(false); const [matchLoading, setMatchLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [statsReady, setStatsReady] = useState(false); const [statsReady, setStatsReady] = useState(false);
const [bouncingGrade, setBouncingGrade] = useState(null);
const [onlineCount, setOnlineCount] = useState(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -327,6 +359,18 @@ export default function Dashboard() {
.finally(() => setMatchLoading(false)); .finally(() => setMatchLoading(false));
}, [token, selectedGrade]); }, [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 signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
const currentEntry = stats.find(e => e.grade === selectedGrade); const currentEntry = stats.find(e => e.grade === selectedGrade);
@@ -402,7 +446,12 @@ export default function Dashboard() {
<button <button
key={g.id} key={g.id}
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`} 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} {g.label}
</button> </button>
@@ -420,6 +469,25 @@ export default function Dashboard() {
</button> </button>
</div> </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 ── */} {/* ── ELO + Tier progress ── */}
<div className="db-elo-block"> <div className="db-elo-block">
<div className="db-elo-block__inner"> <div className="db-elo-block__inner">
+157 -32
View File
@@ -10,16 +10,48 @@ function fmtDuration(ms) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; 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 ────────────────────────────────────────────── // ── ProgressStrip ──────────────────────────────────────────────
function ProgressStrip({ total, current, answers, onJump }) { 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 = []; const items = [];
for (let qi = 0; qi < total; qi++) { for (let qi = 0; qi < total; qi++) {
const active = qi === current; const active = qi === current;
const answered = answers[qi] != null; const filled = answers[qi] != null;
const filled = answered; const popCount = fillCounts[qi] || 0;
items.push( items.push(
<button <button
key={`b-${qi}`} key={`b-${qi}-${popCount}`}
onClick={() => onJump && onJump(qi)} onClick={() => onJump && onJump(qi)}
style={{ style={{
width: 36, height: 36, borderRadius: '50%', 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', fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif',
flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box',
transition: 'background 0.15s, border-color 0.15s', transition: 'background 0.15s, border-color 0.15s',
animation: popCount > 0 ? 'bubblePop 0.25s ease-out' : 'none',
}} }}
> >
{qi + 1} {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(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
const i = +e.key - 1; 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]); }, [q.choices.length, qIdx, onSelect, onNext, onBack]);
return ( return (
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}> <div style={{ background: '#07080f', 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 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' }}> <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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px 0' }}>
Question {qIdx + 1} of {STAGE_SIZE} <p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: 0, lineHeight: 1, fontFamily: "'Barlow Condensed', system-ui, sans-serif", letterSpacing: '0.01em' }}>
</p> 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} /> <ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} />
</div> </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' }}> <p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>
{q.question} {q.question}
</p> </p>
@@ -127,20 +171,28 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
}> }>
{q.choices.map((choice, i) => { {q.choices.map((choice, i) => {
const sel = selected === i; const sel = selected === i;
const hov = hoverIdx === i && !sel;
return ( return (
<button <button
key={i} 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={{ style={{
minHeight: 64, padding: '18px 24px', minHeight: 64, padding: '18px 24px',
background: sel ? '#e8f0fe' : '#f7f8fa', background: sel ? '#e8f0fe' : hov ? '#eef2ff' : '#f7f8fa',
border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, border: `2px solid ${sel ? '#1a73e8' : hov ? '#1a73e8' : '#e0e0e0'}`,
borderRadius: 12, fontSize: 20, borderRadius: 12, fontSize: 20,
color: sel ? '#1a73e8' : '#222', color: sel ? '#1a73e8' : '#222',
textAlign: 'left', cursor: 'pointer', textAlign: 'left', cursor: 'pointer',
fontWeight: sel ? 600 : 400, fontWeight: sel ? 600 : 400,
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
transition: 'background 0.12s, border-color 0.12s', 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 }}> <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 }}> <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> <button style={QUIT_BTN} onClick={onQuit}>Quit</button>
<div style={{ display: 'flex', gap: 14 }}> <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 ? ( {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 Submit
</button> </button>
) : ( ) : (
<button style={NAV_BTN} onClick={onNext}>Next </button> <button className="quiz-nav-btn" style={NAV_BTN} onClick={onNext}>Next </button>
)} )}
</div> </div>
</div> </div>
@@ -267,22 +323,65 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on
function CompleteScreen({ duration, isNewBest, onDashboard }) { function CompleteScreen({ duration, isNewBest, onDashboard }) {
return ( return (
<div style={WRAP}> <div style={{
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}> minHeight: '100vh',
<div style={{ fontSize: 60 }}>🎉</div> background: 'radial-gradient(ellipse at 50% 0%, rgba(245,197,24,0.12) 0%, transparent 60%), #07080f',
<h1 style={{ fontSize: 30, fontWeight: 800, color: '#111' }}>Quiz Complete!</h1> display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
<p style={{ fontSize: 17, color: '#555' }}>You completed both stages of the Mathematics quiz.</p> fontFamily: 'system-ui,sans-serif', position: 'relative', overflow: 'hidden',
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}> }}>
{fmtDuration(duration)} <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> </div>
{isNewBest && ( {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)' }}> <div style={{
🏆 New Personal Best! 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> </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' }}> <div style={{ marginTop: 32, animation: 'statsFadeUp 0.4s ease-out 0.55s both' }}>
Back to Dashboard <button
</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>
</div> </div>
); );
@@ -295,6 +394,11 @@ export default function Game() {
const { state } = useLocation(); const { state } = useLocation();
const grade = state?.grade ?? null; const grade = state?.grade ?? null;
if (!state) {
navigate('/dashboard', { replace: true });
return null;
}
const [phase, setPhase] = useState('loading'); const [phase, setPhase] = useState('loading');
const [stage, setStage] = useState(1); const [stage, setStage] = useState(1);
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
@@ -306,6 +410,7 @@ export default function Game() {
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [fetchError, setFetchError] = useState(false); const [fetchError, setFetchError] = useState(false);
const [isNewBest, setIsNewBest] = useState(false); const [isNewBest, setIsNewBest] = useState(false);
const [stageFlash, setStageFlash] = useState(false);
const timerStartRef = useRef(null); const timerStartRef = useRef(null);
const intervalRef = useRef(null); const intervalRef = useRef(null);
@@ -422,12 +527,17 @@ export default function Game() {
async function handleResultNext() { async function handleResultNext() {
if (stage === 1) { if (stage === 1) {
s1CorrectRef.current = STAGE_SIZE - wrongCount; s1CorrectRef.current = STAGE_SIZE - wrongCount;
loadStage(2); setStageFlash(true);
setTimeout(() => {
setStageFlash(false);
loadStage(2);
}, 800);
} else { } else {
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount)); endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
const token = sessionStorage.getItem('ranked_token'); const token = sessionStorage.getItem('ranked_token');
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0; const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
stopTimer(); stopTimer();
console.log('Submitting best time:', grade, timeMs);
let isNewBest = false; let isNewBest = false;
try { try {
const res = await fetch(`${API}/me/best-time`, { const res = await fetch(`${API}/me/best-time`, {
@@ -454,11 +564,10 @@ export default function Game() {
if (phase === 'loading') return ( if (phase === 'loading') return (
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center' }}> <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' }} /> <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> </div>
); );
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />; 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 === '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')} />; if (phase === 'complete') return <CompleteScreen duration={duration} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
return null; return null;
@@ -466,6 +575,7 @@ export default function Game() {
return ( return (
<> <>
<GlobalStyles />
{timerRunning && ( {timerRunning && (
<div style={{ <div style={{
position: 'fixed', top: 18, right: 24, zIndex: 200, position: 'fixed', top: 18, right: 24, zIndex: 200,
@@ -480,6 +590,21 @@ export default function Game() {
</div> </div>
)} )}
{renderPhase()} {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
View File
@@ -1,139 +1,777 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import LeaderboardModal from '../components/LeaderboardModal'; import { useNavigate } from 'react-router-dom';
const API = import.meta.env.VITE_API_URL; const API = import.meta.env.VITE_API_URL;
/* Animated background shapes — generated once, stable positions */
const DIAMONDS = [ const DIAMONDS = [
{ left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' }, { left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' },
{ left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' }, { left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' },
{ left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' }, { left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' },
{ left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' }, { left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' },
{ left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' }, { left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' },
{ left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' }, { left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' },
{ left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' }, { 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 = [ const RINGS = [
{ left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' }, { left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' },
{ left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' }, { left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' },
{ left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' }, { left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' },
{ left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' },
]; ];
const SCANLINES = [ const SCANLINES = [
{ top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' }, { top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
{ top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' }, { top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' },
{ top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' }, { 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 320 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() { export default function Login() {
const [lbOpen, setLbOpen] = useState(false); const navigate = useNavigate();
const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed'; 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 ( return (
<> <>
<LeaderboardModal isOpen={lbOpen} onClose={() => setLbOpen(false)} /> <style>{LANDING_CSS}</style>
{/* Animated background */} {/* Fixed animated background */}
<div className="arena-bg"> <div className="arena-bg">
<div className="arena-grain" /> <div className="arena-grain" />
<div className="arena-shapes"> <div className="arena-shapes">
{DIAMONDS.map((d, i) => ( {DIAMONDS.map((d, i) => (
<div <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 }} />
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) => ( {RINGS.map((r, i) => (
<div <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 }} />
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) => ( {SCANLINES.map((s, i) => (
<div <div key={i} className="shape shape--line" style={{ top: s.top, left: s.left, width: s.width, animationDuration: s.dur, animationDelay: s.delay }} />
key={i}
className="shape shape--line"
style={{
top: s.top,
left: s.left,
width: s.width,
animationDuration: s.dur,
animationDelay: s.delay,
}}
/>
))} ))}
</div> </div>
</div> </div>
{/* Page content */} {/* ── Sticky Nav ─────────────────────────────────────── */}
<main className="page login"> <nav className="lp-nav">
<p className="login__eyebrow">Ontario Standardized Tests</p> <button className="lp-nav__logo" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
RANKED<span>EQAO</span>
<div className="login__hero"> </button>
<h1 className="login__title"> <div className="lp-nav__links">
<span>Ranked</span> <a href="#how-it-works" className="lp-nav-link hide-mobile">How It Works</a>
EQAO <a href="#leaderboard" className="lp-nav-link hide-mobile">Leaderboard</a>
</h1> <a href="/about" className="lp-nav-link hide-mobile">About</a>
<p className="login__tagline"> <a href="/faq" className="lp-nav-link hide-mobile">FAQ</a>
Compete. Practice. <em>Dominate</em> your grade. <button className="lp-nav-signin" onClick={() => scrollTo('signin')}>Sign In</button>
</p>
</div> </div>
</nav>
<div className="grade-badges"> {/* ── Page ───────────────────────────────────────────── */}
{[ <div style={{ position: 'relative', zIndex: 1 }}>
{ id: 'G3', label: 'Grade 3' },
{ id: 'G6', label: 'Grade 6' }, {/* ── HERO ─────────────────────────────────────────── */}
{ id: 'G9', label: 'Grade 9' }, <section style={{
].map(({ id, label }) => ( minHeight: '100vh', display: 'flex', flexDirection: 'column',
<div key={id} className="grade-badge"> alignItems: 'center', justifyContent: 'center',
<span className="grade-badge__rank">{id}</span> padding: '100px 0 80px', textAlign: 'center',
<span className="grade-badge__label">{label}</span> 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>
<div className="login__cta"> {/* Grade badges */}
{authError && ( <div style={{
<p className="login__error">Sign-in failed please try again.</p> display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap',
)} opacity: 0, animation: 'fadeUp .6s .9s ease both',
<a href={`${API}/auth/google`} className="btn-google"> }}>
<GoogleIcon /> {[{ id: 'G3', label: 'Grade 3' }, { id: 'G6', label: 'Grade 6' }, { id: 'G9', label: 'Grade 9' }].map(({ id, label }) => (
Sign in with Google <div key={id} className="grade-badge">
</a> <span className="grade-badge__rank">{id}</span>
<p className="login__fine">Free · No credit card required</p> <span className="grade-badge__label">{label}</span>
</div> </div>
))}
</div>
</div>
<footer className="login__footer"> {/* Scroll hint */}
Ranked EQAO · Built for Ontario Students <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 (320 chars, letters/numbers/_)"
value={username}
onChange={e => setUsername(e.target.value)}
pattern="^[a-zA-Z0-9_]{3,20}$"
title="320 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 &nbsp;·&nbsp; Not affiliated with EQAO or the Ontario government.
</p>
</div>
</footer> </footer>
</main>
<button className="lb-trophy-btn" onClick={() => setLbOpen(true)} aria-label="Leaderboard"> </div>
<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>
</> </>
); );
} }
@@ -141,22 +779,10 @@ export default function Login() {
function GoogleIcon() { function GoogleIcon() {
return ( return (
<svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true"> <svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true">
<path <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" />
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" <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" />
fill="#4285F4" <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="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> </svg>
); );
} }
+403 -25
View File
@@ -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'; import { useNavigate, useLocation, Navigate } from 'react-router-dom';
const VALID_GRADES = new Set(['G3', 'G6', 'G9']); const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
@@ -9,21 +9,380 @@ function authHeaders() {
return token ? { Authorization: `Bearer ${token}` } : {}; 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(''); 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 minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60; const seconds = elapsed % 60;
const gradeNum = grade?.replace('G', '') ?? '?';
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}> <div style={{ minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontFamily: 'system-ui,sans-serif', overflow: 'hidden' }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', border: '4px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} /> <style>{`
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Searching for opponent{dots}</h2> @keyframes spinCW { to { transform: rotate(360deg); } }
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>Searching all players...</p> @keyframes spinCCW { to { transform: rotate(-360deg); } }
<div style={{ fontSize: 14, color: '#666', fontFamily: 'system-ui,sans-serif', fontWeight: 500, letterSpacing: '0.02em' }}> `}</style>
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
{/* Arena background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{QUEUE_DIAMONDS.map((d, i) => (
<div
key={i}
className="shape shape--diamond"
style={{
left: d.left, top: d.top,
width: d.size, height: d.size,
animationDuration: d.dur, animationDelay: d.delay,
opacity: 0.35,
}}
/>
))}
</div>
</div>
{/* Content */}
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24 }}>
{/* Two counter-rotating rings */}
<div style={{ position: 'relative', width: 80, height: 80 }}>
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
border: '3.5px solid rgba(245,197,24,0.12)',
borderTopColor: '#f5c518', borderRightColor: 'rgba(245,197,24,0.45)',
animation: 'spinCW 1.2s linear infinite',
}} />
<div style={{
position: 'absolute', inset: 13, borderRadius: '50%',
border: '3px solid rgba(79,195,247,0.12)',
borderTopColor: '#4fc3f7', borderLeftColor: 'rgba(79,195,247,0.45)',
animation: 'spinCCW 0.85s linear infinite',
}} />
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%,-50%)',
width: 7, height: 7, borderRadius: '50%',
background: '#f5c518',
boxShadow: '0 0 10px rgba(245,197,24,0.9)',
}} />
</div>
{/* Grade label */}
<p style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.22em',
textTransform: 'uppercase', color: 'rgba(245,197,24,0.45)',
margin: 0,
}}>
Grade {gradeNum} · Ranked
</p>
{/* Heading */}
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#eceef8', margin: 0, letterSpacing: '0.01em' }}>
Searching for opponent{dots}
</h2>
{/* Players online */}
<p style={{
fontSize: 13, color: 'rgba(245,197,24,0.55)',
margin: 0, letterSpacing: '0.04em',
}}>
{playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'}
</p>
{/* Elapsed timer — large gold */}
<div style={{
fontSize: 48, fontWeight: 700,
color: '#f5c518', fontFamily: 'monospace',
letterSpacing: '0.06em', lineHeight: 1,
textShadow: '0 0 28px rgba(245,197,24,0.35)',
}}>
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</div>
{/* Back button */}
<button
onClick={onBack}
style={{
marginTop: 8, padding: '10px 32px',
fontSize: 14, fontWeight: 600,
background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.45)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 999, cursor: 'pointer',
fontFamily: 'system-ui,sans-serif',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.25)';
e.currentTarget.style.color = 'rgba(255,255,255,0.75)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
e.currentTarget.style.color = 'rgba(255,255,255,0.45)';
}}
>
Back to Dashboard
</button>
</div> </div>
<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> </div>
); );
} }
@@ -33,9 +392,10 @@ export default function Queue() {
const { state } = useLocation(); const { state } = useLocation();
const grade = state?.grade ?? null; const grade = state?.grade ?? null;
const [myName, setMyName] = useState(''); const [myName, setMyName] = useState(() => getNameFromToken());
const [myAvatar, setMyAvatar] = useState(null); const [myAvatar, setMyAvatar] = useState(() => getAvatarFromToken());
const [queueElapsed, setQueueElapsed] = useState(0); const [queueElapsed, setQueueElapsed] = useState(0);
const [matchData, setMatchData] = useState(null);
const pollRef = useRef(null); const pollRef = useRef(null);
const joinedRef = useRef(false); const joinedRef = useRef(false);
@@ -43,11 +403,12 @@ export default function Queue() {
const leaveTimerRef = useRef(null); const leaveTimerRef = useRef(null);
const leavingRef = useRef(false); const leavingRef = useRef(false);
// Elapsed timer // Elapsed timer — stop once matched
useEffect(() => { useEffect(() => {
if (matchData) return;
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000); const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, [matchData]);
// Fetch own user info // Fetch own user info
useEffect(() => { useEffect(() => {
@@ -115,7 +476,7 @@ export default function Queue() {
if (!data || leavingRef.current) return; if (!data || leavingRef.current) return;
if (data.status === 'matched') { if (data.status === 'matched') {
clearInterval(pollRef.current); clearInterval(pollRef.current);
navigateToGame(data); showMatchup(data);
} else if (data.status === 'not_in_queue') { } else if (data.status === 'not_in_queue') {
clearInterval(pollRef.current); clearInterval(pollRef.current);
fetch(`${API}/queue/join`, { fetch(`${API}/queue/join`, {
@@ -130,7 +491,7 @@ export default function Queue() {
.then(d => { .then(d => {
if (!d || leavingRef.current) return; if (!d || leavingRef.current) return;
if (d.status === 'waiting') pollQueue(); if (d.status === 'waiting') pollQueue();
else if (d.status === 'matched') navigateToGame(d); else if (d.status === 'matched') showMatchup(d);
}) })
.catch(() => {}); .catch(() => {});
} }
@@ -144,29 +505,46 @@ export default function Queue() {
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (!leavingRef.current && data.status === 'matched') { if (!leavingRef.current && data.status === 'matched') {
navigateToGame(data); showMatchup(data);
} }
}) })
.catch(() => {}); .catch(() => {});
} }
function navigateToGame(data) { function showMatchup(data) {
clearInterval(pollRef.current); clearInterval(pollRef.current);
completedRef.current = true; completedRef.current = true;
setMatchData(data);
}
const goToGame = useCallback(() => {
navigate('/ranked-game', { navigate('/ranked-game', {
state: { state: {
gameId: data.gameId, gameId: matchData.gameId,
questions: data.questions, questions: matchData.questions,
opponentName: data.opponent.username ?? data.opponent.displayName, opponentName: matchData.opponent.username ?? matchData.opponent.displayName,
opponentAvatar: data.opponent.avatarUrl, opponentAvatar: matchData.opponent.avatarUrl,
myName, myName,
myAvatar, myAvatar,
grade, grade,
}, },
}); });
} }, [matchData, myName, myAvatar, grade, navigate]);
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />; 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')} />;
} }
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,8 +1,14 @@
Prisma DB: npx prisma studio Prisma DB for debugging: npx prisma studio
Express server: node src/index.js Express server: node src/index.js
Run: cd frontend && npm run dev Run: cd frontend && npm run dev
KILL 3000: kill $(lsof -ti:3000) KILL 3000: kill $(lsof -ti:3000)
push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main
pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main
push at school: git push origin main
pull at school: git pull --rebase origin main
john repomix: npx repomix --ignore "node_modules,frontend/node_modules,.git,uploads,prisma/migrations,frontend/public,frontend/dist,*.svg,*.png,*.jpg,*.jpeg,repomix-output.xml,frontend/src/assets/tree_bg_svg.svg"
ssh -D 9149 -i ssh-key-2026-05-19.key ubuntu@40.233.106.58
+63
View File
@@ -10,9 +10,11 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.22.1", "express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
@@ -110,6 +112,20 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -421,6 +437,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -591,6 +625,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -769,6 +812,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/oauth": { "node_modules/oauth": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
+2
View File
@@ -19,9 +19,11 @@
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.22.1", "express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
+9 -8
View File
@@ -32,14 +32,15 @@ enum SessionStatus {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
googleId String @unique googleId String? @unique
email String @unique email String @unique
displayName String displayName String
username String @unique username String @unique
avatarUrl String? avatarUrl String?
createdAt DateTime @default(now()) passwordHash String?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions GameSession[] sessions GameSession[]
leaderboardEntries LeaderboardEntry[] leaderboardEntries LeaderboardEntry[]
+290 -65
View File
@@ -5,6 +5,9 @@ const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const cors = require('cors'); const cors = require('cors');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const passport = require('./auth/google'); const passport = require('./auth/google');
const authMiddleware = require('./middleware/auth'); const authMiddleware = require('./middleware/auth');
const db = require('./db'); const db = require('./db');
@@ -13,7 +16,51 @@ const { calculateElo, DEFAULT_ELO } = require('./services/elo');
const { generateStage } = require('./services/questions'); const { generateStage } = require('./services/questions');
const VALID_GRADES = new Set(['G3', 'G6', 'G9']); const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too_many_attempts' },
skipSuccessfulRequests: true,
});
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too_many_attempts' },
});
const authLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too_many_attempts' },
});
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/; const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function issueJwt(user) {
return jwt.sign(
{ userId: user.id, email: user.email, name: user.displayName, picture: user.avatarUrl ?? null },
process.env.JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '7d' },
);
}
async function generateUniqueUsernameFromEmail(email) {
const base = (email.split('@')[0].replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '').slice(0, 15).toLowerCase()) || 'user';
if (!await db.user.findFirst({ where: { username: base } })) return base;
for (let i = 0; i < 20; i++) {
const candidate = `${base}_${String(Math.floor(1000 + Math.random() * 9000))}`;
if (!await db.user.findFirst({ where: { username: candidate } })) return candidate;
}
return `${base}_${Date.now()}`;
}
const STAGE_SIZE = 11; const STAGE_SIZE = 11;
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
@@ -32,6 +79,7 @@ function computeGameResult(game) {
const p1 = game.players[players[1]]; const p1 = game.players[players[1]];
if (p0.finished && p1.finished) { if (p0.finished && p1.finished) {
if (p0.timeSpentMs === p1.timeSpentMs) return players[Math.random() < 0.5 ? 0 : 1];
return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1]; return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1];
} }
if (p0.finished) return players[0]; if (p0.finished) return players[0];
@@ -53,51 +101,65 @@ async function completeGame(game, forcedWinnerId = null) {
game.winner = winnerId; game.winner = winnerId;
game.completedAt = Date.now(); game.completedAt = Date.now();
// Snapshot both ELOs before any writes // No winner (e.g. both disconnected) — skip ELO, just clean up
const eloSnapshot = {}; if (!winnerId) {
for (const uid of players) { for (const uid of players) delete playerGames[uid];
const entry = await db.leaderboardEntry.findUnique({ game.status = 'completed';
where: { userId_grade: { userId: uid, grade: game.grade } }, setTimeout(() => { delete activeGames[game.id]; }, 30000);
}); return;
eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO;
} }
const loserId = players.find(id => id !== winnerId); // Snapshot both ELOs before any writes, then write atomically-as-possible
const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]); try {
const eloSnapshot = {};
for (const uid of players) {
const entry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId: uid, grade: game.grade } },
});
eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO;
}
for (const userId of players) { const loserId = players.find(id => id !== winnerId);
const p = game.players[userId]; const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]);
const isWinner = userId === winnerId;
const change = isWinner ? winnerChange : loserChange;
const newElo = eloSnapshot[userId] + change;
await upsertLeaderboardEntry(userId, game.grade, { for (const userId of players) {
elo: newElo, const p = game.players[userId];
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined, const isWinner = userId === winnerId;
correctAnswers: p.correctAnswers, const change = isWinner ? winnerChange : loserChange;
}); const newElo = eloSnapshot[userId] + change;
p.eloChange = change; await upsertLeaderboardEntry(userId, game.grade, {
p.newElo = newElo; elo: newElo,
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
correctAnswers: p.correctAnswers,
});
delete playerGames[userId]; p.eloChange = change;
p.newElo = newElo;
delete playerGames[userId];
}
// Record per-player match results for dashboard history
await Promise.all(players.map(userId => {
const p = game.players[userId];
return db.matchResult.create({
data: {
userId,
grade: game.grade,
won: userId === winnerId,
eloChange: p.eloChange,
eloAfter: p.newElo,
timeSpentMs: p.timeSpentMs ?? null,
},
});
}));
} catch (err) {
console.error('[completeGame] DB error during ELO write:', err);
// Clean up playerGames refs even on failure so players aren't stuck
for (const uid of players) delete playerGames[uid];
} }
// Record per-player match results for dashboard history
await Promise.all(players.map(userId => {
const p = game.players[userId];
return db.matchResult.create({
data: {
userId,
grade: game.grade,
won: userId === winnerId,
eloChange: p.eloChange,
eloAfter: p.newElo,
timeSpentMs: p.timeSpentMs ?? null,
},
});
}));
// Set completed only after ELO is written so polls see final eloChange // Set completed only after ELO is written so polls see final eloChange
game.status = 'completed'; game.status = 'completed';
@@ -106,7 +168,7 @@ async function completeGame(game, forcedWinnerId = null) {
}, 30000); }, 30000);
} }
// Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s // Cleanup stale queue entries (30s no heartbeat) and old/ghost games every 30s
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [userId, q] of Object.entries(queue)) { for (const [userId, q] of Object.entries(queue)) {
@@ -115,6 +177,18 @@ setInterval(() => {
for (const [id, game] of Object.entries(activeGames)) { for (const [id, game] of Object.entries(activeGames)) {
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) { if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
delete activeGames[id]; delete activeGames[id];
continue;
}
// Ghost game: all players have been silent for 60+ seconds — purge without ELO changes
if (game.status === 'active' && !game.completing) {
const allInactive = Object.values(game.players).every(
p => p.lastSeen && now - p.lastSeen > 60000,
);
if (allInactive) {
console.log(`[cleanup] purging ghost game ${id} — all players inactive >60s`);
for (const uid of Object.keys(game.players)) delete playerGames[uid];
delete activeGames[id];
}
} }
} }
}, 30000); }, 30000);
@@ -129,12 +203,12 @@ const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
app.use('/uploads', express.static(UPLOADS_DIR)); app.use('/uploads', express.static(UPLOADS_DIR));
app.get('/auth/google', passport.authenticate('google', { app.get('/auth/google', authLimiter, passport.authenticate('google', {
scope: ['profile', 'email'], scope: ['profile', 'email'],
session: false, session: false,
})); }));
app.get('/auth/google/callback', (req, res, next) => { app.get('/auth/google/callback', authLimiter, (req, res, next) => {
passport.authenticate('google', { session: false }, (err, result) => { passport.authenticate('google', { session: false }, (err, result) => {
if (err || !result) { if (err || !result) {
console.error('[auth/google/callback] failure:', err); console.error('[auth/google/callback] failure:', err);
@@ -144,6 +218,51 @@ app.get('/auth/google/callback', (req, res, next) => {
})(req, res, next); })(req, res, next);
}); });
app.post('/auth/register', registerLimiter, async (req, res) => {
const { email, password, username } = req.body;
if (!email || !EMAIL_RE.test(email)) return res.status(400).json({ error: 'invalid_email' });
if (!password || password.length < 8) return res.status(400).json({ error: 'password_too_short' });
let finalUsername = username ? username.trim() : null;
if (finalUsername) {
if (!USERNAME_RE.test(finalUsername)) return res.status(400).json({ error: 'invalid_username' });
finalUsername = finalUsername.toLowerCase();
}
try {
if (await db.user.findUnique({ where: { email } })) return res.status(409).json({ error: 'email_taken' });
if (!finalUsername) finalUsername = await generateUniqueUsernameFromEmail(email);
if (await db.user.findFirst({ where: { username: finalUsername } })) return res.status(409).json({ error: 'username_taken' });
const passwordHash = await bcrypt.hash(password, 12);
const user = await db.user.create({
data: { email, displayName: finalUsername, username: finalUsername, passwordHash, avatarUrl: null },
});
res.json({ token: issueJwt(user) });
} catch (err) {
console.error('[auth/register]', err);
if (err.code === 'P2002') {
return res.status(409).json({ error: err.meta?.target?.includes('email') ? 'email_taken' : 'username_taken' });
}
res.status(500).json({ error: 'internal' });
}
});
app.post('/auth/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'missing_fields' });
try {
const user = await db.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
if (!user.passwordHash) return res.status(401).json({ error: 'use_google' });
if (!await bcrypt.compare(password, user.passwordHash)) return res.status(401).json({ error: 'invalid_credentials' });
res.json({ token: issueJwt(user) });
} catch (err) {
console.error('[auth/login]', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/auth/me', authMiddleware, async (req, res) => { app.get('/auth/me', authMiddleware, async (req, res) => {
try { try {
const user = await db.user.findUnique({ where: { id: req.user.userId } }); const user = await db.user.findUnique({ where: { id: req.user.userId } });
@@ -261,7 +380,7 @@ app.post('/game/complete', authMiddleware, async (req, res) => {
}); });
await upsertLeaderboardEntry(userId, grade, { await upsertLeaderboardEntry(userId, grade, {
bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined, bestTime: typeof timeSpentMs === 'number' ? timeSpentMs : undefined,
correctAnswers, correctAnswers,
}); });
@@ -319,7 +438,8 @@ app.post('/me/avatar', authMiddleware, async (req, res) => {
const filepath = path.join(UPLOADS_DIR, filename); const filepath = path.join(UPLOADS_DIR, filename);
try { try {
await fs.promises.writeFile(filepath, buf); await fs.promises.writeFile(filepath, buf);
const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`; const base = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`;
const avatarUrl = `${base}/uploads/${filename}`;
await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } }); await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } });
res.json({ avatarUrl }); res.json({ avatarUrl });
} catch (err) { } catch (err) {
@@ -389,6 +509,7 @@ app.post('/me/best-time', authMiddleware, async (req, res) => {
where: { userId_grade: { userId: req.user.userId, grade } }, where: { userId_grade: { userId: req.user.userId, grade } },
}); });
const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime; const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime;
console.log('[best-time] userId:', req.user.userId, 'grade:', grade, 'timeMs:', timeMs, 'isNewBest:', isNewBest);
const updated = await db.leaderboardEntry.upsert({ const updated = await db.leaderboardEntry.upsert({
where: { userId_grade: { userId: req.user.userId, grade } }, where: { userId_grade: { userId: req.user.userId, grade } },
create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 }, create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 },
@@ -417,6 +538,8 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
} }
if (queue[req.user.userId]) { if (queue[req.user.userId]) {
queue[req.user.userId].grade = grade;
queue[req.user.userId].lastSeen = Date.now();
return res.json({ status: 'waiting' }); return res.json({ status: 'waiting' });
} }
@@ -432,12 +555,14 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
const gameGrade = opponentQueue.grade; const gameGrade = opponentQueue.grade;
console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`); console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`);
const questions = generateStage(gameGrade, 1); const stage1 = generateStage(gameGrade, 1);
const stage2 = generateStage(gameGrade, 2);
const gameId = generateGameId(); const gameId = generateGameId();
const game = { const game = {
id: gameId, id: gameId,
grade: gameGrade, grade: gameGrade,
questions, stage1Questions: stage1,
stage2Questions: stage2,
players: { players: {
[opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() }, [opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
[req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() }, [req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
@@ -446,12 +571,13 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
winner: null, winner: null,
startedAt: Date.now(), startedAt: Date.now(),
completedAt: null, completedAt: null,
chatMessages: [],
}; };
activeGames[gameId] = game; activeGames[gameId] = game;
playerGames[opponentId] = gameId; playerGames[opponentId] = gameId;
playerGames[req.user.userId] = gameId; playerGames[req.user.userId] = gameId;
return res.json({ status: 'matched', gameId }); return res.json({ status: 'matched', gameId, questions: { stage1: game.stage1Questions, stage2: game.stage2Questions } });
} }
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() }; queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() };
@@ -480,7 +606,11 @@ app.delete('/queue/leave', authMiddleware, async (req, res) => {
player.correctAnswers = 0; player.correctAnswers = 0;
player.finished = true; player.finished = true;
player.finishedAt = Date.now(); player.finishedAt = Date.now();
await completeGame(game, opponentId); try {
await completeGame(game, opponentId);
} catch (err) {
console.error('[queue/leave] completeGame error:', err);
}
} }
} }
} }
@@ -497,7 +627,7 @@ app.get('/queue/debug', authMiddleware, (_req, res) => {
}); });
}); });
app.get('/queue/status', authMiddleware, async (req, res) => { app.get('/queue/status', authMiddleware, (req, res) => {
const gameId = playerGames[req.user.userId]; const gameId = playerGames[req.user.userId];
if (gameId) { if (gameId) {
@@ -518,15 +648,13 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
return res.json({ status: 'not_in_queue' }); return res.json({ status: 'not_in_queue' });
} }
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true, avatarUrl: true } });
return res.json({ return res.json({
status: 'matched', status: 'matched',
gameId, gameId,
questions: game.questions, questions: { stage1: game.stage1Questions, stage2: game.stage2Questions },
opponent: { opponent: {
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName, username: opponent.username ?? opponent.displayName,
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl, avatarUrl: opponent.avatarUrl ?? null,
}, },
}); });
} }
@@ -566,13 +694,17 @@ app.post('/game/forfeit', authMiddleware, async (req, res) => {
game.abandonTimeout = null; game.abandonTimeout = null;
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
completeGame(game, opponentId); try {
await completeGame(game, opponentId);
} catch (err) {
console.error('[game/forfeit] completeGame error:', err);
}
res.json({ success: true }); res.json({ success: true, eloChange: player.eloChange ?? 0, newElo: player.newElo ?? 800 });
}); });
app.post('/game/progress', authMiddleware, (req, res) => { app.post('/game/progress', authMiddleware, async (req, res) => {
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body; const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage } = req.body;
const game = activeGames[gameId]; const game = activeGames[gameId];
if (!game || game.status !== 'active') { if (!game || game.status !== 'active') {
@@ -594,31 +726,96 @@ app.post('/game/progress', authMiddleware, (req, res) => {
if (correctAnswers !== undefined) { if (correctAnswers !== undefined) {
player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers)))); player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers))));
} }
if (stage !== undefined) player.currentStage = Number(stage) || 1;
if (finished) { if (finished && stage === 1) {
player.stage1Finished = true;
player.stage1CorrectCount = player.correctAnswers ?? 0;
}
if (finished && stage === 2) {
const elapsed = Date.now() - game.startedAt; const elapsed = Date.now() - game.startedAt;
const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed; const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed;
// Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer // Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000)); player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
player.stage2Finished = true;
player.finished = true; player.finished = true;
player.finishedAt = Date.now(); player.finishedAt = Date.now();
player.completedQuiz = true; player.completedQuiz = true;
const winner = computeGameResult(game); const winner = computeGameResult(game);
if (winner) { if (winner) {
completeGame(game); try {
await completeGame(game);
} catch (err) {
console.error('[game/progress] completeGame error:', err);
}
} else { } else {
// Opponent hasn't finished — award win to this player after 5 minutes if game still active // Opponent hasn't finished — award win to this player after 2 minutes if game still active
const finishedUserId = req.user.userId; const finishedUserId = req.user.userId;
game.abandonTimeout = setTimeout(() => { game.abandonTimeout = setTimeout(async () => {
if (game.status === 'active') completeGame(game, finishedUserId); if (game.status === 'active') {
}, 5 * 60 * 1000); try {
await completeGame(game, finishedUserId);
} catch (err) {
console.error('[game/progress] abandonTimeout completeGame error:', err);
}
}
}, 2 * 60 * 1000);
} }
} }
res.json({ success: true }); res.json({ success: true });
}); });
app.post('/game/emote', authMiddleware, (req, res) => {
const { gameId, emote } = req.body;
if (!gameId) return res.status(400).json({ error: 'gameId required' });
if (!emote || typeof emote !== 'string' || !emote.trim() || emote.length > 20) {
return res.status(400).json({ error: 'invalid emote' });
}
const game = activeGames[gameId];
if (!game || game.status !== 'active') {
return res.status(404).json({ error: 'Game not found or already completed' });
}
const player = game.players[req.user.userId];
if (!player) return res.status(403).json({ error: 'Not a participant' });
player.lastEmote = { text: emote.trim(), sentAt: Date.now() };
res.json({ ok: true });
});
app.post('/game/chat', authMiddleware, (req, res) => {
const { gameId, message } = req.body;
if (!gameId) return res.status(400).json({ error: 'gameId required' });
if (!message || typeof message !== 'string' || !message.trim()) {
return res.status(400).json({ error: 'invalid message' });
}
const trimmed = message.trim().slice(0, 120);
const game = activeGames[gameId];
if (!game || game.status !== 'active') {
return res.status(404).json({ error: 'Game not found or already completed' });
}
const player = game.players[req.user.userId];
if (!player) return res.status(403).json({ error: 'Not a participant' });
if (!player.chatMsgTimes) player.chatMsgTimes = [];
const now = Date.now();
player.chatMsgTimes = player.chatMsgTimes.filter(t => now - t < 1000);
if (player.chatMsgTimes.length >= 4) return res.status(429).json({ error: 'slow_down' });
player.chatMsgTimes.push(now);
game.chatMessages.push({
userId: req.user.userId,
username: player.username ?? player.displayName,
text: trimmed,
sentAt: now,
elapsedMs: now - game.startedAt,
});
if (game.chatMessages.length > 50) game.chatMessages.shift();
res.json({ ok: true });
});
app.get('/game/status/:gameId', authMiddleware, async (req, res) => { app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
const game = activeGames[req.params.gameId]; const game = activeGames[req.params.gameId];
if (!game) { if (!game) {
@@ -639,11 +836,15 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000; const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
if (game.status === 'active' && !player.finished && opponentInactive) { if (game.status === 'active' && !player.finished && opponentInactive) {
game.disconnectedPlayer = opponentId; game.disconnectedPlayer = opponentId;
await completeGame(game, req.user.userId); try {
await completeGame(game, req.user.userId);
} catch (err) {
console.error('[game/status] completeGame error:', err);
}
} }
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } }); // Use cached name — avoids a DB query on every 1-second poll
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName; const opponentName = opponent.username ?? opponent.displayName;
const opponentDced = game.disconnectedPlayer === opponentId; const opponentDced = game.disconnectedPlayer === opponentId;
const youDced = game.disconnectedPlayer === req.user.userId; const youDced = game.disconnectedPlayer === req.user.userId;
@@ -664,6 +865,14 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
timeSpentMs: player.timeSpentMs, timeSpentMs: player.timeSpentMs,
eloChange: player.eloChange, eloChange: player.eloChange,
newElo: player.newElo, newElo: player.newElo,
currentStage: player.currentStage ?? 1,
stage1Finished: player.stage1Finished ?? false,
stage: player.currentStage ?? 1,
savedProgress: {
currentQuestion: player.currentQuestion ?? 0,
correctAnswers: player.correctAnswers ?? 0,
stage: player.currentStage ?? 1,
},
}, },
opponent: { opponent: {
username: opponentName, username: opponentName,
@@ -671,11 +880,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
correctAnswers: opponent.correctAnswers, correctAnswers: opponent.correctAnswers,
finished: opponent.finished, finished: opponent.finished,
timeSpentMs: opponent.timeSpentMs, timeSpentMs: opponent.timeSpentMs,
currentStage: opponent.currentStage ?? 1,
stage1Finished: opponent.stage1Finished ?? false,
lastEmote: (opponent.lastEmote && Date.now() - opponent.lastEmote.sentAt < 4000) ? opponent.lastEmote.text : null,
}, },
}, },
chatMessages: game.chatMessages ?? [],
}); });
}); });
app.get('/online-count', (_req, res) => {
const now = Date.now();
const inQueue = Object.values(queue).filter(q => now - q.lastSeen <= 30000).length;
const inGame = Object.entries(playerGames).filter(([uid, gameId]) => {
const game = activeGames[gameId];
if (!game || game.status !== 'active') return false;
const p = game.players[uid];
return p && p.lastSeen && now - p.lastSeen <= 30000;
}).length;
res.json({ online: inQueue + inGame });
});
app.get('/health', (_req, res) => res.json({ status: 'ok' })); app.get('/health', (_req, res) => res.json({ status: 'ok' }));
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
+2 -2
View File
@@ -36,11 +36,11 @@ async function getLeaderboard(grade, sortBy = 'elo', limit = 10) {
orderBy, orderBy,
take: limit, take: limit,
include: { include: {
user: { select: { displayName: true, avatarUrl: true } }, user: { select: { displayName: true, avatarUrl: true, username: true } },
}, },
}); });
return entries.map((entry, index) => ({ ...entry, rank: index + 1 })); return entries.map((entry, index) => ({ ...entry, ...entry.user, rank: index + 1 }));
} }
async function getUserRank(userId, grade, sortBy = 'elo') { async function getUserRank(userId, grade, sortBy = 'elo') {