Compare commits

..

9 Commits

Author SHA1 Message Date
Maurice a4590bb64a Add env file example.txt 2026-07-11 19:58:37 +00:00
Maurice 7e855b943f Update README.md 2026-07-11 19:52:34 +00:00
Maurice 02b0101ea3 Delete notes.txt 2026-07-11 19:52:14 +00:00
Maurice c83d592fad Update README.md 2026-07-11 19:11:47 +00:00
Maurice fa2a1e7639 db updates 2026-07-11 12:31:22 -04:00
Maurice a8c41a6852 finished many ui changes + polishing dopamine hits 2026-07-11 12:27:35 -04:00
Maurice 4b89a3b034 updated notes.txt 2026-07-11 11:28:36 -04:00
Maurice 045048db20 deleted node_modules 2026-07-11 11:28:16 -04:00
Maurice 1f99268588 fixed so the code compiles 2026-07-11 11:27:43 -04:00
30 changed files with 5623 additions and 1152 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
+5 -1
View File
@@ -1 +1,5 @@
RANKED EQAO
Prisma DB for debugging: npx prisma studio\
Express server: node src/index.js\
Run: cd frontend && npm run dev\
\
demo url: https://youtu.be/Zapj0VfW_0w
+8
View File
@@ -0,0 +1,8 @@
DATABASE_URL="postgresql://TT@localhost:5432/ranked_eqao"
GOOGLE_CLIENT_ID="123654.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="abcdef"
GOOGLE_CALLBACK_URL="http://localhost:3000/auth/google/callback"
JWT_SECRET="abcdef"
GEMINI_API_KEY="abcdef"
PORT=3000
FRONTEND_URL="http://localhost:5173"
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},
+6
View File
@@ -4,7 +4,10 @@ import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Game from './pages/Game';
import Queue from './pages/Queue';
import RankedGame from './pages/RankedGame';
import Settings from './pages/Settings';
import About from './pages/About';
import FAQ from './pages/FAQ';
function getValidStoredToken() {
const token = sessionStorage.getItem('ranked_token');
@@ -38,7 +41,10 @@ export default function App() {
<Route path="/dashboard" element={(loggedIn || hasUrlToken()) ? <Dashboard /> : <Navigate to="/" replace />} />
<Route path="/game" element={(loggedIn || hasUrlToken()) ? <Game />: <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="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
<Route path="/about" element={<About />} />
<Route path="/faq" element={<FAQ />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
const API = import.meta.env.VITE_API_URL;
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
export default function UsernameModal({ token, current, onClose, onSave }) {
const [value, setValue] = useState(current ?? '');
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
async function handleSave() {
const trimmed = value.trim();
if (!trimmed) return;
if (!USERNAME_RE.test(trimmed)) {
setError('3\u201320 characters, letters, numbers, and underscores only.');
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`${API}/me/username`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: trimmed }),
});
const data = await res.json();
if (res.ok) { onSave(data.username); onClose(); }
else if (data.error === 'taken') setError('Username already taken \u2014 try another.');
else setError('Something went wrong. Try again.');
} catch {
setError('Could not save. Check your connection.');
} finally {
setSaving(false);
}
}
return (
<div className="uname-overlay" onMouseDown={onClose}>
<div className="uname-modal" onMouseDown={e => e.stopPropagation()}>
<h3 className="uname-modal__title">Set username</h3>
<p className="uname-modal__hint">320 chars · letters, numbers, underscores</p>
<input
ref={inputRef}
className="uname-input"
value={value}
onChange={e => { setValue(e.target.value); setError(null); }}
onKeyDown={e => e.key === 'Enter' && handleSave()}
placeholder="e.g. player_one"
maxLength={20}
/>
{error && <p className="uname-error">{error}</p>}
<div className="uname-actions">
<button className="uname-btn uname-btn--cancel" onClick={onClose}>Cancel</button>
<button className="uname-btn uname-btn--save" onClick={handleSave} disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</button>
</div>
</div>
</div>
);
}
+732 -44
View File
@@ -20,9 +20,9 @@
--silver: #94a3b8;
--platinum: #f5c518;
--text: #e8eaf6;
--text-muted: #4b5270;
--text-dim: #6b7491;
--text: #eceef8;
--text-muted: #7a85a8;
--text-dim: #9ba5c4;
--font-display: 'Russo One', sans-serif;
--font-ui: 'Barlow Condensed', sans-serif;
@@ -595,16 +595,568 @@ a { color: inherit; text-decoration: none; }
/* Page wrapper */
.dash-page {
align-items: center;
padding: 80px 24px 80px;
padding: 88px 24px 80px;
gap: 0;
}
/* Main content wrapper */
.db-wrap {
width: 100%;
max-width: 820px;
display: flex;
flex-direction: column;
gap: 0;
}
/* Hero */
.db-hero {
text-align: center;
padding: 44px 0 24px;
display: flex;
flex-direction: column;
align-items: center;
}
.db-hero__name {
font-family: var(--font-display);
font-size: clamp(44px, 9vw, 72px);
line-height: 1;
text-transform: uppercase;
color: var(--text);
text-shadow: 0 0 60px rgba(245,197,24,0.1);
}
/* ── CTA buttons ── */
.db-cta {
display: flex;
gap: 14px;
margin-bottom: 32px;
}
.db-btn {
flex: 1;
padding: 17px 24px;
border: none;
border-radius: var(--radius);
font-family: var(--font-display);
font-size: 18px;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s;
}
.db-btn--gold {
background: var(--gold);
color: #07080f;
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4);
position: relative;
overflow: hidden;
}
.db-btn--gold::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.35) 50%, transparent 70%);
background-size: 300% 100%;
background-position: -200% center;
animation: shimmerSweep 3s ease-in-out infinite;
pointer-events: none;
}
.db-btn--gold:hover {
transform: translateY(-2px);
box-shadow: 0 0 44px rgba(245,197,24,0.45), 0 8px 20px rgba(0,0,0,0.5);
}
.db-btn--blue {
background: transparent;
color: var(--blue-bright);
border: 1.5px solid var(--blue);
box-shadow: 0 0 20px rgba(59,130,246,0.15), 0 4px 14px rgba(0,0,0,0.3);
}
.db-btn--blue:hover {
transform: translateY(-2px);
box-shadow: 0 0 36px rgba(59,130,246,0.28), 0 8px 20px rgba(0,0,0,0.4);
}
.db-btn:active { transform: translateY(0); }
/* ── Stats strip ── */
.db-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 36px;
}
.db-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 20px 12px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
}
.db-stat::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(to right, transparent, var(--border-glow), transparent);
}
.db-stat__val {
font-family: var(--font-display);
font-size: clamp(26px, 4vw, 34px);
line-height: 1;
color: var(--text);
}
.db-stat__label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-dim);
}
/* ── Sections ── */
.db-section {
margin-bottom: 28px;
}
.db-section__hd {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding: 0 2px;
}
.db-section__title {
font-family: var(--font-ui);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--text-dim);
}
.lb-tabs {
display: flex;
gap: 4px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px;
}
.lb-tab {
padding: 4px 14px;
background: none;
border: none;
border-radius: 4px;
font-family: var(--font-ui);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.lb-tab:hover { color: var(--text); }
.lb-tab--active {
background: var(--gold-dim);
color: var(--gold);
border: 1px solid var(--gold-mid);
}
.db-section__badge {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
background: var(--gold-dim);
border: 1px solid var(--gold-mid);
border-radius: 4px;
padding: 3px 9px;
}
/* ── Tier Badge ─────────────────────────────────────────────── */
.tier-badge {
display: inline-flex;
align-items: center;
font-family: var(--font-ui);
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--tier-color);
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--tier-color) 35%, transparent);
border-radius: 4px;
box-shadow: 0 0 10px var(--tier-glow);
white-space: nowrap;
flex-shrink: 0;
}
.tier-badge--sm {
font-size: 9px;
padding: 2px 7px;
}
/* ── Tier Progress Bar ─────────────────────────────────────── */
.tier-progress {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.tier-progress__labels {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.tier-progress__pct {
color: var(--text-muted);
font-size: 9px;
}
.tier-progress__bar {
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.tier-progress__fill {
height: 100%;
border-radius: 2px;
transition: width 1s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── Greeting + Hero ─────────────────────────────────────────── */
.db-greeting {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
.db-hero__namerow {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin-bottom: 8px;
}
.db-top-pct {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-top: 4px;
}
.db-top-pct strong {
color: var(--text);
font-weight: 700;
}
/* ── Win Streak ──────────────────────────────────────────────── */
.win-streak {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 6px auto 0;
padding: 4px 14px;
background: rgba(245, 197, 24, 0.06);
border: 1px solid rgba(245, 197, 24, 0.2);
border-radius: 99px;
animation: streakPulse 2.5s ease-in-out infinite;
}
.win-streak__fire { font-size: 14px; }
.win-streak__label {
font-family: var(--font-ui);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
}
@keyframes streakPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,197,24,0); }
50% { box-shadow: 0 0 14px 2px rgba(245,197,24,0.12); }
}
/* ── ELO Hero Block ─────────────────────────────────────────── */
.db-elo-block {
margin-bottom: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px 24px;
position: relative;
overflow: hidden;
}
.db-elo-block::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(to right, transparent, var(--border-glow), transparent);
}
.db-elo-block__inner {
display: flex;
align-items: center;
gap: 28px;
}
.db-elo-main {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-shrink: 0;
gap: 2px;
}
.db-elo-val {
font-family: var(--font-display);
font-size: clamp(40px, 7vw, 58px);
line-height: 1;
}
.db-elo-label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-dim);
margin-top: 2px;
}
.db-elo-rank {
font-family: var(--font-display);
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
.db-elo-rank__total {
font-size: 11px;
opacity: 0.6;
}
.db-elo-block__right {
flex: 1;
min-width: 0;
}
/* ── Two-column layout ─────────────────────────────────────── */
.db-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 28px;
}
.db-section--half {
margin-bottom: 0;
}
/* ── Match History ──────────────────────────────────────────── */
.match-history {
display: flex;
flex-direction: column;
}
.match-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
transition: background 0.15s, transform 0.15s, box-shadow 0.15s;
}
.match-row:last-child { border-bottom: none; }
.match-row:hover {
background: rgba(255,255,255,0.02);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.match-row--win { border-left: 2px solid #22c55e; }
.match-row--loss { border-left: 2px solid #ef4444; }
.match-chip {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 13px;
flex-shrink: 0;
}
.match-chip--win { background: rgba(34,197,94,0.12); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
.match-chip--loss { background: rgba(239,68,68,0.10); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
.match-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.match-elo-after {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.02em;
}
.match-time {
font-family: var(--font-ui);
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.match-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.match-elo-delta {
font-family: var(--font-display);
font-size: 15px;
}
.match-elo-delta--pos { color: #22c55e; }
.match-elo-delta--neg { color: #ef4444; }
.match-ago {
font-family: var(--font-ui);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
/* ── All-grades overview ── */
.db-grades {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.db-grade-cell {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s;
}
.db-grade-cell:hover { border-color: var(--text-muted); transform: translateY(-1px); }
.db-grade-cell--active {
border-color: var(--gold-mid);
background: var(--gold-dim);
}
.db-grade-cell__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.db-grade-cell__name {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-dim);
}
.db-grade-cell__row {
display: flex;
align-items: baseline;
gap: 6px;
}
.db-grade-cell__val {
font-family: var(--font-display);
font-size: 20px;
line-height: 1;
color: var(--text);
}
.db-grade-cell__val--elo {
color: var(--gold);
text-shadow: 0 0 10px rgba(245,197,24,0.3);
}
.db-grade-cell__unit {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
/* Legacy hero — kept for non-dashboard pages if any */
.dash-hero {
text-align: center;
padding: 48px 0 36px;
width: 100%;
max-width: 740px;
max-width: 820px;
}
.dash-hero__eyebrow {
@@ -670,6 +1222,8 @@ a { color: inherit; text-decoration: none; }
gap: 16px;
}
.dash-cta__btn {
display: flex;
align-items: center;
@@ -727,6 +1281,7 @@ a { color: inherit; text-decoration: none; }
margin-bottom: 40px;
}
.stat-card {
display: flex;
flex-direction: column;
@@ -776,6 +1331,7 @@ a { color: inherit; text-decoration: none; }
margin-bottom: 32px;
}
.dash-section__hd {
display: flex;
align-items: center;
@@ -984,6 +1540,38 @@ a { color: inherit; text-decoration: none; }
color: var(--text);
}
/* Ghost (empty slot) rows */
.lb-row--ghost {
pointer-events: none;
opacity: 0.28;
}
.lb-row--ghost:hover {
background: transparent;
}
.lb-avatar--ghost {
background: var(--surface-2);
border: 1px solid var(--border);
}
.lb-ghost-bar {
display: block;
height: 9px;
width: 72px;
border-radius: 5px;
background: var(--surface-2);
}
.lb-ghost-time {
font-family: var(--font-ui);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--text-muted);
flex-shrink: 0;
}
.lb-skeleton {
padding: 12px 16px;
display: flex;
@@ -1027,6 +1615,7 @@ a { color: inherit; text-decoration: none; }
color: var(--text-muted);
}
/* Error banner */
.dash-error-banner {
display: flex;
@@ -1061,6 +1650,8 @@ a { color: inherit; text-decoration: none; }
.dash-error-banner__dismiss:hover { opacity: 1; }
/* Loading skeleton */
.skel-pill {
background: var(--surface-2);
@@ -1290,6 +1881,86 @@ a { color: inherit; text-decoration: none; }
color: var(--text-dim);
}
.settings-row__action {
padding: 6px 16px;
background: var(--gold-dim);
border: 1px solid var(--gold-mid);
border-radius: var(--radius-sm);
color: var(--gold);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s, color 0.2s, transform 0.15s;
flex-shrink: 0;
}
.settings-row__action:hover {
background: var(--gold-mid);
color: #1a1a2e;
}
.settings-row__action:active {
transform: scale(0.96);
}
.settings-avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
border: 2px solid var(--border);
transition: border-color 0.2s, opacity 0.2s;
}
.settings-avatar:hover {
border-color: var(--gold-mid);
opacity: 0.85;
}
.settings-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.settings-avatar__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-2);
color: var(--text-muted);
}
.settings-avatar__spinner {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
border-radius: 50%;
}
.settings-avatar__spinner::after {
content: '';
position: absolute;
inset: 12px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: settings-spin 0.7s linear infinite;
}
@keyframes settings-spin {
to { transform: rotate(360deg); }
}
.settings-toggle {
position: relative;
width: 48px;
@@ -1360,40 +2031,6 @@ a { color: inherit; text-decoration: none; }
color: var(--text);
}
/* ── Username nav elements ────────────────────────────────── */
.dash-nav__username {
background: none;
border: none;
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-dim);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: color 0.15s;
}
.dash-nav__username:hover { color: var(--text); }
.dash-nav__set-username {
background: none;
border: none;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--gold);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
opacity: 0.75;
transition: opacity 0.15s;
}
.dash-nav__set-username:hover { opacity: 1; }
/* ── Username modal ───────────────────────────────────────── */
.uname-overlay {
position: fixed;
@@ -1514,21 +2151,72 @@ a { color: inherit; text-decoration: none; }
white-space: nowrap;
}
/* ── Forfeit / Quit Button ────────────────────────────────── */
.btn-forfeit {
padding: 8px 20px;
border-radius: 999px;
font-family: system-ui, sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1.5px solid #e57373;
background: #fff;
color: #c62828;
transition: background 0.15s, color 0.15s;
}
.btn-forfeit:hover {
background: #c62828;
color: #fff;
}
/* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 700px) {
.db-two-col { grid-template-columns: 1fr; }
.db-elo-block__inner { flex-direction: column; align-items: flex-start; gap: 16px; }
.db-elo-block__right { width: 100%; }
}
@media (max-width: 600px) {
.dash-nav { padding: 0 16px; }
.dash-page { padding-left: 16px; padding-right: 16px; }
.profile-dropdown__trigger .dash-nav__name { display: none; }
.dash-cta__btn { padding: 16px 40px; }
.dash-stats { gap: 8px; }
.stat-card { padding: 18px 10px; }
.stat-card__value { font-size: clamp(22px, 6vw, 32px); }
.db-stats { grid-template-columns: repeat(2, 1fr); }
.db-stat__val { font-size: clamp(22px, 6vw, 30px); }
.db-btn { font-size: 15px; padding: 15px 16px; }
.lb-row { padding: 10px 14px; gap: 10px; }
.lb-row--current { padding-left: 12px; }
.db-hero__namerow { flex-direction: column; gap: 8px; }
}
@media (max-width: 480px) {
.grade-badges { gap: 8px; }
.grade-badge { padding: 12px 16px; }
.dash-grade-tab { padding: 8px 14px; font-size: 12px; }
.dash-hero__title { font-size: clamp(44px, 14vw, 88px); }
.db-hero__name { font-size: clamp(38px, 13vw, 72px); }
.db-grades { grid-template-columns: 1fr; }
}
/* Quiz nav button press feel */
.quiz-nav-btn {
transition: transform 0.1s ease !important;
}
.quiz-nav-btn:active {
transform: scale(0.96) !important;
}
@keyframes shimmerSweep {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes rowFadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gradeTabBounce {
0% { transform: scale(1); }
40% { transform: scale(0.92); }
70% { transform: scale(1.04); }
100% { transform: scale(1); }
}
+490
View File
@@ -0,0 +1,490 @@
import { Link } from 'react-router-dom';
const DIAMONDS = [
{ left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' },
{ left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' },
{ left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' },
{ left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' },
{ left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' },
{ left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' },
{ left: '82%', top: '30%', size: 13, dur: '14s', delay: '1s' },
{ left: '90%', top: '75%', size: 9, dur: '9s', delay: '4s' },
{ left: '38%', top: '45%', size: 6, dur: '15s', delay: '2.5s' },
{ left: '62%', top: '88%', size: 11, dur: '11s', delay: '0.3s' },
{ left: '12%', top: '82%', size: 8, dur: '13s', delay: '1.8s' },
{ left: '76%', top: '10%', size: 12, dur: '10s', delay: '3.5s' },
];
const RINGS = [
{ left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' },
{ left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' },
{ left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' },
{ left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' },
];
const SCANLINES = [
{ top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
{ top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' },
{ top: '18%', width: '200px', left: '38%', dur: '12s', delay: '1.5s' },
{ top: '75%', width: '140px', left: '20%', dur: '9s', delay: '2s' },
];
const PAGE_CSS = `
@keyframes aboutFadeUp {
from { opacity:0; transform:translateY(16px); }
to { opacity:1; transform:translateY(0); }
}
@keyframes aboutHeroPulse {
0%,100% { opacity:.2; transform:translate(-50%,-50%) scale(1); }
50% { opacity:.38; transform:translate(-50%,-50%) scale(1.12); }
}
.ab-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:aboutHeroPulse 10s ease-in-out infinite;
}
.ab-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);
}
.ab-nav__logo {
font-family:'Russo One',sans-serif; font-size:19px;
letter-spacing:.04em; text-transform:uppercase;
color:#eceef8; text-decoration:none;
}
.ab-nav__logo span { color:#f5c518; }
.ab-nav__links { display:flex; align-items:center; gap:28px; }
.ab-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;
}
.ab-nav-link:hover { color:#eceef8; }
.ab-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;
}
.ab-nav-signin:hover { background:rgba(245,197,24,.16); border-color:rgba(245,197,24,.6); }
.ab-wrap { width:100%; max-width:1100px; margin:0 auto; padding:0 24px; }
.ab-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;
}
.ab-eyebrow::before,.ab-eyebrow::after {
content:''; display:block; width:36px; height:1px;
background:rgba(245,197,24,.4);
}
.ab-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;
}
.ab-feature-card {
background:#111426; border:1px solid rgba(255,255,255,.07);
border-radius:12px; padding:28px 24px;
transition:border-color .25s, transform .25s;
}
.ab-feature-card:hover { border-color:rgba(245,197,24,.2); transform:translateY(-3px); }
.ab-prose-card {
background:#111426; border:1px solid rgba(255,255,255,.07);
border-radius:16px; padding:44px 52px;
position:relative; overflow:hidden; max-width:800px; margin:0 auto;
}
.ab-prose-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px;
background:linear-gradient(to right,transparent,rgba(245,197,24,.5),transparent);
}
.ab-grade-card {
background:#111426; border:1px solid rgba(255,255,255,.07);
border-radius:16px; padding:32px 28px; flex:1; min-width:270px;
position:relative; overflow:hidden;
transition:border-color .25s, transform .25s;
}
.ab-grade-card:hover { transform:translateY(-4px); }
.ab-footer-link {
font-family:'Barlow Condensed',sans-serif; font-size:12px;
letter-spacing:.12em; text-transform:uppercase;
color:#7a85a8; text-decoration:none; transition:color .2s;
}
.ab-footer-link:hover { color:#eceef8; }
@media (max-width:768px) {
.ab-nav { padding:0 20px; }
.ab-nav-link.ab-hide-mobile { display:none; }
.ab-prose-card { padding:28px 24px; }
}
@media (max-width:500px) {
.ab-nav__links { gap:12px; }
}
`;
const TECH_STACK = [
{
title: 'Node.js + Express',
desc: 'The backend is straightforward Express — routes for auth, matchmaking, leaderboards, and WebSocket connections for live 1v1 games. No framework magic, just Node doing what Node does.',
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" />
<path d="M12 8v4l3 3" />
</svg>
),
},
{
title: 'PostgreSQL + Prisma',
desc: 'All user data, match results, and ELO history live in Postgres. Prisma handles the schema and migrations so changes to the data model don\'t turn into SQL archaeology.',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
),
},
{
title: 'React + Vite',
desc: 'The frontend is plain React with Vite — no Next.js, no SSR, no overhead. Fast dev builds, component-based UI, and straightforward state management with hooks.',
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: 'Google OAuth',
desc: 'Sign-in goes through Google OAuth via Passport.js. JWTs handle sessions on the client. No passwords to store, no email verification flows — sign in once and you\'re in.',
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
title: 'Gemini API',
desc: 'English sections use Google Gemini to generate reading passages, comprehension questions, and check free-response answers. Every session gets fresh content — no memorizing 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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
),
},
{
title: 'ELO Ranking',
desc: 'The same rating system used in chess and competitive gaming — adapted for 1v1 quiz matches. Win against a higher-rated player, gain more points. Lose to a lower-rated one, feel it.',
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>
),
},
];
const GRADES = [
{
id: 'G3',
label: 'Grade 3',
color: '#c9824a',
desc: 'The first EQAO checkpoint in elementary school. Grade 3 math covers number sense, basic operations, patterning, simple measurement, and geometry. Questions test foundational understanding, not tricks.',
topics: ['Number Sense', 'Patterning', 'Measurement', 'Geometry'],
},
{
id: 'G6',
label: 'Grade 6',
color: '#94a3b8',
desc: "The follow-up at the end of elementary. Problems get more complex — multi-step reasoning, fractions, data management, and proportional thinking. A noticeable step up from Grade 3.",
topics: ['Fractions', 'Data Management', 'Algebra', 'Proportional Reasoning'],
},
{
id: 'G9',
label: 'Grade 9',
color: '#f5c518',
desc: 'The high school transition point. Grade 9 math introduces linear relations, polynomial expressions, and analytic geometry. This is where abstract thinking starts to matter.',
topics: ['Linear Relations', 'Algebra', 'Analytic Geometry', 'Financial Literacy'],
},
];
export default function About() {
return (
<>
<style>{PAGE_CSS}</style>
{/* Background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{DIAMONDS.map((d, i) => (
<div key={i} className="shape shape--diamond" style={{ left: d.left, top: d.top, width: d.size, height: d.size, animationDuration: d.dur, animationDelay: d.delay }} />
))}
{RINGS.map((r, i) => (
<div key={i} className="shape shape--ring" style={{ left: r.left, top: r.top, width: r.size, height: r.size, animationDuration: r.dur, animationDelay: r.delay }} />
))}
{SCANLINES.map((s, i) => (
<div key={i} className="shape shape--line" style={{ top: s.top, left: s.left, width: s.width, animationDuration: s.dur, animationDelay: s.delay }} />
))}
</div>
</div>
{/* Nav */}
<nav className="ab-nav">
<a href="/" className="ab-nav__logo">RANKED<span>EQAO</span></a>
<div className="ab-nav__links">
<a href="/#how-it-works" className="ab-nav-link ab-hide-mobile">How It Works</a>
<a href="/#leaderboard" className="ab-nav-link ab-hide-mobile">Leaderboard</a>
<a href="/faq" className="ab-nav-link ab-hide-mobile">FAQ</a>
<a href="/#signin" className="ab-nav-signin">Sign In</a>
</div>
</nav>
<div style={{ position: 'relative', zIndex: 1 }}>
{/* HERO */}
<section style={{
minHeight: '68vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '120px 0 80px', textAlign: 'center',
position: 'relative', overflow: 'hidden',
}}>
<div className="ab-orb" />
<div className="ab-wrap" 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: 'aboutFadeUp .6s .05s ease both',
}}>
Independent Ontario Project
</p>
<h1 style={{
fontFamily: "'Russo One',sans-serif",
fontSize: 'clamp(58px,13vw,130px)',
lineHeight: 0.88, letterSpacing: '-0.01em',
textTransform: 'uppercase', marginBottom: 36,
}}>
<span style={{ display: 'block', color: '#eceef8', opacity: 0, animation: 'aboutFadeUp .7s .15s cubic-bezier(.16,1,.3,1) both' }}>
ABOUT
</span>
<span style={{ display: 'block', color: '#f5c518', textShadow: '0 0 60px rgba(245,197,24,.4)', opacity: 0, animation: 'aboutFadeUp .7s .3s cubic-bezier(.16,1,.3,1) both' }}>
RANKED EQAO
</span>
</h1>
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 'clamp(15px,2.4vw,19px)',
color: '#9ba5c4', maxWidth: 540, margin: '0 auto', lineHeight: 1.7,
opacity: 0, animation: 'aboutFadeUp .6s .5s ease both',
}}>
A free, competitive quiz platform for Ontario students. Practice EQAO-style math, race against real opponents, and climb a leaderboard because test prep shouldn't feel like a chore.
</p>
</div>
</section>
{/* MISSION */}
<section style={{
padding: '100px 0',
background: 'rgba(13,15,28,.78)',
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
}}>
<div className="ab-wrap">
<p className="ab-eyebrow">Why It Exists</p>
<h2 className="ab-section-title">The Mission</h2>
<div className="ab-prose-card">
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 17, color: '#9ba5c4',
lineHeight: 1.8, marginBottom: 22,
}}>
EQAO prep is, by design, pretty boring. Worksheets, practice tests, the same problems repeated until they stop feeling like problems and start feeling like punishment. Nobody's excited about it including the students who actually need the practice.
</p>
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 17, color: '#9ba5c4',
lineHeight: 1.8, marginBottom: 22,
}}>
Ranked EQAO is an attempt to fix that. By wrapping real EQAO curriculum in a competitive structure live 1v1 matchmaking, ELO ratings, grade leaderboards it turns the same content into something students might actually choose to do on their own. The questions are authentic. The format makes them matter.
</p>
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 17, color: '#9ba5c4',
lineHeight: 1.8,
}}>
It's not affiliated with EQAO or the Ontario government. It's just a side project built by someone who wanted to see if competition could make standardized test prep less miserable.
</p>
</div>
</div>
</section>
{/* HOW IT WAS BUILT */}
<section style={{ padding: '100px 0' }}>
<div className="ab-wrap">
<p className="ab-eyebrow">Under The Hood</p>
<h2 className="ab-section-title">How It Was Built</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(290px,1fr))', gap: 16 }}>
{TECH_STACK.map((tech, i) => (
<div key={i} className="ab-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,
}}>
{tech.icon}
</div>
<h3 style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 18, fontWeight: 700,
letterSpacing: '0.05em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 8,
}}>
{tech.title}
</h3>
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 14, color: '#7a85a8', lineHeight: 1.65 }}>
{tech.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* THE GRADES */}
<section style={{
padding: '100px 0',
background: 'rgba(13,15,28,.78)',
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
}}>
<div className="ab-wrap">
<p className="ab-eyebrow">Coverage</p>
<h2 className="ab-section-title">Grade Levels</h2>
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
{GRADES.map((g) => (
<div key={g.id} className="ab-grade-card" style={{ borderColor: `rgba(${hexToRgb(g.color)},.18)` }}>
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, height: 2,
background: `linear-gradient(to right, transparent, ${g.color}, transparent)`,
}} />
<div style={{
fontFamily: "'Russo One',sans-serif", fontSize: 54, lineHeight: 1,
color: g.color, marginBottom: 4,
textShadow: `0 0 28px ${g.color}55`,
}}>
{g.id}
</div>
<div style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, fontWeight: 700,
letterSpacing: '0.16em', textTransform: 'uppercase', color: '#7a85a8', marginBottom: 18,
}}>
{g.label}
</div>
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 14, color: '#7a85a8',
lineHeight: 1.65, marginBottom: 22,
}}>
{g.desc}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{g.topics.map(topic => (
<span key={topic} style={{
padding: '4px 10px',
background: `${g.color}14`,
border: `1px solid ${g.color}38`,
borderRadius: 4,
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 11,
fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase',
color: g.color,
}}>
{topic}
</span>
))}
</div>
</div>
))}
</div>
</div>
</section>
{/* CONTACT */}
<section style={{ padding: '100px 0 80px' }}>
<div className="ab-wrap">
<div style={{ textAlign: 'center', maxWidth: 580, margin: '0 auto' }}>
<p className="ab-eyebrow">Get In Touch</p>
<h2 style={{
fontFamily: "'Russo One',sans-serif",
fontSize: 'clamp(28px,5vw,44px)', textTransform: 'uppercase',
letterSpacing: '0.02em', color: '#eceef8', marginBottom: 20,
}}>
Have Feedback?
</h2>
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 16, color: '#7a85a8',
lineHeight: 1.75, marginBottom: 36,
}}>
Found a bug? Question not rendering right? Spotted a math error? This project is built and maintained by one person feedback actually gets read. If something's broken or something could be better, reach out.
</p>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<Link
to="/faq"
style={{
padding: '14px 36px',
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', textDecoration: 'none', display: 'inline-block',
transition: 'background .2s, border-color .2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,.16)'; 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)'; }}
>
Read the FAQ
</Link>
<p style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, color: '#4a5470',
letterSpacing: '0.04em',
}}>
Or find the project on GitHub and open an issue.
</p>
</div>
</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="ab-wrap" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18 }}>
<Link to="/" style={{ textDecoration: 'none' }}>
<span style={{ fontFamily: "'Russo One',sans-serif", fontSize: 16, letterSpacing: '0.04em', color: '#eceef8' }}>
RANKED<span style={{ color: '#f5c518' }}>EQAO</span>
</span>
</Link>
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap', justifyContent: 'center' }}>
<Link to="/" className="ab-footer-link">Home</Link>
<Link to="/faq" className="ab-footer-link">FAQ</Link>
</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>
</div>
</>
);
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r},${g},${b}`;
}
+457 -254
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import ProfileDropdown from '../components/ProfileDropdown';
@@ -10,12 +10,29 @@ const GRADES = [
{ id: 'G9', label: 'Grade 9' },
];
const LB_SLOTS = 10;
const TIERS = [
{ name: 'Bronze', min: 800, max: 899, color: '#cd7f32', glow: 'rgba(205,127,50,0.45)', next: 900 },
{ name: 'Silver', min: 900, max: 1099, color: '#b0bec5', glow: 'rgba(176,190,197,0.45)', next: 1100 },
{ name: 'Gold', min: 1100, max: 1299, color: '#f5c518', glow: 'rgba(245,197,24,0.55)', next: 1300 },
{ name: 'Platinum', min: 1300, max: 1499, color: '#90caf9', glow: 'rgba(144,202,249,0.5)', next: 1500 },
{ name: 'Diamond', min: 1500, max: Infinity, color: '#64ffda', glow: 'rgba(100,255,218,0.55)', next: null },
];
function getTier(elo) {
return TIERS.find(t => elo >= t.min && elo <= t.max) ?? TIERS[0];
}
const RANK_STYLES = [
{ color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' },
{ color: '#c0ccda', textShadow: '0 0 10px rgba(192,204,218,0.4)' },
{ color: '#e0915a', textShadow: '0 0 10px rgba(224,145,90,0.4)' },
];
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
try { return JSON.parse(atob(token.split('.')[1])); }
catch { return null; }
}
function fmtTime(ms) {
@@ -24,63 +41,135 @@ function fmtTime(ms) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function StatCard({ label, value, accent = false }) {
function fmtRelative(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function getGreeting() {
const h = new Date().getHours();
if (h < 5) return 'Night owl mode';
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
function useCountUp(target, duration = 900, enabled = true) {
const [value, setValue] = useState(0);
const rafRef = useRef(null);
useEffect(() => {
if (!enabled || target == null || target === 0) {
setValue(target ?? 0);
return;
}
const start = Date.now();
const tick = () => {
const elapsed = Date.now() - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.round(eased * target));
if (progress < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [target, duration, enabled]);
return value;
}
function TierBadge({ elo, size = 'md' }) {
const tier = getTier(elo);
const isLg = size === 'lg';
return (
<div className="stat-card">
<span className="stat-card__label">{label}</span>
<span className={`stat-card__value${accent ? ' stat-card__value--accent' : ''}`}>
{value}
</span>
<span
className="tier-badge"
style={{
'--tier-color': tier.color,
'--tier-glow': tier.glow,
fontSize: isLg ? 12 : 10,
padding: isLg ? '4px 12px' : '3px 9px',
}}
>
{tier.name}
</span>
);
}
function TierProgress({ elo }) {
const tier = getTier(elo);
if (!tier.next) return null;
const pct = Math.round(((elo - tier.min) / (tier.next - tier.min)) * 100);
const nextTier = TIERS.find(t => t.min === tier.next);
return (
<div className="tier-progress">
<div className="tier-progress__labels">
<span style={{ color: tier.color }}>{tier.name}</span>
<span className="tier-progress__pct">{pct}%</span>
<span style={{ color: nextTier?.color ?? tier.color }}>{nextTier?.name}</span>
</div>
<div className="tier-progress__bar">
<div
className="tier-progress__fill"
style={{ width: `${pct}%`, background: tier.color, boxShadow: `0 0 8px ${tier.glow}` }}
/>
</div>
</div>
);
}
const RANK_STYLES = [
{ color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' },
{ color: '#94a3b8', textShadow: '0 0 10px rgba(148,163,184,0.5)' },
{ color: '#c9824a', textShadow: '0 0 10px rgba(201,130,74,0.5)' },
];
function LeaderboardTable({ entries, loading, highlightRank, grade }) {
function LeaderboardTable({ entries, loading, highlightRank, tab }) {
if (loading) {
return (
<div className="lb-skeleton">
{[...Array(5)].map((_, i) => (
<div key={i} className="lb-skel-row" style={{ animationDelay: `${i * 0.07}s` }} />
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{[...Array(LB_SLOTS)].map((_, i) => (
<div key={i} className="skel-pill" style={{ height: 44, animationDelay: `${i * 0.06}s` }} />
))}
</div>
);
}
if (!entries.length) {
const gradeLabel = GRADES.find(g => g.id === grade)?.label ?? grade;
return (
<div className="dash-empty">
<span className="dash-empty__icon"></span>
<p className="dash-empty__text">No times recorded yet for {gradeLabel}</p>
<p className="dash-empty__sub">Complete a quiz to appear on the board</p>
</div>
);
}
const padded = [
...entries,
...[...Array(Math.max(0, LB_SLOTS - entries.length))].map((_, i) => ({
rank: entries.length + i + 1,
ghost: true,
})),
];
return (
<div className="lb-table">
<div className="lb-header">
<span className="lb-header__rank">Rank</span>
<span className="lb-header__rank">#</span>
<span className="lb-header__player">Player</span>
<span className="lb-header__time">Best Time</span>
<span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
</div>
{entries.map(entry => {
{padded.map((entry, idx) => {
const rowAnim = { animation: 'rowFadeUp 0.3s ease-out both', animationDelay: `${idx * 40}ms` };
if (entry.ghost) {
return (
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost" style={rowAnim}>
<span className="lb-rank lb-rank--num">{entry.rank}</span>
<div className="lb-user">
<div className="lb-avatar lb-avatar--ghost" />
<span className="lb-ghost-bar" />
</div>
<span className="lb-ghost-time">{tab === 'elo' ? '—' : '—:——'}</span>
</div>
);
}
const isCurrent = highlightRank !== null && entry.rank === highlightRank;
const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null;
const playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
return (
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}>
<span
className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`}
style={rankStyle ?? undefined}
>
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`} style={rowAnim}>
<span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
{entry.rank}
</span>
<div className="lb-user">
@@ -91,7 +180,9 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) {
<span className="lb-name">{playerLabel}</span>
{isCurrent && <span className="lb-you">YOU</span>}
</div>
<span className="lb-score lb-score--time">{fmtTime(entry.bestTime)}</span>
<span className="lb-score lb-score--time">
{tab === 'elo' ? (entry.elo?.toLocaleString() ?? '—') : fmtTime(entry.bestTime)}
</span>
</div>
);
})}
@@ -99,63 +190,76 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) {
);
}
function UsernameModal({ token, current, onClose, onSave }) {
const [value, setValue] = useState(current ?? '');
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
async function handleSave() {
const trimmed = value.trim();
if (!trimmed) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`${API}/me/username`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: trimmed }),
});
const data = await res.json();
if (res.ok) { onSave(data.username); onClose(); }
else if (data.error === 'taken') setError('Username already taken — try another.');
else setError('320 characters, letters, numbers, and underscores only.');
} catch {
setError('Could not save. Check your connection.');
} finally {
setSaving(false);
}
}
return (
<div className="uname-overlay" onMouseDown={onClose}>
<div className="uname-modal" onMouseDown={e => e.stopPropagation()}>
<h3 className="uname-modal__title">Set username</h3>
<p className="uname-modal__hint">320 chars · letters, numbers, underscores</p>
<input
ref={inputRef}
className="uname-input"
value={value}
onChange={e => { setValue(e.target.value); setError(null); }}
onKeyDown={e => e.key === 'Enter' && handleSave()}
placeholder="e.g. player_one"
maxLength={20}
/>
{error && <p className="uname-error">{error}</p>}
<div className="uname-actions">
<button className="uname-btn uname-btn--cancel" onClick={onClose}>Cancel</button>
<button className="uname-btn uname-btn--save" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
function MatchHistory({ matches, loading }) {
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}>
{[0, 1, 2].map(i => (
<div key={i} className="skel-pill" style={{ height: 60, animationDelay: `${i * 0.07}s` }} />
))}
</div>
);
}
if (!matches.length) {
return (
<div className="dash-empty">
<div className="dash-empty__icon">🕐</div>
<span className="dash-empty__text">No games played yet</span>
<span className="dash-empty__sub">Queue up to start climbing</span>
</div>
);
}
return (
<div className="match-history">
{matches.slice(0, 5).map((m, i) => {
const sign = m.eloChange >= 0 ? '+' : '';
const eloDeltaColor = m.eloChange >= 0 ? '#1b8a2d' : '#c62828';
return (
<div key={m.id ?? i} className={`match-row${m.won ? ' match-row--win' : ' match-row--loss'}`}>
{/* Win/loss emoji icon */}
<div
className={`match-chip${m.won ? ' match-chip--win' : ' match-chip--loss'}`}
style={{ fontSize: 20, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{m.won ? '🏆' : '💀'}
</div>
{/* Center: grade badge · label · ELO delta · time · ago */}
<div className="match-info" style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
background: '#f0f4ff', color: '#1a73e8',
letterSpacing: '0.04em', fontFamily: 'system-ui,sans-serif',
}}>
{m.grade}
</span>
<span style={{
fontSize: 14, fontWeight: 700,
color: m.won ? '#1b8a2d' : '#c62828',
fontFamily: 'system-ui,sans-serif',
}}>
{m.won ? 'Victory' : 'Defeat'}
</span>
<span style={{
fontSize: 13, fontWeight: 700, color: eloDeltaColor,
fontFamily: 'system-ui,sans-serif',
}}>
{sign}{m.eloChange} ELO
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
</div>
</div>
{/* Right: ELO after */}
<span className="match-elo-after" style={{ whiteSpace: 'nowrap' }}>
{m.eloAfter?.toLocaleString()} ELO
</span>
</div>
);
})}
</div>
);
}
@@ -166,25 +270,17 @@ function LoadingSkeleton() {
<div className="arena-bg"><div className="arena-grain" /></div>
<nav className="dash-nav">
<div className="skel-pill" style={{ width: 140, height: 22 }} />
<div className="skel-pill" style={{ width: 180, height: 36, borderRadius: 999 }} />
<div className="skel-pill" style={{ width: 40, height: 40, borderRadius: '50%' }} />
</nav>
<main className="page dash-page">
<section className="dash-hero">
<div className="skel-pill" style={{ width: 96, height: 12, marginBottom: 16 }} />
<div className="skel-pill" style={{ width: 300, height: 64, marginBottom: 32 }} />
<div style={{ display: 'flex', gap: 8 }}>
{[0, 1, 2].map(i => (
<div key={i} className="skel-pill" style={{ width: 96, height: 38, animationDelay: `${i * 0.08}s` }} />
))}
<div className="db-wrap">
<div className="skel-pill" style={{ width: 280, height: 72, marginBottom: 24, alignSelf: 'center' }} />
<div className="skel-pill" style={{ width: 240, height: 40, borderRadius: 999, marginBottom: 40, alignSelf: 'center' }} />
<div className="skel-pill" style={{ height: 60, marginBottom: 28 }} />
<div className="db-stats">
{[0,1,2,3].map(i => <div key={i} className="skel-pill" style={{ height: 88, animationDelay: `${i*0.07}s` }} />)}
</div>
</section>
<div className="dash-cta">
<div className="skel-pill" style={{ width: 220, height: 58 }} />
</div>
<div className="dash-stats">
{[0, 1, 2].map(i => (
<div key={i} className="skel-pill" style={{ height: 88, animationDelay: `${i * 0.08}s` }} />
))}
<div className="skel-pill" style={{ height: 480 }} />
</div>
</main>
</>
@@ -197,20 +293,23 @@ export default function Dashboard() {
const [jwtPayload, setJwtPayload] = useState(null);
const [user, setUser] = useState(null);
const [username, setUsername] = useState(null);
const [usernameModal, setUsernameModal] = useState(false);
const [stats, setStats] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
const [matches, setMatches] = useState([]);
const [selectedGrade, setSelectedGrade] = useState('G3');
const [lbTab, setLbTab] = useState('elo');
const [loading, setLoading] = useState(true);
const [lbLoading, setLbLoading] = useState(false);
const [matchLoading, setMatchLoading] = useState(false);
const [error, setError] = useState(null);
const profileElo = stats.find(e => e.grade === selectedGrade)?.elo ?? null;
const [statsReady, setStatsReady] = useState(false);
const [bouncingGrade, setBouncingGrade] = useState(null);
const [onlineCount, setOnlineCount] = useState(null);
// ── Auth init ──
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
let tok = urlToken;
let tok = urlToken;
if (urlToken) {
sessionStorage.setItem('ranked_token', urlToken);
history.replaceState(null, '', window.location.pathname);
@@ -222,183 +321,287 @@ export default function Dashboard() {
setJwtPayload(decodeJwt(tok));
}, [navigate]);
// ── Fetch user + stats ──
useEffect(() => {
if (!token) return;
const headers = { Authorization: `Bearer ${token}` };
Promise.all([
fetch(`${API}/auth/me`, { headers }),
fetch(`${API}/me/stats`, { headers }),
])
Promise.all([fetch(`${API}/auth/me`, { headers }), fetch(`${API}/me/stats`, { headers })])
.then(async ([meRes, statsRes]) => {
if (meRes.status === 401) {
sessionStorage.removeItem('ranked_token');
setLoading(false);
navigate('/');
return;
}
if (meRes.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return; }
const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]);
setUser(meData);
setUsername(meData.username ?? null);
setStats(statsData.entries ?? []);
setLoading(false);
requestAnimationFrame(() => setStatsReady(true));
})
.catch(() => {
setError('Could not reach the server. Check your connection and refresh.');
setLoading(false);
});
.catch(() => { setError('Could not reach the server.'); setLoading(false); });
}, [token, navigate]);
// ── Fetch leaderboard ──
useEffect(() => {
if (!token) return;
setLbLoading(true);
fetch(`${API}/leaderboard?grade=${selectedGrade}`)
.then(res => res.json())
.then(data => setLeaderboard(Array.isArray(data) ? data : []))
.catch(() => setLeaderboard([]))
fetch(`${API}/leaderboard?grade=${selectedGrade}&type=${lbTab}`)
.then(r => r.json())
.then(d => setLeaderboard(Array.isArray(d) ? d : []))
.catch(() => setLeaderboard([]))
.finally(() => setLbLoading(false));
}, [token, selectedGrade, lbTab]);
useEffect(() => {
if (!token) return;
setMatchLoading(true);
fetch(`${API}/me/recent-matches?grade=${selectedGrade}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then(d => setMatches(Array.isArray(d) ? d : []))
.catch(() => setMatches([]))
.finally(() => setMatchLoading(false));
}, [token, selectedGrade]);
const signOut = () => {
sessionStorage.removeItem('ranked_token');
navigate('/');
};
useEffect(() => {
const fetchOnline = () => {
fetch(`${API}/online-count`)
.then(r => r.json())
.then(d => setOnlineCount(d.online ?? 0))
.catch(() => {});
};
fetchOnline();
const id = setInterval(fetchOnline, 30000);
return () => clearInterval(id);
}, []);
const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
const currentEntry = stats.find(e => e.grade === selectedGrade);
const highlightRank = currentEntry?.timeRank ?? null;
const highlightRank = lbTab === 'elo' ? (currentEntry?.eloRank ?? null) : (currentEntry?.timeRank ?? null);
const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player';
const firstName = displayName.split(' ')[0];
const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null;
const initial = displayName[0]?.toUpperCase() ?? '?';
const elo = currentEntry?.elo ?? 800;
const tier = getTier(elo);
const winStreak = currentEntry?.winStreak ?? 0;
const eloRank = currentEntry?.eloRank ?? null;
const totalPlayers = currentEntry?.totalPlayers ?? 0;
const gamesPlayed = currentEntry?.gamesPlayed ?? 0;
const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player';
const firstName = displayName.split(' ')[0];
const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null;
const initial = displayName[0]?.toUpperCase() ?? '?';
const topPct = eloRank && totalPlayers > 0
? Math.max(1, Math.round((eloRank / totalPlayers) * 100))
: null;
const animatedElo = useCountUp(elo, 1000, statsReady);
const animatedGames = useCountUp(gamesPlayed, 800, statsReady);
if (loading) return <LoadingSkeleton />;
const heroLabel = username ?? firstName;
const casualLabel = gamesPlayed === 0 ? 'Play First Game' : 'Casual';
return (
<>
<div className="arena-bg"><div className="arena-grain" /></div>
{usernameModal && (
<UsernameModal
token={token}
current={username}
onClose={() => setUsernameModal(false)}
onSave={(u) => setUsername(u)}
/>
)}
{/* ── Nav ── */}
<nav className="dash-nav">
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
{profileElo !== null && <span style={{
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
color: 'var(--gold)', letterSpacing: '0.04em',
padding: '4px 12px', borderRadius: 999,
background: 'var(--gold-dim)',
border: '1px solid var(--gold-mid)',
}}>
ELO: {profileElo.toLocaleString()}
</span>}
</div>
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
<div className="dash-nav__right">
{username
? <button className="dash-nav__username" onClick={() => setUsernameModal(true)}>@{username}</button>
: <button className="dash-nav__set-username" onClick={() => setUsernameModal(true)}>Set username</button>
}
<ProfileDropdown
displayName={displayName}
avatarUrl={avatarUrl}
initial={initial}
onSignOut={signOut}
/>
<ProfileDropdown displayName={displayName} avatarUrl={avatarUrl} initial={initial} onSignOut={signOut} />
</div>
</nav>
<main className="page dash-page">
<div className="db-wrap">
{/* ── Hero ── */}
<section className="dash-hero">
<h1 className="dash-hero__title">{username ?? firstName}</h1>
<div className="dash-grade-tabs">
{GRADES.map(g => (
<button
key={g.id}
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`}
onClick={() => setSelectedGrade(g.id)}
>
{g.label}
</button>
))}
{error && (
<div className="dash-error-banner" style={{ maxWidth: '100%', marginBottom: 24 }}>
<span>{error}</span>
<button className="dash-error-banner__dismiss" onClick={() => setError(null)}></button>
</div>
)}
{/* ── Hero ── */}
<section className="db-hero">
<p className="db-greeting">{getGreeting()},</p>
<div className="db-hero__namerow">
<h1 className="db-hero__name">{heroLabel}</h1>
<TierBadge elo={elo} size="lg" />
</div>
{winStreak >= 2 && (
<div className="win-streak">
<span className="win-streak__fire">🔥</span>
<span className="win-streak__label">{winStreak} win streak</span>
</div>
)}
{topPct && (
<p className="db-top-pct">
Top <strong>{topPct}%</strong> in Grade {selectedGrade.replace('G', '')}
</p>
)}
<div className="dash-grade-tabs" style={{ marginTop: 20 }}>
{GRADES.map(g => (
<button
key={g.id}
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`}
style={bouncingGrade === g.id ? { animation: 'gradeTabBounce 0.2s ease-out' } : {}}
onClick={() => {
setSelectedGrade(g.id);
setBouncingGrade(g.id);
setTimeout(() => setBouncingGrade(null), 200);
}}
>
{g.label}
</button>
))}
</div>
</section>
{/* ── CTA buttons ── */}
<div className="db-cta">
<button className="db-btn db-btn--gold" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}>
{casualLabel}
</button>
<button className="db-btn db-btn--blue" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}>
Queue Ranked
</button>
</div>
</section>
{/* ── CTA Buttons ── */}
<div className="dash-cta">
<button className="dash-cta__btn" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}>
<span className="dash-cta__label">Start Game</span>
</button>
<button className="dash-cta__btn dash-cta__btn--ranked" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}>
<span className="dash-cta__label">Queue Ranked</span>
</button>
</div>
{/* ── Online count ── */}
{onlineCount !== null && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 8 }}>
<span style={{
fontSize: 12, fontWeight: 600, color: 'rgba(255,255,255,0.5)',
display: 'flex', alignItems: 'center', gap: 6,
fontFamily: 'system-ui, sans-serif',
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%',
background: '#4caf50',
boxShadow: '0 0 6px rgba(76,175,80,0.8)',
display: 'inline-block',
}} />
{onlineCount} {onlineCount === 1 ? 'player' : 'players'} online
</span>
</div>
)}
{/* ── Error banner ── */}
{error && (
<div className="dash-error-banner">
<span>{error}</span>
<button className="dash-error-banner__dismiss" onClick={() => setError(null)}></button>
</div>
)}
{/* ── Stats row ── */}
<div className="dash-stats">
<StatCard
label="Best Time"
value={fmtTime(currentEntry?.bestTime)}
accent
/>
<StatCard
label="Games Played"
value={currentEntry?.gamesPlayed ?? '—'}
/>
<StatCard
label="ELO"
value={currentEntry?.elo?.toLocaleString() ?? '—'}
/>
</div>
{/* ── Leaderboard ── */}
<section className="dash-section">
<div className="dash-section__hd">
<h2 className="dash-section__title">Leaderboard</h2>
<span className="dash-section__badge">Best Time</span>
</div>
<div className="dash-card">
<LeaderboardTable
entries={leaderboard}
loading={lbLoading}
highlightRank={highlightRank}
grade={selectedGrade}
/>
</div>
</section>
{/* ── Recent Activity ── */}
<section className="dash-section">
<div className="dash-section__hd">
<h2 className="dash-section__title">Recent Activity</h2>
</div>
<div className="dash-card">
<div className="dash-empty">
<span className="dash-empty__icon">🎮</span>
<p className="dash-empty__text">No games played yet</p>
<p className="dash-empty__sub">Complete a game to see your activity here</p>
{/* ── ELO + Tier progress ── */}
<div className="db-elo-block">
<div className="db-elo-block__inner">
<div className="db-elo-main">
<span className="db-elo-val" style={{ color: tier.color, textShadow: `0 0 24px ${tier.glow}` }}>
{animatedElo.toLocaleString()}
</span>
<span className="db-elo-label">ELO</span>
{eloRank && (
<span className="db-elo-rank">
#{eloRank}
{totalPlayers > 0 && <span className="db-elo-rank__total"> / {totalPlayers}</span>}
</span>
)}
</div>
<div className="db-elo-block__right">
<TierProgress elo={elo} />
</div>
</div>
</div>
</section>
{/* ── Stats strip ── */}
<div className="db-stats">
{[
{ label: 'Best Time', value: fmtTime(currentEntry?.bestTime), accent: true },
{ label: 'Games', value: animatedGames },
{ label: 'Correct', value: currentEntry?.totalCorrect ?? 0 },
{ label: 'Time Rank', value: currentEntry?.timeRank != null ? `#${currentEntry.timeRank}` : '—' },
].map(s => (
<div key={s.label} className="db-stat">
<span className="db-stat__val" style={s.accent ? { color: 'var(--gold)', textShadow: '0 0 18px rgba(245,197,24,0.35)' } : {}}>
{s.value}
</span>
<span className="db-stat__label">{s.label}</span>
</div>
))}
</div>
{/* ── Recent Matches + Leaderboard side by side ── */}
<div className="db-two-col">
{/* Recent Matches */}
<section className="db-section db-section--half">
<div className="db-section__hd">
<span className="db-section__title">Recent Matches</span>
{winStreak >= 2 && (
<span className="db-section__badge" style={{ background: 'rgba(245,197,24,0.08)', borderColor: 'rgba(245,197,24,0.3)', color: 'var(--gold)' }}>
🔥 {winStreak} streak
</span>
)}
</div>
<div className="dash-card">
<MatchHistory matches={matches} loading={matchLoading} />
</div>
</section>
{/* Leaderboard */}
<section className="db-section db-section--half">
<div className="db-section__hd">
<span className="db-section__title">Leaderboard</span>
<div className="lb-tabs">
<button className={`lb-tab${lbTab === 'elo' ? ' lb-tab--active' : ''}`} onClick={() => setLbTab('elo')}>ELO</button>
<button className={`lb-tab${lbTab === 'time' ? ' lb-tab--active' : ''}`} onClick={() => setLbTab('time')}>Time</button>
</div>
</div>
<div className="dash-card">
<LeaderboardTable entries={leaderboard} loading={lbLoading} highlightRank={highlightRank} tab={lbTab} />
</div>
</section>
</div>
{/* ── All-grades overview ── */}
<section className="db-section">
<div className="db-section__hd">
<span className="db-section__title">All Grades</span>
</div>
<div className="db-grades">
{GRADES.map(g => {
const e = stats.find(s => s.grade === g.id);
const t = getTier(e?.elo ?? 800);
return (
<div
key={g.id}
className={`db-grade-cell${selectedGrade === g.id ? ' db-grade-cell--active' : ''}`}
onClick={() => setSelectedGrade(g.id)}
>
<div className="db-grade-cell__top">
<span className="db-grade-cell__name">{g.label}</span>
<span className="tier-badge tier-badge--sm" style={{ '--tier-color': t.color, '--tier-glow': t.glow }}>
{t.name}
</span>
</div>
<div className="db-grade-cell__row">
<span className="db-grade-cell__val db-grade-cell__val--elo" style={{ color: t.color, textShadow: `0 0 10px ${t.glow}` }}>
{e?.elo?.toLocaleString() ?? '800'}
</span>
<span className="db-grade-cell__unit">ELO</span>
</div>
<div className="db-grade-cell__row">
<span className="db-grade-cell__val">{fmtTime(e?.bestTime)}</span>
<span className="db-grade-cell__unit">best</span>
</div>
<div className="db-grade-cell__row">
<span className="db-grade-cell__val">{e?.gamesPlayed ?? 0}</span>
<span className="db-grade-cell__unit">games</span>
</div>
</div>
);
})}
</div>
</section>
</div>
</main>
</>
);
+393
View File
@@ -0,0 +1,393 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
const DIAMONDS = [
{ left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' },
{ left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' },
{ left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' },
{ left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' },
{ left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' },
{ left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' },
{ left: '82%', top: '30%', size: 13, dur: '14s', delay: '1s' },
{ left: '90%', top: '75%', size: 9, dur: '9s', delay: '4s' },
{ left: '38%', top: '45%', size: 6, dur: '15s', delay: '2.5s' },
{ left: '62%', top: '88%', size: 11, dur: '11s', delay: '0.3s' },
{ left: '12%', top: '82%', size: 8, dur: '13s', delay: '1.8s' },
{ left: '76%', top: '10%', size: 12, dur: '10s', delay: '3.5s' },
];
const RINGS = [
{ left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' },
{ left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' },
{ left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' },
{ left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' },
];
const SCANLINES = [
{ top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
{ top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' },
{ top: '18%', width: '200px', left: '38%', dur: '12s', delay: '1.5s' },
{ top: '75%', width: '140px', left: '20%', dur: '9s', delay: '2s' },
];
const PAGE_CSS = `
@keyframes faqFadeUp {
from { opacity:0; transform:translateY(16px); }
to { opacity:1; transform:translateY(0); }
}
@keyframes faqHeroPulse {
0%,100% { opacity:.2; transform:translate(-50%,-50%) scale(1); }
50% { opacity:.38; transform:translate(-50%,-50%) scale(1.12); }
}
.fq-orb {
position:absolute; top:50%; left:50%;
width:900px; height:600px;
background:radial-gradient(ellipse at center,
rgba(245,197,24,.06) 0%, rgba(37,99,235,.04) 45%, transparent 70%);
border-radius:50%; pointer-events:none;
animation:faqHeroPulse 10s ease-in-out infinite;
}
.fq-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);
}
.fq-nav__logo {
font-family:'Russo One',sans-serif; font-size:19px;
letter-spacing:.04em; text-transform:uppercase;
color:#eceef8; text-decoration:none;
}
.fq-nav__logo span { color:#f5c518; }
.fq-nav__links { display:flex; align-items:center; gap:28px; }
.fq-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;
}
.fq-nav-link:hover { color:#eceef8; }
.fq-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;
}
.fq-nav-signin:hover { background:rgba(245,197,24,.16); border-color:rgba(245,197,24,.6); }
.fq-wrap { width:100%; max-width:800px; margin:0 auto; padding:0 24px; }
.fq-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;
}
.fq-eyebrow::before,.fq-eyebrow::after {
content:''; display:block; width:36px; height:1px;
background:rgba(245,197,24,.4);
}
.fq-item {
border-bottom:1px solid rgba(255,255,255,.07);
}
.fq-item:first-child {
border-top:1px solid rgba(255,255,255,.07);
}
.fq-question {
width:100%; display:flex; align-items:center;
justify-content:space-between; padding:22px 0;
background:none; border:none; cursor:pointer; text-align:left; gap:16px;
transition:opacity .15s;
}
.fq-question:hover .fq-q-text { color:#eceef8; }
.fq-q-num {
font-family:'Russo One',sans-serif; font-size:12px;
color:rgba(245,197,24,.35); min-width:28px; flex-shrink:0;
transition:color .2s;
}
.fq-item--open .fq-q-num { color:rgba(245,197,24,.7); }
.fq-q-text {
font-family:'Barlow Condensed',sans-serif; font-size:20px;
font-weight:700; letter-spacing:.03em; color:#9ba5c4;
transition:color .2s; flex:1;
}
.fq-item--open .fq-q-text { color:#eceef8; }
.fq-chevron {
flex-shrink:0; transition:transform .3s cubic-bezier(.16,1,.3,1);
}
.fq-item--open .fq-chevron { transform:rotate(180deg); }
.fq-answer-wrap {
overflow:hidden;
transition:max-height .38s cubic-bezier(.16,1,.3,1);
}
.fq-answer {
padding:0 0 26px 44px;
font-family:'Barlow',sans-serif; font-size:15px;
color:#7a85a8; line-height:1.75;
transition:opacity .25s ease;
}
.fq-answer strong { color:#9ba5c4; font-weight:600; }
.fq-footer-link {
font-family:'Barlow Condensed',sans-serif; font-size:12px;
letter-spacing:.12em; text-transform:uppercase;
color:#7a85a8; text-decoration:none; transition:color .2s;
}
.fq-footer-link:hover { color:#eceef8; }
@media (max-width:768px) {
.fq-nav { padding:0 20px; }
.fq-nav-link.fq-hide-mobile { display:none; }
.fq-answer { padding-left:28px; }
}
@media (max-width:500px) {
.fq-nav__links { gap:12px; }
.fq-q-text { font-size:17px; }
}
`;
const FAQS = [
{
q: 'What is EQAO?',
a: 'EQAO stands for the Education Quality and Accountability Office. It\'s Ontario\'s standardized testing body, responsible for the province-wide assessments that all students in Grades 3, 6, and 9 take. The tests cover math and literacy. This app focuses on the math portion.',
},
{
q: 'Is this official?',
a: 'No. Ranked EQAO is an independent project — it has no affiliation with EQAO, the Ontario government, or any school board. It\'s built by one developer using publicly known curriculum expectations. Think of it as fan-made study content.',
},
{
q: 'Is it free?',
a: 'Yes, completely. No ads, no paywalls, no subscription, no premium tier. It costs nothing and always will. The goal is to be actually useful to students, and charging for it would work against that.',
},
{
q: 'How does ranking work?',
a: 'Ranked EQAO uses an ELO system — the same rating model used in chess and most competitive games. Everyone starts at 800. When you win a match, you gain points; when you lose, you lose points. How many you gain or lose depends on the difference in ratings between you and your opponent.\n\nFive tiers: Bronze (800), Silver (900), Gold (1100), Platinum (1300), Diamond (1500+).',
},
{
q: 'What grades are supported?',
a: 'Grade 3, Grade 6, and Grade 9 math. Each grade has its own separate queue, leaderboard, and ELO rating — so your Grade 6 rank doesn\'t affect your Grade 9 rank. You can play any grade you want.',
},
{
q: 'How are questions generated?',
a: 'Math questions are built from pre-written templates that match EQAO curriculum expectations for each grade. The values — numbers, units, names, scenarios — are randomized on every attempt, so you can\'t just memorize answers. The structure stays consistent with EQAO question style.',
},
{
q: 'What is a ranked match?',
a: 'A ranked match is a 1v1 race. You and an opponent from the matchmaking queue get the same set of questions at the same time. First player to correctly answer all questions wins the match and gains ELO. The other player loses ELO. Speed and accuracy both matter.',
},
{
q: 'What happens if my opponent disconnects?',
a: 'If your opponent goes inactive for more than 20 seconds — closes the tab, loses connection, or just stops responding — they\'re marked as a forfeit. You get the win and the ELO that comes with it. This avoids draws or unfair timeouts from connection issues.',
},
{
q: 'Can I change my username?',
a: 'Yes. Go to Settings from your dashboard and you\'ll see the option to update your username. Usernames must be 320 characters and can only contain letters, numbers, and underscores. Changes take effect immediately.',
},
{
q: 'Why do I have to get 100% to advance?',
a: "That's how the real EQAO test works — every question counts and there's no partial credit. This app reflects that. Getting 9 out of 11 correct isn't a pass in the actual test, and it's not a pass here either. The point is mastery, not getting close enough.",
},
{
q: 'Is there a mobile app?',
a: 'Not yet. There\'s no native iOS or Android app. The web app is built to be responsive and should work reasonably well on mobile browsers, but it\'s designed primarily for desktop play. A dedicated mobile app is something that could happen in the future.',
},
{
q: 'Will English or language questions be added?',
a: 'Yes — English is planned. The backend already has a Gemini integration for generating reading passages and comprehension questions, as well as checking free-response answers. It\'s partially built and will be available in a future update.',
},
];
export default function FAQ() {
const navigate = useNavigate();
const [openIndex, setOpenIndex] = useState(null);
function toggle(i) {
setOpenIndex(prev => (prev === i ? null : i));
}
return (
<>
<style>{PAGE_CSS}</style>
{/* Background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{DIAMONDS.map((d, i) => (
<div key={i} className="shape shape--diamond" style={{ left: d.left, top: d.top, width: d.size, height: d.size, animationDuration: d.dur, animationDelay: d.delay }} />
))}
{RINGS.map((r, i) => (
<div key={i} className="shape shape--ring" style={{ left: r.left, top: r.top, width: r.size, height: r.size, animationDuration: r.dur, animationDelay: r.delay }} />
))}
{SCANLINES.map((s, i) => (
<div key={i} className="shape shape--line" style={{ top: s.top, left: s.left, width: s.width, animationDuration: s.dur, animationDelay: s.delay }} />
))}
</div>
</div>
{/* Nav */}
<nav className="fq-nav">
<a href="/" className="fq-nav__logo">RANKED<span>EQAO</span></a>
<div className="fq-nav__links">
<a href="/#how-it-works" className="fq-nav-link fq-hide-mobile">How It Works</a>
<a href="/#leaderboard" className="fq-nav-link fq-hide-mobile">Leaderboard</a>
<a href="/about" className="fq-nav-link fq-hide-mobile">About</a>
<a href="/#signin" className="fq-nav-signin">Sign In</a>
</div>
</nav>
<div style={{ position: 'relative', zIndex: 1 }}>
{/* HERO */}
<section style={{
minHeight: '50vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '120px 0 60px', textAlign: 'center',
position: 'relative', overflow: 'hidden',
}}>
<div className="fq-orb" />
<div style={{ position: 'relative', zIndex: 1, width: '100%' }}>
<p style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 11, fontWeight: 700,
letterSpacing: '0.3em', textTransform: 'uppercase', color: '#f5c518',
marginBottom: 20, opacity: 0, animation: 'faqFadeUp .6s .05s ease both',
}}>
Got Questions?
</p>
<h1 style={{
fontFamily: "'Russo One',sans-serif",
fontSize: 'clamp(72px,16vw,160px)',
lineHeight: 0.85, letterSpacing: '-0.02em',
textTransform: 'uppercase', color: '#f5c518',
textShadow: '0 0 80px rgba(245,197,24,.3)',
marginBottom: 28, opacity: 0,
animation: 'faqFadeUp .7s .15s cubic-bezier(.16,1,.3,1) both',
}}>
FAQ
</h1>
<p style={{
fontFamily: "'Barlow Condensed',sans-serif",
fontSize: 'clamp(14px,2.2vw,18px)', fontWeight: 500,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: '#7a85a8', opacity: 0,
animation: 'faqFadeUp .6s .35s ease both',
}}>
Everything you need to know about Ranked EQAO
</p>
</div>
</section>
{/* ACCORDION */}
<section style={{
padding: '60px 0 100px',
background: 'rgba(13,15,28,.78)',
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
}}>
<div className="fq-wrap">
{FAQS.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={i}
className={`fq-item${isOpen ? ' fq-item--open' : ''}`}
>
<button className="fq-question" onClick={() => toggle(i)} aria-expanded={isOpen}>
<span className="fq-q-num">{String(i + 1).padStart(2, '0')}</span>
<span className="fq-q-text">{item.q}</span>
<svg
className="fq-chevron"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="rgba(122,133,168,.6)"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
className="fq-answer-wrap"
style={{ maxHeight: isOpen ? '600px' : '0' }}
>
<p
className="fq-answer"
style={{ opacity: isOpen ? 1 : 0 }}
>
{item.a.split('\n\n').map((para, pi) => (
<span key={pi} style={{ display: 'block', marginBottom: pi < item.a.split('\n\n').length - 1 ? 14 : 0 }}>
{para}
</span>
))}
</p>
</div>
</div>
);
})}
</div>
{/* Bottom CTA */}
<div style={{ textAlign: 'center', marginTop: 72, padding: '0 24px' }}>
<p style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 16,
letterSpacing: '0.06em', color: '#4a5470', marginBottom: 20,
}}>
Still have a question?
</p>
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
<Link
to="/about"
style={{
padding: '12px 28px',
background: 'rgba(245,197,24,.08)', border: '1px solid rgba(245,197,24,.35)',
borderRadius: 8, fontFamily: "'Barlow Condensed',sans-serif",
fontSize: 13, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
color: '#f5c518', textDecoration: 'none', display: 'inline-block',
transition: 'background .2s, border-color .2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,.16)'; 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)'; }}
>
Read About The Project
</Link>
<button
onClick={() => navigate('/')}
style={{
padding: '12px 28px', background: 'transparent', color: '#eceef8',
border: '1.5px solid rgba(255,255,255,.15)', borderRadius: 8,
fontFamily: "'Barlow Condensed',sans-serif",
fontSize: 13, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
cursor: 'pointer', transition: 'border-color .2s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.4)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.15)'; }}
>
Back to Home
</button>
</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="fq-wrap" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18, maxWidth: 1100 }}>
<Link to="/" style={{ textDecoration: 'none' }}>
<span style={{ fontFamily: "'Russo One',sans-serif", fontSize: 16, letterSpacing: '0.04em', color: '#eceef8' }}>
RANKED<span style={{ color: '#f5c518' }}>EQAO</span>
</span>
</Link>
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap', justifyContent: 'center' }}>
<Link to="/" className="fq-footer-link">Home</Link>
<Link to="/about" className="fq-footer-link">About</Link>
</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>
</div>
</>
);
}
+204 -62
View File
@@ -10,16 +10,48 @@ function fmtDuration(ms) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function GlobalStyles() {
return (
<style>{`
@keyframes questionEnter { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
@keyframes answerPulse { 0%{transform:scale(1)} 50%{transform:scale(0.97)} 100%{transform:scale(1)} }
@keyframes submitGlow { from{box-shadow:0 0 8px #ff69b4aa} to{box-shadow:0 0 22px #ff69b4,0 0 40px #ff69b466} }
@keyframes bubblePop { 0%{transform:scale(0.7)} 70%{transform:scale(1.1)} 100%{transform:scale(1)} }
@keyframes titleFadeDown { from{opacity:0;transform:translateY(-16px)} to{opacity:1;transform:translateY(0)} }
@keyframes statsFadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
@keyframes spin { to{transform:rotate(360deg);} }
`}</style>
);
}
// ── ProgressStrip ──────────────────────────────────────────────
function ProgressStrip({ total, current, answers, onJump }) {
const [fillCounts, setFillCounts] = useState({});
const prevAnswersRef = useRef({});
useEffect(() => {
const updates = {};
for (let qi = 0; qi < total; qi++) {
if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true;
}
if (Object.keys(updates).length > 0) {
setFillCounts(prev => {
const next = { ...prev };
for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1;
return next;
});
}
prevAnswersRef.current = answers;
}, [answers, total]);
const items = [];
for (let qi = 0; qi < total; qi++) {
const active = qi === current;
const answered = answers[qi] != null;
const filled = answered;
const active = qi === current;
const filled = answers[qi] != null;
const popCount = fillCounts[qi] || 0;
items.push(
<button
key={`b-${qi}`}
key={`b-${qi}-${popCount}`}
onClick={() => onJump && onJump(qi)}
style={{
width: 36, height: 36, borderRadius: '50%',
@@ -30,6 +62,7 @@ function ProgressStrip({ total, current, answers, onJump }) {
fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif',
flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box',
transition: 'background 0.15s, border-color 0.15s',
animation: popCount > 0 ? 'bubblePop 0.25s ease-out' : 'none',
}}
>
{qi + 1}
@@ -63,6 +96,12 @@ const NAV_BTN = {
border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8',
};
const QUIT_BTN = {
padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
fontSize: 14, fontWeight: 600, cursor: 'pointer',
border: '1.5px solid #e57373', background: '#fff', color: '#c62828',
};
const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' };
// ── Screens ────────────────────────────────────────────────────
@@ -89,7 +128,10 @@ function StageIntro({ stage, onNext }) {
);
}
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump }) {
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit, grade }) {
const [pulsingIdx, setPulsingIdx] = useState(null);
const [hoverIdx, setHoverIdx] = useState(null);
useEffect(() => {
const handler = (e) => {
const i = +e.key - 1;
@@ -102,16 +144,24 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
return (
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
<div style={{ background: '#07080f', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div className="arena-bg"><div className="arena-grain" /></div>
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 1, borderTop: '3px solid #1a73e8', boxShadow: '0 0 60px rgba(26,115,232,0.08), 0 0 40px rgba(0,60,120,0.10)' }}>
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '24px 32px 20px' }}>
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 20px 0', lineHeight: 1 }}>
Question {qIdx + 1} of {STAGE_SIZE}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px 0' }}>
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: 0, lineHeight: 1, fontFamily: "'Barlow Condensed', system-ui, sans-serif", letterSpacing: '0.01em' }}>
Question {qIdx + 1} of {STAGE_SIZE}
</p>
{grade && (
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4, background: 'rgba(26,115,232,0.08)', border: '1px solid rgba(26,115,232,0.25)', color: '#1a73e8', letterSpacing: '0.06em', fontFamily: 'system-ui,sans-serif', flexShrink: 0 }}>
{grade}
</span>
)}
</div>
<ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} />
</div>
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
<div key={qIdx} style={{ flex: 1, padding: '28px 32px 24px', animation: 'questionEnter 0.2s ease-out' }}>
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>
{q.question}
</p>
@@ -121,20 +171,28 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
}>
{q.choices.map((choice, i) => {
const sel = selected === i;
const hov = hoverIdx === i && !sel;
return (
<button
key={i}
onClick={() => onSelect(i)}
onClick={() => {
onSelect(i);
setPulsingIdx(i);
setTimeout(() => setPulsingIdx(p => p === i ? null : p), 150);
}}
onMouseEnter={() => setHoverIdx(i)}
onMouseLeave={() => setHoverIdx(null)}
style={{
minHeight: 64, padding: '18px 24px',
background: sel ? '#e8f0fe' : '#f7f8fa',
border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`,
background: sel ? '#e8f0fe' : hov ? '#eef2ff' : '#f7f8fa',
border: `2px solid ${sel ? '#1a73e8' : hov ? '#1a73e8' : '#e0e0e0'}`,
borderRadius: 12, fontSize: 20,
color: sel ? '#1a73e8' : '#222',
textAlign: 'left', cursor: 'pointer',
fontWeight: sel ? 600 : 400,
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
transition: 'background 0.12s, border-color 0.12s',
animation: pulsingIdx === i ? 'answerPulse 0.15s ease-out' : 'none',
}}
>
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>
@@ -147,15 +205,22 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
</div>
</div>
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}> Back</button>}
{qIdx === STAGE_SIZE - 1 ? (
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>
Submit
</button>
) : (
<button style={NAV_BTN} onClick={onNext}>Next </button>
)}
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'space-between', gap: 14 }}>
<button style={QUIT_BTN} onClick={onQuit}>Quit</button>
<div style={{ display: 'flex', gap: 14 }}>
{qIdx > 0 && <button className="quiz-nav-btn" style={NAV_BTN} onClick={onBack}> Back</button>}
{qIdx === STAGE_SIZE - 1 ? (
<button
className="quiz-nav-btn"
style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', animation: 'submitGlow 1.5s ease-in-out infinite alternate' }}
onClick={onNext}
>
Submit
</button>
) : (
<button className="quiz-nav-btn" style={NAV_BTN} onClick={onNext}>Next </button>
)}
</div>
</div>
</div>
</div>
@@ -256,39 +321,67 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on
);
}
function CompleteScreen({ duration, grade, onDashboard }) {
const [pbState, setPbState] = useState(null);
useEffect(() => {
const token = sessionStorage.getItem('ranked_token');
if (!token || !grade) { setPbState({ updated: false }); return; }
fetch(`${API}/me/best-time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ grade, timeMs: duration }),
})
.then(r => r.json())
.then(data => setPbState({ updated: data.updated ?? false }))
.catch(() => setPbState({ updated: false }));
}, []);
function CompleteScreen({ duration, isNewBest, onDashboard }) {
return (
<div style={WRAP}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
<div style={{ fontSize: 60 }}>🎉</div>
<h1 style={{ fontSize: 30, fontWeight: 800, color: '#111' }}>Quiz Complete!</h1>
<p style={{ fontSize: 17, color: '#555' }}>You completed both stages of the Mathematics quiz.</p>
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
{fmtDuration(duration)}
<div style={{
minHeight: '100vh',
background: 'radial-gradient(ellipse at 50% 0%, rgba(245,197,24,0.12) 0%, transparent 60%), #07080f',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
fontFamily: 'system-ui,sans-serif', position: 'relative', overflow: 'hidden',
}}>
<div style={{ width: '100%', maxWidth: 480, padding: '0 24px', textAlign: 'center', boxSizing: 'border-box' }}>
<div style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase',
color: 'rgba(245,197,24,0.45)', marginBottom: 16,
}}>
Mathematics
</div>
{pbState?.updated && (
<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' }}>
New Personal Best!
<div style={{
fontSize: 'clamp(64px, 10vw, 96px)', fontWeight: 900,
color: '#f5c518', letterSpacing: '0.05em', lineHeight: 1,
fontFamily: '"Russo One", system-ui, sans-serif',
animation: 'titleFadeDown 0.5s ease-out both',
}}>
COMPLETE
</div>
<div style={{ width: 80, height: 2, background: '#f5c518', margin: '16px auto 24px' }} />
<div style={{
fontSize: 'clamp(36px, 6vw, 56px)', fontWeight: 700, color: '#fff',
fontFamily: 'monospace', letterSpacing: '0.08em',
animation: 'statsFadeUp 0.4s ease-out 0.3s both',
}}>
{fmtDuration(duration)}
</div>
{isNewBest && (
<div style={{
display: 'inline-block', marginTop: 16,
padding: '8px 24px',
background: 'rgba(245,197,24,0.12)',
border: '1.5px solid rgba(245,197,24,0.5)',
borderRadius: 999, fontSize: 14, fontWeight: 700,
color: '#f5c518', letterSpacing: '0.06em', textTransform: 'uppercase',
boxShadow: '0 0 12px rgba(245,197,24,0.35)',
animation: 'statsFadeUp 0.4s ease-out 0.45s both',
}}>
New Personal Best!
</div>
)}
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>
Back to Dashboard
</button>
<div style={{ marginTop: 32, animation: 'statsFadeUp 0.4s ease-out 0.55s both' }}>
<button
onClick={onDashboard}
style={{
padding: '14px 48px', borderRadius: 6, fontSize: 15, fontWeight: 700,
letterSpacing: '0.08em', textTransform: 'uppercase', cursor: 'pointer',
border: '1px solid rgba(245,197,24,0.5)',
background: 'rgba(245,197,24,0.1)', color: '#f5c518',
fontFamily: 'system-ui,sans-serif',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,0.2)'; e.currentTarget.style.borderColor = '#f5c518'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(245,197,24,0.1)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,0.5)'; }}
>
Back to Dashboard
</button>
</div>
</div>
</div>
);
@@ -301,16 +394,23 @@ export default function Game() {
const { state } = useLocation();
const grade = state?.grade ?? null;
if (!state) {
navigate('/dashboard', { replace: true });
return null;
}
const [phase, setPhase] = useState('loading');
const [stage, setStage] = useState(1);
const [questions, setQuestions] = useState([]);
const [qIdx, setQIdx] = useState(0);
const [answers, setAnswers] = useState({});
const [selected, setSelected] = useState(null);
const [wrongCount, setWrongCount] = useState(0);
const [duration, setDuration] = useState(0);
const [elapsed, setElapsed] = useState(0);
const [fetchError, setFetchError] = useState(false);
const [wrongCount, setWrongCount] = useState(0);
const [duration, setDuration] = useState(0);
const [elapsed, setElapsed] = useState(0);
const [fetchError, setFetchError] = useState(false);
const [isNewBest, setIsNewBest] = useState(false);
const [stageFlash, setStageFlash] = useState(false);
const timerStartRef = useRef(null);
const intervalRef = useRef(null);
@@ -418,13 +518,40 @@ export default function Game() {
setPhase('question');
}
function handleResultNext() {
function handleQuit() {
if (!window.confirm('Are you sure you want to quit? Your progress will be lost.')) return;
stopTimer();
navigate('/dashboard');
}
async function handleResultNext() {
if (stage === 1) {
s1CorrectRef.current = STAGE_SIZE - wrongCount;
loadStage(2);
setStageFlash(true);
setTimeout(() => {
setStageFlash(false);
loadStage(2);
}, 800);
} else {
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
setDuration(stopTimer());
const token = sessionStorage.getItem('ranked_token');
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
stopTimer();
console.log('Submitting best time:', grade, timeMs);
let isNewBest = false;
try {
const res = await fetch(`${API}/me/best-time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ grade, timeMs }),
});
const data = await res.json();
isNewBest = data.updated === true;
} catch (e) {
console.error('Failed to record best time:', e);
}
setDuration(timeMs);
setIsNewBest(isNewBest);
setPhase('complete');
}
}
@@ -437,18 +564,18 @@ export default function Game() {
if (phase === 'loading') return (
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />;
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} />;
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} onQuit={handleQuit} grade={grade} />;
if (phase === 'result') return <StageResult stage={stage} wrongCount={wrongCount} questions={questions} answers={answers} onRetry={handleRetry} onNext={handleResultNext} />;
if (phase === 'complete') return <CompleteScreen duration={duration} grade={grade} onDashboard={() => navigate('/dashboard')} />;
if (phase === 'complete') return <CompleteScreen duration={duration} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
return null;
}
return (
<>
<GlobalStyles />
{timerRunning && (
<div style={{
position: 'fixed', top: 18, right: 24, zIndex: 200,
@@ -463,6 +590,21 @@ export default function Game() {
</div>
)}
{renderPhase()}
{stageFlash && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: '#07080f',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
fontSize: 'clamp(28px, 5vw, 48px)', fontWeight: 900, color: '#f5c518',
fontFamily: '"Russo One", system-ui, sans-serif',
letterSpacing: '0.05em',
}}>
Stage 1 Complete.
</div>
</div>
)}
</>
);
}
+743 -117
View File
@@ -1,139 +1,777 @@
import { useState } from 'react';
import LeaderboardModal from '../components/LeaderboardModal';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const API = import.meta.env.VITE_API_URL;
/* Animated background shapes — generated once, stable positions */
const DIAMONDS = [
{ left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' },
{ left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' },
{ left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' },
{ left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' },
{ left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' },
{ left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' },
{ left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' },
{ left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' },
{ left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' },
{ left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' },
{ left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' },
{ left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' },
{ left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' },
{ left: '82%', top: '30%', size: 13, dur: '14s', delay: '1s' },
{ left: '90%', top: '75%', size: 9, dur: '9s', delay: '4s' },
{ left: '38%', top: '45%', size: 6, dur: '15s', delay: '2.5s' },
{ left: '62%', top: '88%', size: 11, dur: '11s', delay: '0.3s' },
{ left: '12%', top: '82%', size: 8, dur: '13s', delay: '1.8s' },
{ left: '76%', top: '10%', size: 12, dur: '10s', delay: '3.5s' },
];
const RINGS = [
{ left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' },
{ left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' },
{ left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' },
{ left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' },
{ left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' },
{ left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' },
{ left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' },
];
const SCANLINES = [
{ top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
{ top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' },
{ top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' },
{ top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
{ top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' },
{ top: '18%', width: '200px', left: '38%', dur: '12s', delay: '1.5s' },
{ top: '75%', width: '140px', left: '20%', dur: '9s', delay: '2s' },
];
const INPUT_STYLE = {
width: '100%', padding: '12px 16px', background: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.18)', borderRadius: 10, color: '#fff',
fontSize: 15, fontFamily: 'system-ui,sans-serif', outline: 'none', boxSizing: 'border-box',
};
const ERROR_MSGS = {
email_taken: 'An account with this email already exists.',
username_taken: 'That username is already taken.',
use_google: 'This account uses Google sign-in — use the Google button above.',
invalid_credentials: 'Incorrect email or password.',
invalid_email: 'Please enter a valid email address.',
password_too_short: 'Password must be at least 8 characters.',
invalid_username: 'Username must be 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() {
const [lbOpen, setLbOpen] = useState(false);
const navigate = useNavigate();
const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed';
const [authMode, setAuthMode] = useState('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState('');
const [lbGrade, setLbGrade] = useState('G3');
const [lbEntries, setLbEntries] = useState([]);
const [lbLoading, setLbLoading] = useState(true);
function resetForm() { setEmail(''); setPassword(''); setUsername(''); setFormError(''); }
async function handleEmailAuth(e) {
e.preventDefault();
setFormError('');
setSubmitting(true);
try {
const isRegister = authMode === 'register';
const body = isRegister
? { email, password, ...(username.trim() ? { username: username.trim() } : {}) }
: { email, password };
const res = await fetch(`${API}/auth/${isRegister ? 'register' : 'login'}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) { setFormError(ERROR_MSGS[data.error] ?? 'Something went wrong. Please try again.'); return; }
sessionStorage.setItem('ranked_token', data.token);
navigate('/dashboard', { replace: true });
} catch {
setFormError('Something went wrong. Check your connection.');
} finally {
setSubmitting(false);
}
}
useEffect(() => {
setLbLoading(true);
fetch(`${API}/leaderboard?grade=${lbGrade}&type=time`)
.then(r => r.json())
.then(d => setLbEntries(Array.isArray(d) ? d.slice(0, 5) : []))
.catch(() => setLbEntries([]))
.finally(() => setLbLoading(false));
}, [lbGrade]);
useEffect(() => {
if (authError) setTimeout(() => scrollTo('signin'), 400);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function scrollTo(id) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
}
return (
<>
<LeaderboardModal isOpen={lbOpen} onClose={() => setLbOpen(false)} />
<style>{LANDING_CSS}</style>
{/* Animated background */}
{/* Fixed animated background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{DIAMONDS.map((d, i) => (
<div
key={i}
className="shape shape--diamond"
style={{
left: d.left,
top: d.top,
width: d.size,
height: d.size,
animationDuration: d.dur,
animationDelay: d.delay,
}}
/>
<div key={i} className="shape shape--diamond" style={{ left: d.left, top: d.top, width: d.size, height: d.size, animationDuration: d.dur, animationDelay: d.delay }} />
))}
{RINGS.map((r, i) => (
<div
key={i}
className="shape shape--ring"
style={{
left: r.left,
top: r.top,
width: r.size,
height: r.size,
animationDuration: r.dur,
animationDelay: r.delay,
}}
/>
<div key={i} className="shape shape--ring" style={{ left: r.left, top: r.top, width: r.size, height: r.size, animationDuration: r.dur, animationDelay: r.delay }} />
))}
{SCANLINES.map((s, i) => (
<div
key={i}
className="shape shape--line"
style={{
top: s.top,
left: s.left,
width: s.width,
animationDuration: s.dur,
animationDelay: s.delay,
}}
/>
<div key={i} className="shape shape--line" style={{ top: s.top, left: s.left, width: s.width, animationDuration: s.dur, animationDelay: s.delay }} />
))}
</div>
</div>
{/* Page content */}
<main className="page login">
<p className="login__eyebrow">Ontario Standardized Tests</p>
<div className="login__hero">
<h1 className="login__title">
<span>Ranked</span>
EQAO
</h1>
<p className="login__tagline">
Compete. Practice. <em>Dominate</em> your grade.
</p>
{/* ── Sticky Nav ─────────────────────────────────────── */}
<nav className="lp-nav">
<button className="lp-nav__logo" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
RANKED<span>EQAO</span>
</button>
<div className="lp-nav__links">
<a href="#how-it-works" className="lp-nav-link hide-mobile">How It Works</a>
<a href="#leaderboard" className="lp-nav-link hide-mobile">Leaderboard</a>
<a href="/about" className="lp-nav-link hide-mobile">About</a>
<a href="/faq" className="lp-nav-link hide-mobile">FAQ</a>
<button className="lp-nav-signin" onClick={() => scrollTo('signin')}>Sign In</button>
</div>
</nav>
<div className="grade-badges">
{[
{ id: 'G3', label: 'Grade 3' },
{ id: 'G6', label: 'Grade 6' },
{ id: 'G9', label: 'Grade 9' },
].map(({ id, label }) => (
<div key={id} className="grade-badge">
<span className="grade-badge__rank">{id}</span>
<span className="grade-badge__label">{label}</span>
{/* ── Page ───────────────────────────────────────────── */}
<div style={{ position: 'relative', zIndex: 1 }}>
{/* ── HERO ─────────────────────────────────────────── */}
<section style={{
minHeight: '100vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '100px 0 80px', textAlign: 'center',
position: 'relative', overflow: 'hidden',
}}>
<div className="lp-hero-orb" />
<div className="lp-container" style={{ position: 'relative', zIndex: 1 }}>
<p style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 11, fontWeight: 700,
letterSpacing: '0.3em', textTransform: 'uppercase', color: '#f5c518',
marginBottom: 28, opacity: 0, animation: 'fadeUp .6s .05s ease both',
}}>
Ontario Standardized Tests
</p>
<h1 style={{
fontFamily: "'Russo One',sans-serif",
fontSize: 'clamp(68px,14vw,148px)',
lineHeight: 0.9, letterSpacing: '-0.01em',
textTransform: 'uppercase', marginBottom: 36,
}}>
<span className="lp-word lp-word-1" style={{ color: '#eceef8' }}>COMPETE.</span>
<span className="lp-word lp-word-2" style={{ color: '#eceef8' }}>PRACTICE.</span>
<span className="lp-word lp-word-3" style={{ color: '#f5c518', textShadow: '0 0 60px rgba(245,197,24,.4)' }}>DOMINATE.</span>
</h1>
<p style={{
fontFamily: "'Barlow',sans-serif", fontSize: 'clamp(15px,2.4vw,19px)',
color: '#9ba5c4', maxWidth: 560, margin: '0 auto 44px', lineHeight: 1.65,
opacity: 0, animation: 'fadeUp .6s .6s ease both',
}}>
The ranked EQAO quiz platform for Ontario students. Practice Grade 3, 6, and 9 math then go head-to-head against real opponents.
</p>
<div style={{
display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap',
marginBottom: 56, opacity: 0, animation: 'fadeUp .6s .75s ease both',
}}>
<button
onClick={() => scrollTo('signin')}
style={{
padding: '15px 38px', background: '#f5c518', color: '#07080f',
border: 'none', borderRadius: 10,
fontFamily: "'Russo One',sans-serif", fontSize: 17,
letterSpacing: '0.04em', textTransform: 'uppercase', cursor: 'pointer',
boxShadow: '0 0 32px rgba(245,197,24,.35), 0 4px 16px rgba(0,0,0,.4)',
transition: 'transform .15s, box-shadow .2s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 0 52px rgba(245,197,24,.5), 0 8px 24px rgba(0,0,0,.5)'; }}
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 0 32px rgba(245,197,24,.35), 0 4px 16px rgba(0,0,0,.4)'; }}
>
Start Playing
</button>
<a
href="#how-it-works"
style={{
padding: '15px 38px', background: 'transparent', color: '#eceef8',
border: '1.5px solid rgba(255,255,255,.22)', borderRadius: 10,
fontFamily: "'Russo One',sans-serif", fontSize: 17,
letterSpacing: '0.04em', textTransform: 'uppercase',
textDecoration: 'none', display: 'inline-flex', alignItems: 'center',
transition: 'border-color .2s, color .2s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.5)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.22)'; }}
>
How It Works
</a>
</div>
))}
</div>
<div className="login__cta">
{authError && (
<p className="login__error">Sign-in failed please try again.</p>
)}
<a href={`${API}/auth/google`} className="btn-google">
<GoogleIcon />
Sign in with Google
</a>
<p className="login__fine">Free · No credit card required</p>
</div>
{/* Grade badges */}
<div style={{
display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap',
opacity: 0, animation: 'fadeUp .6s .9s ease both',
}}>
{[{ id: 'G3', label: 'Grade 3' }, { id: 'G6', label: 'Grade 6' }, { id: 'G9', label: 'Grade 9' }].map(({ id, label }) => (
<div key={id} className="grade-badge">
<span className="grade-badge__rank">{id}</span>
<span className="grade-badge__label">{label}</span>
</div>
))}
</div>
</div>
<footer className="login__footer">
Ranked EQAO · Built for Ontario Students
{/* Scroll hint */}
<div style={{
position: 'absolute', bottom: 32, left: '50%', transform: 'translateX(-50%)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
opacity: 0, animation: 'fadeUp .6s 1.2s ease both',
}}>
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 10, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#7a85a8' }}>Scroll</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(122,133,168,.55)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ animation: 'scrollBounce 2s ease-in-out infinite' }}>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</section>
{/* ── HOW IT WORKS ─────────────────────────────────── */}
<section id="how-it-works" style={{
padding: '100px 0',
background: 'rgba(13,15,28,.78)',
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
}}>
<div className="lp-container">
<p className="lp-eyebrow">Get Started</p>
<h2 className="lp-section-title">How It Works</h2>
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
{[
{
num: '01',
icon: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
title: 'Sign In',
desc: "Create a free account with Google or email. Pick your grade — G3, G6, or G9 — and you're ready to start.",
},
{
num: '02',
icon: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
),
title: 'Practice',
desc: 'Answer 22 EQAO-style math questions across two stages. Every question must be correct to advance — no partial credit, no shortcuts.',
},
{
num: '03',
icon: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
),
title: 'Compete',
desc: 'Join the ranked queue and race against a real opponent. Your ELO rises or falls on the result. Climb from Bronze all the way to Diamond.',
},
].map((step, i) => (
<div key={i} className="lp-step-card">
<span style={{
fontFamily: "'Russo One',sans-serif", fontSize: 64, lineHeight: 1,
color: 'rgba(245,197,24,.1)', position: 'absolute', top: 16, right: 22,
pointerEvents: 'none',
}}>
{step.num}
</span>
<div style={{
width: 52, height: 52,
background: 'rgba(245,197,24,.07)', border: '1px solid rgba(245,197,24,.2)',
borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 20,
}}>
{step.icon}
</div>
<h3 style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 22, fontWeight: 700,
letterSpacing: '0.06em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 10,
}}>
{step.title}
</h3>
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 15, color: '#7a85a8', lineHeight: 1.65 }}>
{step.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── FEATURES ─────────────────────────────────────── */}
<section id="features" style={{ padding: '100px 0' }}>
<div className="lp-container">
<p className="lp-eyebrow">The Platform</p>
<h2 className="lp-section-title">Why Ranked EQAO?</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(290px,1fr))', gap: 16 }}>
{[
{
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M9 7h6M9 11h6M9 15h4"/></svg>,
title: 'Real EQAO-Style Questions',
desc: 'Randomized values on every attempt — same problem structure, different numbers. You can\'t just memorize answers.',
},
{
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>,
title: 'Live 1v1 Matchmaking',
desc: 'Race against real students in real time. First to correctly complete all questions wins the match and earns ELO.',
},
{
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>,
title: 'ELO Ranking System',
desc: 'Climb from Bronze through Silver, Gold, Platinum, and Diamond. Your rating reflects actual performance against real opponents.',
},
{
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
title: 'Best Time Leaderboard',
desc: 'Every second counts. Your fastest completion time is tracked separately on the global leaderboard.',
},
{
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
title: 'Grade-Specific Content',
desc: 'Separate queues, leaderboards, and ELO ratings for Grade 3, 6, and 9. Difficulty scales with each grade level.',
},
{
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>,
title: '100% Free',
desc: 'No ads, no paywalls, no subscriptions. Ranked EQAO is completely free for every Ontario student.',
},
].map((f, i) => (
<div key={i} className="lp-feature-card">
<div style={{
width: 44, height: 44,
background: 'rgba(245,197,24,.07)', border: '1px solid rgba(245,197,24,.18)',
borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
{f.icon}
</div>
<h3 style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 18, fontWeight: 700,
letterSpacing: '0.05em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 8,
}}>
{f.title}
</h3>
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 14, color: '#7a85a8', lineHeight: 1.65 }}>
{f.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── LEADERBOARD PREVIEW ───────────────────────────── */}
<section id="leaderboard" style={{
padding: '100px 0',
background: 'rgba(13,15,28,.78)',
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
}}>
<div className="lp-container">
<p className="lp-eyebrow">Rankings</p>
<h2 className="lp-section-title">Top Players</h2>
{/* Grade tabs */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 28 }}>
{['G3', 'G6', 'G9'].map(g => (
<button
key={g}
onClick={() => setLbGrade(g)}
style={{
padding: '7px 24px',
background: lbGrade === g ? 'rgba(245,197,24,.12)' : 'rgba(17,20,38,.8)',
border: `1px solid ${lbGrade === g ? 'rgba(245,197,24,.5)' : 'rgba(255,255,255,.1)'}`,
borderRadius: 8,
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, fontWeight: 700,
letterSpacing: '0.1em', textTransform: 'uppercase',
color: lbGrade === g ? '#f5c518' : '#7a85a8',
cursor: 'pointer', transition: 'all .15s',
}}
>
{g}
</button>
))}
</div>
<div className="lp-lb-card">
{/* Table header */}
<div style={{ display: 'flex', gap: 14, padding: '10px 20px', borderBottom: '1px solid rgba(255,255,255,.07)' }}>
{['#', 'Player', 'Best Time'].map((h, i) => (
<span key={h} style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 10, fontWeight: 700,
letterSpacing: '0.16em', textTransform: 'uppercase', color: '#7a85a8',
minWidth: i === 0 ? 36 : undefined, textAlign: i === 0 ? 'center' : undefined,
flex: i === 1 ? 1 : undefined,
}}>{h}</span>
))}
</div>
{lbLoading ? (
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="skel-pill" style={{ height: 44, animationDelay: `${i * 0.06}s` }} />
))}
</div>
) : lbEntries.length === 0 ? (
<div style={{ padding: '48px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 28, marginBottom: 12, opacity: 0.4 }}>🏆</div>
<p style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 15, color: '#7a85a8' }}>No scores yet be the first!</p>
</div>
) : (
lbEntries.map((entry, i) => {
const rc = i < 3 ? RANK_COLORS[i] : '#7a85a8';
const playerLabel = entry.username ? `@${entry.username}` : (entry.displayName ?? 'Player');
const initial = playerLabel[0]?.toUpperCase() ?? '?';
return (
<div
key={i}
className="lp-lb-row"
style={i === 0 ? { borderLeft: '2px solid #f5c518', paddingLeft: 18 } : {}}
>
<span style={{
fontFamily: "'Russo One',sans-serif", fontSize: 15, color: rc,
textShadow: i < 3 ? `0 0 12px ${rc}60` : 'none',
minWidth: 36, textAlign: 'center',
}}>
{i + 1}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
{entry.avatarUrl
? <img src={entry.avatarUrl} alt={playerLabel} referrerPolicy="no-referrer" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, border: i < 3 ? `1.5px solid ${rc}55` : '1px solid rgba(255,255,255,.1)' }} />
: <div style={{ width: 28, height: 28, borderRadius: '50%', background: '#181c32', border: '1px solid rgba(255,255,255,.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: "'Russo One',sans-serif", fontSize: 11, color: '#9ba5c4', flexShrink: 0 }}>{initial}</div>
}
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 15, fontWeight: 600, color: '#eceef8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{playerLabel}
</span>
</div>
<span style={{
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 16, fontWeight: 700,
letterSpacing: '0.05em', color: i === 0 ? '#f5c518' : '#eceef8',
}}>
{fmtTime(entry.bestTime)}
</span>
</div>
);
})
)}
</div>
<div style={{ textAlign: 'center', marginTop: 32 }}>
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 15, color: '#7a85a8', marginBottom: 16 }}>
Think you can crack the top 5?
</p>
<button
onClick={() => scrollTo('signin')}
style={{
padding: '12px 32px',
background: 'rgba(245,197,24,.08)', border: '1px solid rgba(245,197,24,.35)',
borderRadius: 8, fontFamily: "'Barlow Condensed',sans-serif",
fontSize: 14, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
color: '#f5c518', cursor: 'pointer', transition: 'background .2s, border-color .2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,.17)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,.6)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(245,197,24,.08)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,.35)'; }}
>
Sign In to Compete
</button>
</div>
</div>
</section>
{/* ── SIGN IN ───────────────────────────────────────── */}
<section id="signin" style={{ padding: '100px 24px 120px' }}>
<div style={{ textAlign: 'center', marginBottom: 44 }}>
<p className="lp-eyebrow">Join the Arena</p>
<h2 style={{
fontFamily: "'Russo One',sans-serif",
fontSize: 'clamp(30px,5vw,48px)',
textTransform: 'uppercase', letterSpacing: '0.02em', color: '#eceef8',
}}>
Ready to Play?
</h2>
</div>
<div className="lp-signin-card">
<div className="login__cta" style={{ marginTop: 0 }}>
{authError && <p className="login__error">Sign-in failed please try again.</p>}
{/* Google button */}
<a href={`${API}/auth/google`} className="btn-google" style={{ width: '100%', justifyContent: 'center', boxSizing: 'border-box' }}>
<GoogleIcon />Sign in with Google
</a>
{/* Divider */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,.12)' }} />
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, color: 'rgba(255,255,255,.35)', letterSpacing: '0.05em' }}> or </span>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,.12)' }} />
</div>
{/* Mode toggle */}
<div style={{ display: 'flex', width: '100%', background: 'rgba(255,255,255,.05)', borderRadius: 10, padding: 3, gap: 3, boxSizing: 'border-box' }}>
{['login', 'register'].map(mode => (
<button
key={mode}
type="button"
onClick={() => { resetForm(); setAuthMode(mode); }}
style={{
flex: 1, padding: '9px 0', border: 'none', borderRadius: 8,
fontSize: 14, fontWeight: 600,
fontFamily: "'Barlow Condensed',sans-serif", letterSpacing: '0.06em',
cursor: 'pointer', transition: 'all .15s',
background: authMode === mode ? 'rgba(255,255,255,.11)' : 'transparent',
color: authMode === mode ? '#fff' : 'rgba(255,255,255,.4)',
}}
>
{mode === 'login' ? 'Sign in' : 'Create account'}
</button>
))}
</div>
{/* Email form */}
<form onSubmit={handleEmailAuth} style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<input type="email" required placeholder="Email address" value={email} onChange={e => setEmail(e.target.value)} style={INPUT_STYLE} autoComplete="email" />
<input type="password" required placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} style={INPUT_STYLE} autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} />
{authMode === 'register' && (
<input
type="text"
placeholder="Username (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>
</main>
<button className="lb-trophy-btn" onClick={() => setLbOpen(true)} aria-label="Leaderboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
</button>
</div>
</>
);
}
@@ -141,22 +779,10 @@ export default function Login() {
function GoogleIcon() {
return (
<svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
);
}
}
+441 -396
View File
@@ -1,278 +1,414 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const STAGE_SIZE = 11;
const API = import.meta.env.VITE_API_URL;
const NAV_BTN = {
padding: '14px 40px', borderRadius: 999,
fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600,
cursor: 'pointer', border: '1.5px solid #b6d8f5',
background: '#e8f4fd', color: '#1a73e8',
};
function OpponentBar({ progress, total, name }) {
const pct = (progress / total) * 100;
return (
<div style={{ marginTop: 8, padding: '8px 12px', background: '#fef9f0', borderRadius: 8, border: '1px solid #fce4c8', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', whiteSpace: 'nowrap', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.02em' }}>{name}</span>
<div style={{ flex: 1, height: 8, borderRadius: 4, background: '#f5e6d3', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, #f39c12, #e67e22)', borderRadius: 4, transition: 'width 0.6s ease' }} />
</div>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', minWidth: 36, textAlign: 'right', fontFamily: 'system-ui,sans-serif' }}>{Math.min(Math.round(progress), total)}/{total}</span>
</div>
);
}
function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
const [dots, setDots] = useState('');
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', border: '4px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Searching for opponent{dots}</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>Searching all players...</p>
<div style={{ fontSize: 14, color: '#666', fontFamily: 'system-ui,sans-serif', fontWeight: 500, letterSpacing: '0.02em' }}>
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
</div>
<button onClick={onBack} style={{ marginTop: 8, padding: '10px 32px', fontSize: 15, fontWeight: 600, background: '#f5f5f5', color: '#555', border: '1px solid #ccc', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}> Back to Dashboard</button>
{showForceMatch && (
<button onClick={onForceMatch} style={{ padding: '14px 36px', fontSize: 16, fontWeight: 700, background: '#ff69b4', color: '#000', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif', boxShadow: '0 0 8px #ff69b4aa' }}> Force Match</button>
)}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function PlayerChip({ name, avatar, reverse }) {
const pic = avatar
? <img src={avatar} alt="" style={{ width: 56, height: 56, borderRadius: '50%', flexShrink: 0 }} referrerPolicy="no-referrer" />
: <div style={{ width: 56, height: 56, borderRadius: '50%', background: '#1a73e8', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, fontWeight: 700, flexShrink: 0 }}>{name?.[0]?.toUpperCase() ?? '?'}</div>;
const label = <span style={{ fontSize: 17, fontWeight: 700, color: '#222', fontFamily: 'system-ui,sans-serif' }}>{name}</span>;
return (
<div style={{ display: 'flex', flexDirection: reverse ? 'row-reverse' : 'row', alignItems: 'center', gap: 12 }}>
{pic}{label}
</div>
);
}
function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) {
const [count, setCount] = useState(5);
const gradeNum = grade?.replace('G', '') ?? '';
useEffect(() => {
const id = setInterval(() => {
setCount(c => {
if (c <= 1) { clearInterval(id); onNext(); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(id);
}, [onNext]);
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 28 }}>
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Ranked Match</p>
<h1 style={{ fontSize: 'clamp(30px,5vw,52px)', fontWeight: 800, color: '#111', margin: 0, textAlign: 'center' }}>EQAO Grade {gradeNum}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
<PlayerChip name={myName} avatar={myAvatar} reverse={false} />
<span style={{ fontSize: 18, fontWeight: 700, color: '#aaa', letterSpacing: 2 }}>vs</span>
<PlayerChip name={opponentName} avatar={opponentAvatar} reverse={true} />
</div>
<p style={{ fontSize: 14, color: '#aaa', margin: 0 }}>First to finish all {STAGE_SIZE} questions wins.</p>
<div style={{ fontSize: 'clamp(88px,18vw,144px)', fontWeight: 900, color: '#e53935', lineHeight: 1, fontVariantNumeric: 'tabular-nums', minWidth: 120, textAlign: 'center' }}>{count}</div>
</div>
);
}
function ProgressStrip({ total, current, answers, onJump }) {
const items = [];
for (let qi = 0; qi < total; qi++) {
const active = qi === current, answered = answers[qi] != null, filled = answered;
items.push(
<button key={`b-${qi}`} onClick={() => onJump && onJump(qi)} style={{ width: 36, height: 36, borderRadius: '50%', border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`, background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff', color: filled ? '#fff' : active ? '#1a73e8' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</button>
);
if (qi < total - 1) items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }} />);
}
return <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>{items}</div>;
}
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName }) {
useEffect(() => {
const handler = (e) => {
const i = +e.key - 1;
if (i >= 0 && i < q.choices.length) { onSelect(i); return; }
if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext();
if (e.key === 'ArrowLeft' && qIdx > 0) onBack();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
return (
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '20px 32px 16px' }}>
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 14px 0', lineHeight: 1 }}>Question {qIdx + 1} of {STAGE_SIZE}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#1a73e8', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>You</span>
<div style={{ flex: 1 }}><ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} /></div>
</div>
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
</div>
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>{q.question}</p>
<div style={q.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } : { display: 'flex', flexDirection: 'column', gap: 12 }}>
{q.choices.map((choice, i) => {
const sel = selected === i;
return (
<button key={i} onClick={() => onSelect(i)} style={{ minHeight: 64, padding: '18px 24px', background: sel ? '#e8f0fe' : '#f7f8fa', border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, borderRadius: 12, fontSize: 20, color: sel ? '#1a73e8' : '#222', textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, transition: 'background 0.12s, border-color 0.12s' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>{i + 1}</span>{choice}
</button>
);
})}
</div>
</div>
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}> Back</button>}
{qIdx === STAGE_SIZE - 1 ? (
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>Submit </button>
) : (
<button style={NAV_BTN} onClick={onNext}>Next </button>
)}
</div>
</div>
</div>
);
}
function DefeatOverlay({ opponentName, onViewResults }) {
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }}>
<div style={{ background: '#fff', borderRadius: 16, padding: '40px 48px', textAlign: 'center', maxWidth: 380, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<div style={{ fontSize: 64, marginBottom: 12 }}>💀</div>
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Defeated!</h1>
<p style={{ fontSize: 16, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>{opponentName} finished before you.</p>
<button onClick={onViewResults} style={{ padding: '12px 36px', fontSize: 16, fontWeight: 600, background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>See Results</button>
</div>
</div>
);
}
function WaitingScreen({ opponentName, opponentProgress }) {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#ff69b4', animation: 'spin 0.6s linear infinite' }} />
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Waiting for {opponentName}...</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>You finished! Now waiting for your opponent to complete.</p>
<div style={{ width: 300, marginTop: 8 }}>
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function CompleteScreen({ won, opponentName, duration, eloChange, onDashboard }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
<div style={{ fontSize: 72 }}>{won ? '🏆' : '💀'}</div>
<h1 style={{ fontSize: 36, fontWeight: 800, color: won ? '#1b8a2d' : '#c62828', margin: 0 }}>{won ? 'Victory!' : 'Defeated!'}</h1>
<p style={{ fontSize: 17, color: '#555', maxWidth: 400, margin: 0 }}>{won ? `You beat ${opponentName}! Well played.` : `${opponentName} beat you. Better luck next time.`}</p>
<div style={{ padding: '14px 40px', background: won ? '#f0faf0' : '#fef0f0', border: `2px solid ${won ? '#a5d6a7' : '#ef9a9a'}`, borderRadius: 12, fontSize: 24, fontWeight: 700, color: won ? '#1b8a2d' : '#c62828' }}>
{Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')}
</div>
{eloChange !== 0 && <div style={{ padding: '10px 24px', borderRadius: 8, fontSize: 18, fontWeight: 700, fontFamily: 'system-ui,sans-serif', color: eloChange > 0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}</div>}
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Back to Dashboard</button>
</div>
</div>
);
}
function authHeaders() {
const token = sessionStorage.getItem('ranked_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
function getNameFromToken() {
try {
const token = sessionStorage.getItem('ranked_token');
if (!token) return 'You';
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.name ?? 'You';
} catch {
return 'You';
}
}
function getAvatarFromToken() {
try {
const token = sessionStorage.getItem('ranked_token');
if (!token) return null;
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.picture ?? null;
} catch {
return null;
}
}
function MatchFoundScreen({ myName, myAvatar, opponentName, opponentAvatar, grade, onDone }) {
const [count, setCount] = useState(5);
const [phase, setPhase] = useState('intro'); // intro → reveal → countdown
useEffect(() => {
const t1 = setTimeout(() => setPhase('reveal'), 400);
const t2 = setTimeout(() => setPhase('countdown'), 1200);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, []);
useEffect(() => {
if (phase !== 'countdown') return;
if (count <= 0) { onDone(); return; }
const id = setTimeout(() => setCount(c => c - 1), 1000);
return () => clearTimeout(id);
}, [count, phase, onDone]);
const countColor = count <= 1 ? '#ff3b30' : count <= 2 ? '#ff9500' : count <= 3 ? '#ffcc00' : '#4fc3f7';
const countGlow = count <= 1 ? '#ff3b3088' : count <= 2 ? '#ff950088' : count <= 3 ? '#ffcc0088' : '#4fc3f788';
return (
<div style={{
minHeight: '100vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: '#07090f',
fontFamily: "'system-ui',sans-serif", overflow: 'hidden', position: 'relative',
}}>
<style>{`
@keyframes bg-scan {
0% { transform: translateY(-100%); opacity: 0.07; }
100% { transform: translateY(100vh); opacity: 0.03; }
}
@keyframes header-drop {
0% { transform: translateY(-40px) scaleX(0.6); opacity: 0; letter-spacing: 0.3em; }
60% { transform: translateY(4px) scaleX(1.02); opacity: 1; letter-spacing: 0.18em; }
100% { transform: translateY(0) scaleX(1); opacity: 1; letter-spacing: 0.18em; }
}
@keyframes player-slide-left {
0% { transform: translateX(-120px); opacity: 0; }
70% { transform: translateX(6px); opacity: 1; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes player-slide-right {
0% { transform: translateX(120px); opacity: 0; }
70% { transform: translateX(-6px); opacity: 1; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes vs-slam {
0% { transform: scale(3) rotate(-8deg); opacity: 0; }
55% { transform: scale(0.92) rotate(1deg); opacity: 1; }
75% { transform: scale(1.06) rotate(-0.5deg); }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
@keyframes divider-grow {
0% { width: 0; opacity: 0; }
100% { width: 100%; opacity: 1; }
}
@keyframes count-slam {
0% { transform: scale(2.2); opacity: 0; }
50% { transform: scale(0.88); opacity: 1; }
75% { transform: scale(1.08); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes ring-pulse {
0% { transform: scale(1); opacity: 0.7; }
100% { transform: scale(2.2); opacity: 0; }
}
@keyframes avatar-glow-blue {
0%, 100% { box-shadow: 0 0 18px 4px #1a73e8aa, 0 0 0 3px #1a73e8; }
50% { box-shadow: 0 0 32px 10px #1a73e8cc, 0 0 0 3px #1a73e8; }
}
@keyframes avatar-glow-red {
0%, 100% { box-shadow: 0 0 18px 4px #ff3b30aa, 0 0 0 3px #ff3b30; }
50% { box-shadow: 0 0 32px 10px #ff3b30cc, 0 0 0 3px #ff3b30; }
}
@keyframes scan-line {
0% { top: 0%; }
100% { top: 100%; }
}
.player-left { animation: player-slide-left 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
.player-right { animation: player-slide-right 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
.vs-text { animation: vs-slam 0.5s cubic-bezier(.22,1,.36,1) 1.05s both; }
.header-text { animation: header-drop 0.6s cubic-bezier(.22,1,.36,1) 0.1s both; }
.count-slam { animation: count-slam 0.3s cubic-bezier(.22,1,.36,1) both; }
.ring { animation: ring-pulse 0.7s ease-out both; }
`}</style>
{/* Scanline overlay */}
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 0,
}}>
<div style={{
position: 'absolute', left: 0, right: 0, height: 2,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent)',
animation: 'scan-line 3s linear infinite',
}} />
{/* subtle grid */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: 'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}} />
</div>
{/* Header */}
<div className="header-text" style={{
fontSize: 13, fontWeight: 800, letterSpacing: '0.18em',
textTransform: 'uppercase', color: '#4fc3f7',
marginBottom: 48, zIndex: 1,
textShadow: '0 0 20px #4fc3f7aa',
}}>
Match Found
</div>
{/* Players row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 48, zIndex: 1 }}>
{/* You */}
<div className="player-left" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{
width: 100, height: 100, borderRadius: '50%',
background: myAvatar ? 'transparent' : '#0d2a4a',
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'avatar-glow-blue 2s ease-in-out infinite',
}}>
{myAvatar
? <img src={myAvatar} alt={myName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 38, color: '#4fc3f7', fontWeight: 800 }}>{(myName?.[0] ?? '?').toUpperCase()}</span>
}
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#e8f4fd', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{myName}</span>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#1a73e8' }}>You</span>
</div>
{/* VS + countdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20 }}>
<span className="vs-text" style={{
fontSize: 40, fontWeight: 900, color: '#fff',
letterSpacing: '0.05em',
textShadow: '0 0 24px rgba(255,255,255,0.25)',
}}>VS</span>
{phase === 'countdown' && (
<div style={{ position: 'relative', width: 80, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* pulse rings */}
<div key={`r1-${count}`} className="ring" style={{
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
border: `2px solid ${countColor}`,
}} />
<div key={`r2-${count}`} className="ring" style={{
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
border: `2px solid ${countColor}`,
animationDelay: '0.15s',
}} />
<div key={count} className="count-slam" style={{
width: 80, height: 80, borderRadius: '50%',
background: `radial-gradient(circle at 38% 38%, ${countColor}33, #0a0a1a)`,
border: `2px solid ${countColor}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 38, fontWeight: 900, color: countColor,
boxShadow: `0 0 28px ${countGlow}, inset 0 0 12px ${countColor}22`,
fontVariantNumeric: 'tabular-nums',
}}>
{count}
</div>
</div>
)}
</div>
{/* Opponent */}
<div className="player-right" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{
width: 100, height: 100, borderRadius: '50%',
background: opponentAvatar ? 'transparent' : '#2a0d0d',
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'avatar-glow-red 2s ease-in-out infinite',
}}>
{opponentAvatar
? <img src={opponentAvatar} alt={opponentName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 38, color: '#ff6b6b', fontWeight: 800 }}>{(opponentName?.[0] ?? '?').toUpperCase()}</span>
}
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#ffe8e8', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{opponentName}</span>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#ff3b30' }}>Opponent</span>
</div>
</div>
{/* Divider line */}
<div style={{
marginTop: 52, height: 1, zIndex: 1,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent)',
animation: 'divider-grow 0.6s ease-out 1.4s both',
width: 340,
}} />
{/* Grade tag */}
<div style={{
marginTop: 20, fontSize: 11, fontWeight: 700, letterSpacing: '0.15em',
textTransform: 'uppercase', color: 'rgba(255,255,255,0.2)', zIndex: 1,
animation: 'header-drop 0.5s ease-out 1.5s both',
}}>
Ranked · Grade {grade?.replace('G', '') ?? '?'}
</div>
</div>
);
}
const QUEUE_DIAMONDS = [
{ left: '8%', top: '15%', size: 10, dur: '12s', delay: '0s' },
{ left: '22%', top: '68%', size: 7, dur: '15s', delay: '2s' },
{ left: '52%', top: '20%', size: 13, dur: '11s', delay: '1s' },
{ left: '68%', top: '72%', size: 9, dur: '14s', delay: '3s' },
{ left: '84%', top: '35%', size: 11, dur: '13s', delay: '0.5s' },
{ left: '40%', top: '82%', size: 8, dur: '10s', delay: '4s' },
];
function FindingScreen({ elapsed, grade, onBack }) {
const [dots, setDots] = useState('');
const [playersOnline, setPlayersOnline] = useState(null);
useEffect(() => {
const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400);
return () => clearInterval(t);
}, []);
useEffect(() => {
const fetchCount = () => {
fetch(`${API}/online-count`)
.then(r => r.json())
.then(d => setPlayersOnline(d.online ?? 0))
.catch(() => {});
};
fetchCount();
const t = setInterval(fetchCount, 30000);
return () => clearInterval(t);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const gradeNum = grade?.replace('G', '') ?? '?';
return (
<div style={{ minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontFamily: 'system-ui,sans-serif', overflow: 'hidden' }}>
<style>{`
@keyframes spinCW { to { transform: rotate(360deg); } }
@keyframes spinCCW { to { transform: rotate(-360deg); } }
`}</style>
{/* Arena background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{QUEUE_DIAMONDS.map((d, i) => (
<div
key={i}
className="shape shape--diamond"
style={{
left: d.left, top: d.top,
width: d.size, height: d.size,
animationDuration: d.dur, animationDelay: d.delay,
opacity: 0.35,
}}
/>
))}
</div>
</div>
{/* Content */}
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24 }}>
{/* Two counter-rotating rings */}
<div style={{ position: 'relative', width: 80, height: 80 }}>
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
border: '3.5px solid rgba(245,197,24,0.12)',
borderTopColor: '#f5c518', borderRightColor: 'rgba(245,197,24,0.45)',
animation: 'spinCW 1.2s linear infinite',
}} />
<div style={{
position: 'absolute', inset: 13, borderRadius: '50%',
border: '3px solid rgba(79,195,247,0.12)',
borderTopColor: '#4fc3f7', borderLeftColor: 'rgba(79,195,247,0.45)',
animation: 'spinCCW 0.85s linear infinite',
}} />
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%,-50%)',
width: 7, height: 7, borderRadius: '50%',
background: '#f5c518',
boxShadow: '0 0 10px rgba(245,197,24,0.9)',
}} />
</div>
{/* Grade label */}
<p style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.22em',
textTransform: 'uppercase', color: 'rgba(245,197,24,0.45)',
margin: 0,
}}>
Grade {gradeNum} · Ranked
</p>
{/* Heading */}
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#eceef8', margin: 0, letterSpacing: '0.01em' }}>
Searching for opponent{dots}
</h2>
{/* Players online */}
<p style={{
fontSize: 13, color: 'rgba(245,197,24,0.55)',
margin: 0, letterSpacing: '0.04em',
}}>
{playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'}
</p>
{/* Elapsed timer — large gold */}
<div style={{
fontSize: 48, fontWeight: 700,
color: '#f5c518', fontFamily: 'monospace',
letterSpacing: '0.06em', lineHeight: 1,
textShadow: '0 0 28px rgba(245,197,24,0.35)',
}}>
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</div>
{/* Back button */}
<button
onClick={onBack}
style={{
marginTop: 8, padding: '10px 32px',
fontSize: 14, fontWeight: 600,
background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.45)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 999, cursor: 'pointer',
fontFamily: 'system-ui,sans-serif',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.25)';
e.currentTarget.style.color = 'rgba(255,255,255,0.75)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
e.currentTarget.style.color = 'rgba(255,255,255,0.45)';
}}
>
Back to Dashboard
</button>
</div>
</div>
);
}
export default function Queue() {
const navigate = useNavigate();
const { state } = useLocation();
const grade = state?.grade ?? null;
const [myName, setMyName] = useState('');
const [myAvatar, setMyAvatar] = useState(null);
const [phase, setPhase] = useState('finding');
const [questions, setQuestions] = useState([]);
const [qIdx, setQIdx] = useState(0);
const [answers, setAnswers] = useState({});
const [selected, setSelected] = useState(null);
const [opponentProgress, setOpponentProgress] = useState(0);
const [opponentName, setOpponentName] = useState('');
const [opponentAvatar, setOpponentAvatar] = useState(null);
const [playerWon, setPlayerWon] = useState(null);
const [showDefeatOverlay, setShowDefeatOverlay] = useState(false);
const [startTime] = useState(() => Date.now());
const [duration, setDuration] = useState(0);
const [eloChange, setEloChange] = useState(0);
const [myName, setMyName] = useState(() => getNameFromToken());
const [myAvatar, setMyAvatar] = useState(() => getAvatarFromToken());
const [queueElapsed, setQueueElapsed] = useState(0);
const [showForceMatch, setShowForceMatch] = useState(false);
const [matchData, setMatchData] = useState(null);
const gameIdRef = useRef(null);
const pollRef = useRef(null);
const completedRef = useRef(false);
const submittedRef = useRef(false);
const joinedRef = useRef(false);
const completedRef = useRef(false);
const leaveTimerRef = useRef(null);
const leavingRef = useRef(false);
// Elapsed timer while finding
// Elapsed timer — stop once matched
useEffect(() => {
if (phase !== 'finding') return;
if (matchData) return;
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
return () => clearInterval(id);
}, [phase]);
// Show force match after 15s
useEffect(() => {
if (phase !== 'finding') return;
const id = setTimeout(() => setShowForceMatch(true), 15000);
return () => clearTimeout(id);
}, [phase]);
async function forceMatch() {
clearInterval(pollRef.current);
console.log('[queue] force match requested');
try {
const r = await fetch(`${API}/queue/debug`, { headers: authHeaders() });
const debug = await r.json();
console.log('[queue] debug state:', JSON.stringify(debug));
} catch {}
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
console.log('[queue] force match result:', JSON.stringify(data));
if (!data) return;
joinedRef.current = true;
if (data.status === 'waiting') {
pollQueue();
} else if (data.status === 'matched') {
fetchMatchData(data.gameId);
}
})
.catch(() => {});
}
}, [matchData]);
// Fetch own user info
useEffect(() => {
@@ -288,43 +424,43 @@ export default function Queue() {
// Join queue on mount
useEffect(() => {
// Cancel any pending leave from a previous unmount (Strict Mode guard)
// Cancel any pending leave from a previous mount (handles StrictMode double-invoke)
if (leaveTimerRef.current) {
clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
// Reset leaving flag so callbacks work on this mount
leavingRef.current = false;
joinedRef.current = false;
completedRef.current = false;
let cancelled = false;
console.log('[queue] joining with grade', grade);
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { console.log('[queue] 401 on join'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
console.log('[queue] join response:', JSON.stringify(data));
if (!data || cancelled) return;
if (!data || leavingRef.current) return;
joinedRef.current = true;
if (data.status === 'waiting') {
pollQueue();
} else if (data.status === 'matched') {
fetchMatchData(data.gameId);
fetchMatchData();
}
})
.catch(e => console.log('[queue] join error:', e));
.catch(() => {});
return () => {
cancelled = true;
leavingRef.current = true;
clearInterval(pollRef.current);
if (joinedRef.current && !gameIdRef.current && !completedRef.current) {
if (joinedRef.current && !completedRef.current) {
leaveTimerRef.current = setTimeout(() => {
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
}, 300);
}, 0);
}
};
}, [grade, navigate]);
@@ -333,21 +469,15 @@ export default function Queue() {
pollRef.current = setInterval(() => {
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => {
if (r.status === 401) { console.log('[queue] 401 on poll'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
if (!data) return;
if (!data || leavingRef.current) return;
if (data.status === 'matched') {
console.log('[queue] poll found match!');
clearInterval(pollRef.current);
gameIdRef.current = data.gameId;
setQuestions(data.questions);
setOpponentName(data.opponent.username ?? data.opponent.displayName);
setOpponentAvatar(data.opponent.avatarUrl);
setPhase('intro');
showMatchup(data);
} else if (data.status === 'not_in_queue') {
console.log('[queue] poll got not_in_queue, rejoining');
clearInterval(pollRef.current);
fetch(`${API}/queue/join`, {
method: 'POST',
@@ -355,151 +485,66 @@ export default function Queue() {
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { console.log('[queue] 401 on rejoin'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(d => {
console.log('[queue] rejoin response:', JSON.stringify(d));
if (!d) return;
if (!d || leavingRef.current) return;
if (d.status === 'waiting') pollQueue();
else if (d.status === 'matched') fetchMatchData(d.gameId);
else if (d.status === 'matched') showMatchup(d);
})
.catch(() => {});
}
})
.catch(() => {});
}, 1500);
}
function fetchMatchData(gameId) {
gameIdRef.current = gameId;
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (data.status === 'matched') {
setQuestions(data.questions);
setOpponentName(data.opponent.username ?? data.opponent.displayName);
setOpponentAvatar(data.opponent.avatarUrl);
setPhase('intro');
}
})
.catch(() => {});
}
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
fetch(`${API}/game/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId: gameIdRef.current, currentQuestion, correctAnswers, finished, timeSpentMs }),
}).catch(() => {});
}
function pollGameStatus() {
const id = gameIdRef.current;
if (!id) return;
pollRef.current = setInterval(() => {
if (completedRef.current) { clearInterval(pollRef.current); return; }
fetch(`${API}/game/status/${id}`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (data.status === 'completed') {
clearInterval(pollRef.current);
const you = data.players.you;
const won = data.youWon === true;
completedRef.current = true;
setDuration(you.timeSpentMs || (Date.now() - startTime));
setEloChange(you.eloChange || 0);
setPlayerWon(won);
if (won) {
setPhase('complete');
} else {
setShowDefeatOverlay(true);
}
} else {
const opp = data.players.opponent;
if (opp.username) setOpponentName(opp.username);
setOpponentProgress(opp.currentQuestion + 1);
}
})
.catch(() => {});
}, 1000);
}
function handleNext() {
const newAnswers = { ...answers, [qIdx]: selected };
setAnswers(newAnswers);
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
const elapsed = Date.now() - startTime;
if (qIdx < STAGE_SIZE - 1) {
submitProgress(qIdx + 1, correctCount, false);
const next = qIdx + 1;
setQIdx(next);
setSelected(newAnswers[next] ?? null);
} else {
submittedRef.current = true;
submitProgress(STAGE_SIZE, correctCount, true, elapsed);
setPhase('waiting');
}
function fetchMatchData() {
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (!leavingRef.current && data.status === 'matched') {
showMatchup(data);
}
})
.catch(() => {});
}
function handleBack() {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
const prev = qIdx - 1;
setQIdx(prev);
setSelected(newAnswers[prev] ?? null);
function showMatchup(data) {
clearInterval(pollRef.current);
completedRef.current = true;
setMatchData(data);
}
function handleJump(targetIdx) {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
setQIdx(targetIdx);
setSelected(newAnswers[targetIdx] ?? null);
}
const goToGame = useCallback(() => {
navigate('/ranked-game', {
state: {
gameId: matchData.gameId,
questions: matchData.questions,
opponentName: matchData.opponent.username ?? matchData.opponent.displayName,
opponentAvatar: matchData.opponent.avatarUrl,
myName,
myAvatar,
grade,
},
});
}, [matchData, myName, myAvatar, grade, navigate]);
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
if (phase === 'finding')
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} showForceMatch={showForceMatch} onForceMatch={forceMatch} />;
if (phase === 'intro')
return <StageIntro grade={grade} myName={myName} myAvatar={myAvatar} opponentName={opponentName} opponentAvatar={opponentAvatar} onNext={() => { setPhase('question'); pollGameStatus(); }} />;
if (phase === 'question')
if (matchData) {
return (
<>
<QuestionScreen
question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected}
onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump}
opponentProgress={opponentProgress} opponentName={opponentName}
/>
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
</>
);
if (phase === 'waiting')
return (
<>
<WaitingScreen opponentName={opponentName} opponentProgress={opponentProgress} />
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
</>
);
if (phase === 'complete')
return (
<CompleteScreen
won={playerWon} opponentName={opponentName} duration={duration}
eloChange={eloChange} onDashboard={() => navigate('/dashboard')}
<MatchFoundScreen
myName={myName}
myAvatar={myAvatar}
opponentName={matchData.opponent.username ?? matchData.opponent.displayName}
opponentAvatar={matchData.opponent.avatarUrl}
grade={grade}
onDone={goToGame}
/>
);
}
return null;
return <FindingScreen elapsed={queueElapsed} grade={grade} onBack={() => navigate('/dashboard')} />;
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -1,14 +1,72 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../context/ThemeContext';
import UsernameModal from '../components/UsernameModal';
const API = import.meta.env.VITE_API_URL;
export default function Settings() {
const navigate = useNavigate();
const { theme, toggle } = useTheme();
const [token, setToken] = useState(null);
const [username, setUsername] = useState(null);
const [avatarUrl, setAvatarUrl] = useState(null);
const [usernameModal, setUsernameModal] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
const tok = sessionStorage.getItem('ranked_token');
if (!tok) { navigate('/'); return; }
setToken(tok);
}, [navigate]);
useEffect(() => {
if (!token) return;
fetch(`${API}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => { if (res.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } return res.json(); })
.then(data => { if (data) { setUsername(data.username ?? null); setAvatarUrl(data.avatarUrl ?? null); } })
.catch(() => {});
}, [token, navigate]);
async function handleFileSelect(e) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { alert('Image too large. Max 2MB.'); return; }
setUploading(true);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const res = await fetch(`${API}/me/avatar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ image: event.target.result }),
});
const data = await res.json();
if (res.ok) setAvatarUrl(data.avatarUrl);
else alert(data.error || 'Upload failed');
} catch {
alert('Could not upload. Check your connection.');
} finally {
setUploading(false);
}
};
reader.readAsDataURL(file);
}
return (
<>
<div className="arena-bg"><div className="arena-grain" /></div>
{usernameModal && (
<UsernameModal
token={token}
current={username}
onClose={() => setUsernameModal(false)}
onSave={(u) => setUsername(u)}
/>
)}
<nav className="dash-nav">
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
<button className="settings-back" onClick={() => navigate('/dashboard')}>Back</button>
@@ -19,6 +77,38 @@ export default function Settings() {
<h1 className="settings-title">Settings</h1>
<div className="settings-card">
<div className="settings-row">
<div className="settings-row__info">
<span className="settings-row__label">Profile Picture</span>
<span className="settings-row__desc">
{avatarUrl ? 'Click to change' : 'Upload a photo'}
</span>
</div>
<div className="settings-avatar" onClick={() => fileInputRef.current?.click()}>
{avatarUrl ? (
<img className="settings-avatar__img" src={avatarUrl} alt="Avatar" referrerPolicy="no-referrer" />
) : (
<div className="settings-avatar__placeholder">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</div>
)}
{uploading && <div className="settings-avatar__spinner" />}
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} style={{ display: 'none' }} />
</div>
</div>
<div className="settings-row">
<div className="settings-row__info">
<span className="settings-row__label">Username</span>
<span className="settings-row__desc">
{username ? `@${username}` : 'Not set \u2014 choose a unique name'}
</span>
</div>
<button className="settings-row__action" onClick={() => setUsernameModal(true)}>
{username ? 'Change' : 'Set'}
</button>
</div>
<div className="settings-row">
<div className="settings-row__info">
<span className="settings-row__label">Theme</span>
-11
View File
@@ -1,11 +0,0 @@
Prisma DB: npx prisma studio
Run: cd frontend && npm run dev
Express server: node src/index.js
KILL 3000: kill $(lsof -ti:3000)
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
push at school: git push origin main
pull at school: git pull --rebase origin main
+63
View File
@@ -10,9 +10,11 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
@@ -110,6 +112,20 @@
"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": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -421,6 +437,24 @@
"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": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -591,6 +625,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -769,6 +812,26 @@
"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": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
+3
View File
@@ -6,6 +6,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"studio": "prisma studio",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
@@ -18,9 +19,11 @@
"type": "commonjs",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
@@ -0,0 +1,21 @@
/*
Warnings:
- Made the column `username` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- Backfill: assign a unique username derived from email or googleId for rows with NULL username
UPDATE "User"
SET "username" = lower(regexp_replace(split_part("email", '@', 1), '[^a-zA-Z0-9_]', '', 'g'))
WHERE "username" IS NULL;
-- Ensure uniqueness by appending a suffix for any collisions
UPDATE "User" u
SET "username" = u."username" || '_' || substr(u."id", 1, 6)
WHERE (
SELECT COUNT(*) FROM "User" u2
WHERE u2."username" = u."username" AND u2."id" != u."id"
) > 0;
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "username" SET NOT NULL;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "MatchResult" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"grade" "Grade" NOT NULL,
"won" BOOLEAN NOT NULL,
"eloChange" INTEGER NOT NULL,
"eloAfter" INTEGER NOT NULL,
"timeSpentMs" INTEGER,
"playedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MatchResult_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "MatchResult" ADD CONSTRAINT "MatchResult_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "passwordHash" TEXT,
ALTER COLUMN "googleId" DROP NOT NULL;
+24 -9
View File
@@ -32,17 +32,19 @@ enum SessionStatus {
}
model User {
id String @id @default(cuid())
googleId String @unique
email String @unique
displayName String
username String? @unique
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
googleId String? @unique
email String @unique
displayName String
username String @unique
avatarUrl String?
passwordHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions GameSession[]
sessions GameSession[]
leaderboardEntries LeaderboardEntry[]
matchResults MatchResult[]
}
model Question {
@@ -103,3 +105,16 @@ model LeaderboardEntry {
@@unique([userId, grade])
}
model MatchResult {
id String @id @default(cuid())
userId String
grade Grade
won Boolean
eloChange Int
eloAfter Int
timeSpentMs Int?
playedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
+18 -8
View File
@@ -3,19 +3,29 @@ const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const jwt = require('jsonwebtoken');
const db = require('../db');
async function generateUniqueUsername(base) {
const candidate = base.toLowerCase();
const taken = await db.user.findFirst({ where: { username: candidate } });
if (!taken) return candidate;
function deriveBase(email) {
const local = email.split('@')[0];
const sanitized = local
.replace(/\./g, '_')
.replace(/[^a-zA-Z0-9_]/g, '')
.slice(0, 15)
.toLowerCase();
return sanitized || 'user';
}
async function generateUniqueUsername(email) {
const base = deriveBase(email);
const taken = await db.user.findFirst({ where: { username: base } });
if (!taken) return base;
let attempts = 0;
while (attempts < 20) {
const suffix = String(Math.floor(1000 + Math.random() * 9000));
const username = `${candidate}${suffix}`;
const username = `${base}_${suffix}`;
const collision = await db.user.findFirst({ where: { username } });
if (!collision) return username;
attempts++;
}
return `${candidate}${Date.now()}`;
return `${base}_${Date.now()}`;
}
passport.use(
@@ -31,7 +41,7 @@ passport.use(
let user = await db.user.findUnique({ where: { googleId: profile.id } });
if (!user) {
const username = await generateUniqueUsername(email.split('@')[0]);
const username = await generateUniqueUsername(email);
user = await db.user.create({
data: {
googleId: profile.id,
@@ -51,7 +61,7 @@ passport.use(
},
});
if (!user.username) {
const username = await generateUniqueUsername((user.email ?? '').split('@')[0]);
const username = await generateUniqueUsername(user.email ?? '');
user = await db.user.update({ where: { id: user.id }, data: { username } });
}
}
+446 -98
View File
@@ -2,7 +2,12 @@ require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const path = require('path');
const fs = require('fs');
const cors = require('cors');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const passport = require('./auth/google');
const authMiddleware = require('./middleware/auth');
const db = require('./db');
@@ -11,11 +16,53 @@ const { calculateElo, DEFAULT_ELO } = require('./services/elo');
const { generateStage } = require('./services/questions');
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 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 MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
const DC_TIMEOUT_MS = 8000; // no heartbeat for this long → disconnected
const DC_GRACE_MS = 15000; // don't check until this long after game start
// ── In-memory matchmaking ──────────────────────────────────────
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
@@ -32,6 +79,7 @@ function computeGameResult(game) {
const p1 = game.players[players[1]];
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];
}
if (p0.finished) return players[0];
@@ -43,36 +91,73 @@ async function completeGame(game, forcedWinnerId = null) {
if (game.completing || game.status !== 'active') return;
game.completing = true;
if (game.abandonTimeout) {
clearTimeout(game.abandonTimeout);
game.abandonTimeout = null;
}
const players = Object.keys(game.players);
const winnerId = forcedWinnerId ?? computeGameResult(game);
game.winner = winnerId;
game.completedAt = Date.now();
for (const userId of players) {
const p = game.players[userId];
const won = userId === winnerId;
// No winner (e.g. both disconnected) — skip ELO, just clean up
if (!winnerId) {
for (const uid of players) delete playerGames[uid];
game.status = 'completed';
setTimeout(() => { delete activeGames[game.id]; }, 30000);
return;
}
const currentEntry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId, grade: game.grade } },
});
const currentElo = currentEntry?.elo ?? DEFAULT_ELO;
const otherId = players.find(id => id !== userId);
const otherEntry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId: otherId, grade: game.grade } },
});
const opponentElo = otherEntry?.elo ?? DEFAULT_ELO;
const result = calculateElo(currentElo, opponentElo, won);
// Snapshot both ELOs before any writes, then write atomically-as-possible
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;
}
await upsertLeaderboardEntry(userId, game.grade, {
elo: result.newElo,
bestTime: p.timeSpentMs ?? undefined,
correctAnswers: p.correctAnswers,
});
const loserId = players.find(id => id !== winnerId);
const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]);
p.eloChange = result.change;
p.newElo = result.newElo;
for (const userId of players) {
const p = game.players[userId];
const isWinner = userId === winnerId;
const change = isWinner ? winnerChange : loserChange;
const newElo = eloSnapshot[userId] + change;
delete playerGames[userId];
await upsertLeaderboardEntry(userId, game.grade, {
elo: newElo,
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
correctAnswers: p.correctAnswers,
});
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];
}
// Set completed only after ELO is written so polls see final eloChange
@@ -83,74 +168,47 @@ async function completeGame(game, forcedWinnerId = null) {
}, 30000);
}
// Disconnect detection — runs every 3s
setInterval(() => {
const now = Date.now();
for (const game of Object.values(activeGames)) {
if (game.status !== 'active' || game.completing) continue;
if (now - game.startedAt < DC_GRACE_MS) continue;
const players = Object.keys(game.players);
const gone = players.filter(id => {
const p = game.players[id];
return !p.finished && now - (p.lastSeen ?? game.startedAt) > DC_TIMEOUT_MS;
});
if (gone.length === 2) {
// Both gone — cancel, no ELO change
game.completing = true;
game.status = 'completed';
game.winner = null;
game.completedAt = now;
for (const id of players) delete playerGames[id];
setTimeout(() => { delete activeGames[game.id]; }, 30000);
} else if (gone.length === 1) {
const winnerId = players.find(id => id !== gone[0]);
completeGame(game, winnerId);
}
}
}, 3000);
// Cleanup stale queue entries (5min timeout) and hard-expired games every 30s
// Cleanup stale queue entries (30s no heartbeat) and old/ghost games every 30s
setInterval(() => {
const now = Date.now();
for (const [userId, q] of Object.entries(queue)) {
if (now - q.joinedAt > 300000) delete queue[userId];
if (now - q.lastSeen > 30000) delete queue[userId];
}
for (const [id, game] of Object.entries(activeGames)) {
if (game.status === 'active' && now - game.startedAt > 300000) {
const players = Object.keys(game.players);
const p0 = game.players[players[0]];
const p1 = game.players[players[1]];
if (p0.finished || p1.finished) {
completeGame(game);
} else {
game.completing = true;
game.status = 'completed';
game.winner = null;
game.completedAt = now;
for (const userId of players) delete playerGames[userId];
delete activeGames[id];
}
}
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
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);
const app = express();
app.use(express.json());
app.use(express.json({ limit: '3mb' }));
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(passport.initialize());
app.get('/auth/google', passport.authenticate('google', {
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
app.use('/uploads', express.static(UPLOADS_DIR));
app.get('/auth/google', authLimiter, passport.authenticate('google', {
scope: ['profile', 'email'],
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) => {
if (err || !result) {
console.error('[auth/google/callback] failure:', err);
@@ -160,6 +218,51 @@ app.get('/auth/google/callback', (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) => {
try {
const user = await db.user.findUnique({ where: { id: req.user.userId } });
@@ -177,14 +280,15 @@ app.get('/auth/me', authMiddleware, async (req, res) => {
});
app.get('/leaderboard', async (req, res) => {
const { grade } = req.query;
const { grade, type = 'time' } = req.query;
if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
}
const byElo = type === 'elo';
try {
const entries = await db.leaderboardEntry.findMany({
where: { grade, bestTime: { not: null } },
orderBy: { bestTime: 'asc' },
where: byElo ? { grade, gamesPlayed: { gt: 0 } } : { grade, bestTime: { not: null } },
orderBy: byElo ? { elo: 'desc' } : { bestTime: 'asc' },
take: 10,
include: {
user: { select: { displayName: true, username: true, avatarUrl: true } },
@@ -196,6 +300,7 @@ app.get('/leaderboard', async (req, res) => {
username: e.user.username,
avatarUrl: e.user.avatarUrl,
bestTime: e.bestTime,
elo: e.elo,
})));
} catch {
res.status(500).json({ error: 'Internal server error' });
@@ -206,11 +311,22 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
try {
const entries = await Promise.all(
['G3', 'G6', 'G9'].map(async grade => {
const [eloResult, timeResult] = await Promise.all([
const [eloResult, timeResult, totalPlayers, recentMatches] = await Promise.all([
getUserRank(req.user.userId, grade, 'elo'),
getUserRank(req.user.userId, grade, 'bestTime'),
db.leaderboardEntry.count({ where: { grade, gamesPlayed: { gt: 0 } } }),
db.matchResult.findMany({
where: { userId: req.user.userId, grade },
orderBy: { playedAt: 'desc' },
take: 20,
}),
]);
const entry = eloResult?.entry ?? null;
let winStreak = 0;
for (const m of recentMatches) {
if (m.won) winStreak++;
else break;
}
return {
grade,
elo: entry?.elo ?? 800,
@@ -219,6 +335,8 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
totalCorrect: entry?.totalCorrect ?? 0,
eloRank: eloResult?.rank ?? null,
timeRank: timeResult?.rank ?? null,
totalPlayers,
winStreak,
};
})
);
@@ -228,6 +346,23 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
}
});
app.get('/me/recent-matches', authMiddleware, async (req, res) => {
const { grade } = req.query;
if (grade && !VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'invalid grade' });
}
try {
const matches = await db.matchResult.findMany({
where: { userId: req.user.userId, ...(grade ? { grade } : {}) },
orderBy: { playedAt: 'desc' },
take: 5,
});
res.json(matches);
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/game/complete', authMiddleware, async (req, res) => {
const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body;
@@ -245,7 +380,7 @@ app.post('/game/complete', authMiddleware, async (req, res) => {
});
await upsertLeaderboardEntry(userId, grade, {
bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined,
bestTime: typeof timeSpentMs === 'number' ? timeSpentMs : undefined,
correctAnswers,
});
@@ -270,7 +405,7 @@ app.put('/me/username', authMiddleware, async (req, res) => {
const lower = username.toLowerCase();
try {
const conflict = await db.user.findFirst({
where: { username: lower, NOT: { id: req.user.userId } },
where: { username: lower, id: { not: req.user.userId } },
});
if (conflict) return res.status(409).json({ error: 'taken' });
const updated = await db.user.update({
@@ -278,11 +413,41 @@ app.put('/me/username', authMiddleware, async (req, res) => {
data: { username: lower },
});
res.json({ username: updated.username });
} catch {
} catch (err) {
console.error('[me/username]', err);
if (err.code === 'P2002') return res.status(409).json({ error: 'taken' });
res.status(500).json({ error: 'Internal server error' });
}
});
const VALID_IMAGE_RE = /^data:image\/(png|jpeg|jpg|gif|webp);base64,/;
app.post('/me/avatar', authMiddleware, async (req, res) => {
const { image } = req.body;
if (!image || typeof image !== 'string' || !VALID_IMAGE_RE.test(image)) {
return res.status(400).json({ error: 'Invalid image data' });
}
const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
const buf = Buffer.from(base64Data, 'base64');
if (buf.length > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'Image too large (max 2MB)' });
}
const mime = image.match(/^data:(image\/\w+);/)[1];
const ext = mime.split('/')[1].replace('jpeg', 'jpg');
const filename = `avatar_${req.user.userId}_${Date.now()}.${ext}`;
const filepath = path.join(UPLOADS_DIR, filename);
try {
await fs.promises.writeFile(filepath, buf);
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 } });
res.json({ avatarUrl });
} catch (err) {
console.error('[me/avatar]', err);
res.status(500).json({ error: 'Failed to save image' });
}
});
app.get('/questions', authMiddleware, (req, res) => {
const { grade } = req.query;
if (!VALID_GRADES.has(grade)) {
@@ -344,6 +509,7 @@ app.post('/me/best-time', authMiddleware, async (req, res) => {
where: { userId_grade: { userId: req.user.userId, grade } },
});
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({
where: { userId_grade: { userId: req.user.userId, grade } },
create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 },
@@ -372,6 +538,8 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
}
if (queue[req.user.userId]) {
queue[req.user.userId].grade = grade;
queue[req.user.userId].lastSeen = Date.now();
return res.json({ status: 'waiting' });
}
@@ -387,12 +555,14 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
const gameGrade = opponentQueue.grade;
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 game = {
id: gameId,
grade: gameGrade,
questions,
stage1Questions: stage1,
stage2Questions: stage2,
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() },
[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() },
@@ -401,15 +571,16 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
winner: null,
startedAt: Date.now(),
completedAt: null,
chatMessages: [],
};
activeGames[gameId] = game;
playerGames[opponentId] = 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 };
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() };
res.json({ status: 'waiting' });
} catch (err) {
console.error('[queue/join] error:', err);
@@ -417,8 +588,33 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
}
});
app.delete('/queue/leave', authMiddleware, (req, res) => {
delete queue[req.user.userId];
app.delete('/queue/leave', authMiddleware, async (req, res) => {
const userId = req.user.userId;
delete queue[userId];
// If the player was matched into a game during the cancel window, forfeit it
// immediately so the opponent isn't stuck waiting for a ghost.
const gameId = playerGames[userId];
if (gameId) {
const game = activeGames[gameId];
if (game && game.status === 'active') {
const player = game.players[userId];
if (player && !player.finished) {
const opponentId = Object.keys(game.players).find(id => id !== userId);
// Leave timeSpentMs null so completeGame doesn't record a bestTime for
// someone who cancelled before the game even started.
player.correctAnswers = 0;
player.finished = true;
player.finishedAt = Date.now();
try {
await completeGame(game, opponentId);
} catch (err) {
console.error('[queue/leave] completeGame error:', err);
}
}
}
}
res.json({ success: true });
});
@@ -431,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];
if (gameId) {
@@ -441,6 +637,8 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
return res.json({ status: 'not_in_queue' });
}
game.players[req.user.userId].lastSeen = Date.now();
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
const opponent = game.players[opponentId];
@@ -450,20 +648,19 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
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({
status: 'matched',
gameId,
questions: game.questions,
questions: { stage1: game.stage1Questions, stage2: game.stage2Questions },
opponent: {
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName,
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl,
username: opponent.username ?? opponent.displayName,
avatarUrl: opponent.avatarUrl ?? null,
},
});
}
if (queue[req.user.userId]) {
queue[req.user.userId].lastSeen = Date.now();
return res.json({ status: 'waiting' });
}
@@ -472,8 +669,42 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
// ── Game management ────────────────────────────────────────────
app.post('/game/progress', authMiddleware, (req, res) => {
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
app.post('/game/forfeit', authMiddleware, async (req, res) => {
const { gameId } = req.body;
if (!gameId) return res.status(400).json({ error: 'gameId required' });
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 in this game' });
}
if (player.finished) return res.json({ success: true });
const elapsed = Date.now() - game.startedAt;
player.timeSpentMs = Math.max(MIN_GAME_MS, elapsed + 5000);
player.correctAnswers = 0;
player.finished = true;
player.finishedAt = Date.now();
game.abandonTimeout = null;
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
try {
await completeGame(game, opponentId);
} catch (err) {
console.error('[game/forfeit] completeGame error:', err);
}
res.json({ success: true, eloChange: player.eloChange ?? 0, newElo: player.newElo ?? 800 });
});
app.post('/game/progress', authMiddleware, async (req, res) => {
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage } = req.body;
const game = activeGames[gameId];
if (!game || game.status !== 'active') {
@@ -485,32 +716,106 @@ app.post('/game/progress', authMiddleware, (req, res) => {
return res.status(403).json({ error: 'Not a participant in this game' });
}
if (player.finished) return res.json({ success: true }); // idempotent
player.lastSeen = Date.now();
if (player.finished) return res.json({ success: true }); // idempotent
if (currentQuestion !== undefined) {
player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion))));
}
if (correctAnswers !== undefined) {
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 claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed;
// 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.stage2Finished = true;
player.finished = true;
player.finishedAt = Date.now();
player.completedQuiz = true;
const winner = computeGameResult(game);
if (winner) completeGame(game);
if (winner) {
try {
await completeGame(game);
} catch (err) {
console.error('[game/progress] completeGame error:', err);
}
} else {
// Opponent hasn't finished — award win to this player after 2 minutes if game still active
const finishedUserId = req.user.userId;
game.abandonTimeout = setTimeout(async () => {
if (game.status === 'active') {
try {
await completeGame(game, finishedUserId);
} catch (err) {
console.error('[game/progress] abandonTimeout completeGame error:', err);
}
}
}, 2 * 60 * 1000);
}
}
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) => {
const game = activeGames[req.params.gameId];
if (!game) {
@@ -527,12 +832,31 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
const opponent = game.players[opponentId];
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
// Detect opponent disconnect — award win after 20s of inactivity
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
if (game.status === 'active' && !player.finished && opponentInactive) {
game.disconnectedPlayer = opponentId;
try {
await completeGame(game, req.user.userId);
} catch (err) {
console.error('[game/status] completeGame error:', err);
}
}
// Use cached name — avoids a DB query on every 1-second poll
const opponentName = opponent.username ?? opponent.displayName;
const opponentDced = game.disconnectedPlayer === opponentId;
const youDced = game.disconnectedPlayer === req.user.userId;
// Show DC badge early (8s inactivity) before the game officially ends at 20s
const opponentConnected = !opponentDced && (!opponent.lastSeen || Date.now() - opponent.lastSeen < 8000);
res.json({
status: game.status,
youWon: game.winner !== null && game.winner === req.user.userId,
opponentDced,
youDced,
opponentConnected,
players: {
you: {
currentQuestion: player.currentQuestion,
@@ -541,6 +865,14 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
timeSpentMs: player.timeSpentMs,
eloChange: player.eloChange,
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: {
username: opponentName,
@@ -548,11 +880,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
correctAnswers: opponent.correctAnswers,
finished: opponent.finished,
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' }));
const PORT = process.env.PORT || 3000;
+6 -6
View File
@@ -4,13 +4,13 @@ const DIFF_STEP = 30;
const MAX_CHANGE = 30;
const MIN_CHANGE = 1;
// Custom ELO: base ±10, adjusted ±1 for every 30-point gap, clamped [1, 30].
// Underdog who wins gains more; favourite who wins gains less.
function calculateElo(yourElo, opponentElo, won) {
const diff = opponentElo - yourElo;
// Calculate ELO change for a completed match.
// Magnitude is derived once from the winner's perspective so both sides
// move by the same amount (winner gains X, loser loses X).
function calculateElo(winnerElo, loserElo) {
const diff = loserElo - winnerElo; // positive = underdog won, negative = favourite won
const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP)));
const change = won ? magnitude : -magnitude;
return { newElo: yourElo + change, change };
return { winnerChange: magnitude, loserChange: -magnitude };
}
module.exports = { calculateElo, DEFAULT_ELO };
+3 -3
View File
@@ -17,7 +17,7 @@ async function upsertLeaderboardEntry(userId, grade, { elo, bestTime, correctAns
},
update: {
...(elo !== undefined && { elo }),
...(bestTime !== undefined && existing?.bestTime == null || bestTime < (existing?.bestTime ?? Infinity)
...(bestTime !== undefined && (existing?.bestTime == null || bestTime < existing.bestTime)
? { bestTime }
: {}),
gamesPlayed: { increment: 1 },
@@ -36,11 +36,11 @@ async function getLeaderboard(grade, sortBy = 'elo', limit = 10) {
orderBy,
take: limit,
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') {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB