From 23382d0301740c16b26e7915975facbdc31bbc90 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 7 Apr 2026 16:42:17 -0400 Subject: [PATCH] v0.0.1 --- CLAUDE.md | 336 ++++++++++++++++ README.md | 238 +++++++++++ generate-icons.js | 16 + index.html | 22 ++ install.sh | 128 ++++++ package.json | 25 ++ public/apple-touch-icon.png | Bin 0 -> 142 bytes public/favicon.svg | 4 + public/icon-192.png | Bin 0 -> 142 bytes public/icon-512.png | Bin 0 -> 142 bytes src/App.jsx | 29 ++ src/components/Avatar.jsx | 29 ++ src/components/Nav.jsx | 86 ++++ src/components/PWABanner.jsx | 46 +++ src/components/SportBadge.jsx | 11 + src/data/seedData.js | 373 ++++++++++++++++++ src/hooks/useStore.jsx | 61 +++ src/index.css | 312 +++++++++++++++ src/main.jsx | 10 + src/pages/AthleteDetail.jsx | 131 +++++++ src/pages/Athletes.jsx | 81 ++++ src/pages/Filter.jsx | 248 ++++++++++++ src/pages/Home.jsx | 113 ++++++ src/pages/Leaders.jsx | 159 ++++++++ src/pages/Register.jsx | 316 +++++++++++++++ vibecode-prompt.md | 720 ++++++++++++++++++++++++++++++++++ vite.config.js | 31 ++ 27 files changed, 3525 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 generate-icons.js create mode 100644 index.html create mode 100644 install.sh create mode 100644 package.json create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.svg create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 src/App.jsx create mode 100644 src/components/Avatar.jsx create mode 100644 src/components/Nav.jsx create mode 100644 src/components/PWABanner.jsx create mode 100644 src/components/SportBadge.jsx create mode 100644 src/data/seedData.js create mode 100644 src/hooks/useStore.jsx create mode 100644 src/index.css create mode 100644 src/main.jsx create mode 100644 src/pages/AthleteDetail.jsx create mode 100644 src/pages/Athletes.jsx create mode 100644 src/pages/Filter.jsx create mode 100644 src/pages/Home.jsx create mode 100644 src/pages/Leaders.jsx create mode 100644 src/pages/Register.jsx create mode 100644 vibecode-prompt.md create mode 100644 vite.config.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5e7a20b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,336 @@ +# CLAUDE.md — PlayersEdge + +This file tells Claude how to work in this codebase. Read it fully before making any changes. + +--- + +## Current version + +- `Version:` 0.0.1 + +--- + +## What This Project Is + +PlayersEdge is a client-side Progressive Web App for tracking athlete biometric and sport-specific statistics. It supports five sports (American Football, Hockey, Baseball, Soccer, Basketball) with multi-sport athlete profiles. There is no backend, no database, and no API — all data lives in `localStorage`. The app is built with React 18 + Vite and deployed as static files behind Nginx on Ubuntu 24.04. + +--- + +## Commands + +```bash +npm run dev # Dev server at http://localhost:5173 (hot reload) +npm run build # Production build → ./dist/ +npm run preview # Serve production build at http://localhost:4173 +``` + +There are no tests. There is no linter config. There is no TypeScript. + +--- + +## Architecture + +### State Management + +All app state flows through a single React Context defined in `src/hooks/useStore.jsx`. + +- `StoreProvider` wraps the entire app in `App.jsx` +- Data is loaded from `localStorage` key `PlayersEdge_users_v1` on first render; falls back to `SEED_USERS` from `seedData.js` if the key is absent or unparseable +- Every change to `users` is auto-saved to `localStorage` via a `useEffect` +- There is no per-session state beyond what's in localStorage — refreshing the page preserves all data + +**Do not use component-level state for anything that needs to persist.** Route it through `useStore`. + +**The store API:** +```js +const { users, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore(); +``` + +- `addUser(user)` — generates `id` (Date.now()) and `joinDate` automatically +- `updateUser(id, updates)` — shallow-merges updates into the matching user +- `deleteUser(id)` — removes by id +- `getUsersBySport(sport)` — filters `users` where `u.sports.includes(sport)` +- `resetToSeed()` — replaces all users with `SEED_USERS` + +### Routing + +React Router v6. All routes are defined once in `App.jsx`. The Nginx config uses `try_files $uri $uri/ /index.html` for SPA fallback — all routes resolve client-side. + +``` +/ → Home.jsx +/leaders → Leaders.jsx (accepts ?sport= query param) +/filter → Filter.jsx +/athletes → Athletes.jsx +/athletes/:id → AthleteDetail.jsx +/register → Register.jsx (accepts ?edit={id} query param) +``` + +### Data Layer + +`src/data/seedData.js` is the single source of truth for: +- `SPORTS` — sport registry object +- `SPORT_STATS_DEFS` — stat definitions per sport (key, label, type) +- `BIOMETRIC_FIELDS` — 12 biometric field definitions +- `SEED_USERS` — 125 generated fake athletes +- `getUsersBySport(sport)` — exported helper (also re-implemented in the store) + +**`SPORT_STATS_DEFS` governs everything** — Leaders table columns, Filter dropdowns, Register form fields, and AthleteDetail stat grids all read from it. When adding a stat, add it here and nowhere else. The UI derives from the data. + +Stat definition shape: +```js +{ key: 'touchdowns', label: 'TDs', type: 'number' } +// type: 'text' | 'number' | 'decimal' +``` + +- `text` — rendered as a select (position field only) +- `number` — integer; displayed as-is +- `decimal` — float; displayed with `.toFixed(2)` in tables, `.toFixed(1)` in biometrics + +--- + +## User Object Shape + +```js +{ + id: String, // Date.now() string for new users; '101'–'225' for seed users + firstName: String, + lastName: String, + name: String, // firstName + ' ' + lastName — must be kept in sync + email: String, + phone: String, + city: String, + country: String, + bio: String, // max 300 chars + socials: { + facebook: String, // @handle or empty string + instagram: String, + bluesky: String, + snapchat: String, + }, + avatarColor: String, // hex — used for avatar bg tint when no profileImage + profileImage: String, // base64 data URL or empty string + primarySport: String, // one of the SPORTS keys + sports: String[], // array of sport keys — always contains primarySport + biometrics: { + height_cm, weight_kg, age, reach_cm, + dominant_hand, dominant_foot, + body_fat_pct, vo2_max, vertical_jump_cm, + '40_yard_dash', bench_press_reps, years_pro + }, + sportStats: { + football: { position, touchdowns, passing_yards, ... }, + hockey: { position, goals, assists, ... }, + // only present for sports in the user's sports array + }, + joinDate: String, // YYYY-MM-DD +} +``` + +**Key invariants:** +- `user.sports` always has at least one entry +- `user.primarySport` always exists in `user.sports` +- `user.sportStats` only has keys that appear in `user.sports` +- `user.name` must equal `user.firstName + ' ' + user.lastName` + +--- + +## Component Conventions + +### File Organization + +``` +src/components/ — shared, reusable, no page logic +src/pages/ — one file per route, owns that route's state +src/hooks/ — useStore only (for now) +src/data/ — seedData only +``` + +### Import Style + +All imports use explicit `.jsx` extensions. No index barrel files. Example: +```js +import Avatar from '../components/Avatar.jsx'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS, SPORT_STATS_DEFS } from '../data/seedData.js'; +``` + +### Component Patterns + +- All components are functional with hooks — no class components +- Default export only — no named component exports +- `useMemo` is used for any list that filters or sorts — don't compute in render body +- Hover effects are applied with `onMouseEnter`/`onMouseLeave` setting `e.currentTarget.style` directly (no CSS modules, no Tailwind) +- Navigation after an action (register, delete) uses `useNavigate` from react-router-dom + +### Form State Pattern + +The `Register.jsx` form uses a single `form` object in state and a `setField(dotPath, value)` helper that deep-clones and traverses the path: + +```js +function setField(path, value) { + setForm(f => { + const clone = JSON.parse(JSON.stringify(f)); + const parts = path.split('.'); + let cur = clone; + for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]]; + cur[parts[parts.length - 1]] = value; + return clone; + }); +} +// Usage: +setField('socials.instagram', '@handle'); +setField('sportStats.football.touchdowns', 42); +``` + +Do not flatten the form structure. Do not use separate `useState` calls per field. + +### Toast Notifications + +The Register page shows a `.toast` div using a local `toast` state string. The toast CSS class in `index.css` handles the animation entirely (keyframes `toastIn` at 0s, `toastOut` at 2.5s). Set the toast message, then clear it after 3s with `setTimeout`. Navigate after 1.8s on success. + +--- + +## Styling Conventions + +**All styles live in two places only:** +1. `src/index.css` — global CSS custom properties and utility classes +2. Inline `style={{}}` props on JSX elements — for component-specific layout + +There are no CSS modules, no styled-components, no Tailwind, no SCSS. Do not introduce any CSS tooling. + +### CSS Variables (defined in `:root` in `index.css`) + +``` +Backgrounds: --bg-base, --bg-card, --bg-card2, --bg-input +Accents: --accent (#00d4ff), --accent2 (#ff6b35), --accent3 (#a855f7) +Text: --text-primary, --text-secondary, --text-muted +Borders: --border, --border-bright +Semantic: --success, --warning, --danger +Fonts: --font-display ('Barlow Condensed'), --font-body ('Barlow') +Layout: --nav-h (60px), --bottom-nav-h (64px mobile / 0px desktop), --radius (12px), --radius-sm (8px) +``` + +Always use CSS variables — never hardcode colors that match a variable. + +### Utility Classes (defined in `index.css`) + +Use these classes rather than recreating their styles inline: + +| Class | Purpose | +|---|---| +| `.page` | Page content wrapper — handles top/bottom nav padding, max-width 900px, centered | +| `.card` | Primary surface — `--bg-card`, 1px border, 12px radius, 20px padding | +| `.card2` | Secondary surface — `--bg-card2`, 1px `--border-bright`, 8px radius, 16px padding | +| `.btn` | Base button — display, font, uppercase, transition | +| `.btn-primary` | Cyan fill button | +| `.btn-secondary` | Ghost button | +| `.btn-danger` | Red fill button | +| `.badge` | Small pill label | +| `.sport-{id}` | Sport-specific badge colors (football/hockey/baseball/soccer/basketball) | +| `.label` | Form field label — uppercase, 12px, `--font-display` | +| `.form-group` | Form field wrapper — 16px bottom margin | +| `.section-title` | Page heading — 28px, 800 weight, uppercase | +| `.section-sub` | Page subheading — 14px, `--text-secondary` | +| `.stat-val` | Large stat number — 24px, 800 weight, `--accent` | +| `.stat-lbl` | Stat label beneath value — 11px, uppercase, `--text-muted` | +| `.tab-bar` | Horizontal scrollable tab container | +| `.tab` / `.tab.active` | Tab item and active state | +| `.rank-badge` | Circular rank number (32px) | +| `.rank-1/2/3/n` | Gold/silver/bronze/neutral rank colors | +| `.grid-2` | 2-column grid, collapses to 1-col at 600px | +| `.grid-3` | 3-column grid, collapses to 2-col at 600px | +| `.avatar` | Circular avatar base styles | +| `.toast` | Fixed bottom notification pill | +| `.pwa-banner` | Fixed PWA install prompt | + +### Responsive Breakpoints + +- `768px` — desktop/mobile navigation switch (set in Nav.jsx ` + + ); +} diff --git a/src/components/PWABanner.jsx b/src/components/PWABanner.jsx new file mode 100644 index 0000000..b0f742b --- /dev/null +++ b/src/components/PWABanner.jsx @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; + +export default function PWABanner() { + const [prompt, setPrompt] = useState(null); + const [show, setShow] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (localStorage.getItem('pwa_dismissed')) return; + const handler = (e) => { + e.preventDefault(); + setPrompt(e); + setShow(true); + }; + window.addEventListener('beforeinstallprompt', handler); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + if (!show || dismissed) return null; + + async function install() { + if (!prompt) return; + prompt.prompt(); + const result = await prompt.userChoice; + if (result.outcome === 'accepted') setShow(false); + setDismissed(true); + } + + function dismiss() { + setShow(false); + setDismissed(true); + localStorage.setItem('pwa_dismissed', '1'); + } + + return ( +
+
+
+
Install StatSphere
+
Add to home screen for the best experience
+
+ + +
+ ); +} diff --git a/src/components/SportBadge.jsx b/src/components/SportBadge.jsx new file mode 100644 index 0000000..0102f33 --- /dev/null +++ b/src/components/SportBadge.jsx @@ -0,0 +1,11 @@ +import { SPORTS } from '../data/seedData.js'; + +export default function SportBadge({ sport }) { + const s = SPORTS[sport]; + if (!s) return null; + return ( + + {s.emoji} {s.name} + + ); +} diff --git a/src/data/seedData.js b/src/data/seedData.js new file mode 100644 index 0000000..979cdbe --- /dev/null +++ b/src/data/seedData.js @@ -0,0 +1,373 @@ +export const SPORTS = { + football: { id: 'football', name: 'American Football', emoji: '🏈', color: '#4ade80' }, + hockey: { id: 'hockey', name: 'Hockey', emoji: '🏒', color: '#60a5fa' }, + baseball: { id: 'baseball', name: 'Baseball', emoji: '⚾', color: '#f87171' }, + soccer: { id: 'soccer', name: 'Soccer', emoji: '⚽', color: '#c084fc' }, + basketball: { id: 'basketball', name: 'Basketball', emoji: '🏀', color: '#fb923c' }, +}; + +export const SPORT_STATS_DEFS = { + football: [ + { key: 'position', label: 'Position', type: 'text' }, + { key: 'touchdowns', label: 'TDs', type: 'number' }, + { key: 'passing_yards', label: 'Passing Yds', type: 'number' }, + { key: 'rushing_yards', label: 'Rushing Yds', type: 'number' }, + { key: 'receiving_yards', label: 'Receiving Yds', type: 'number' }, + { key: 'completions', label: 'Completions', type: 'number' }, + { key: 'attempts', label: 'Attempts', type: 'number' }, + { key: 'completion_pct', label: 'Comp %', type: 'decimal' }, + { key: 'interceptions', label: 'INTs', type: 'number' }, + { key: 'sacks', label: 'Sacks', type: 'decimal' }, + { key: 'tackles', label: 'Tackles', type: 'number' }, + { key: 'receptions', label: 'Receptions', type: 'number' }, + { key: 'fumbles', label: 'Fumbles', type: 'number' }, + { key: 'field_goals', label: 'FGs', type: 'number' }, + { key: 'games_played', label: 'Games', type: 'number' }, + { key: 'passer_rating', label: 'QBR', type: 'decimal' }, + ], + hockey: [ + { key: 'position', label: 'Position', type: 'text' }, + { key: 'goals', label: 'Goals', type: 'number' }, + { key: 'assists', label: 'Assists', type: 'number' }, + { key: 'points', label: 'Points', type: 'number' }, + { key: 'plus_minus', label: '+/-', type: 'number' }, + { key: 'penalty_minutes', label: 'PIM', type: 'number' }, + { key: 'shots', label: 'Shots', type: 'number' }, + { key: 'shot_pct', label: 'Shot %', type: 'decimal' }, + { key: 'games_played', label: 'GP', type: 'number' }, + { key: 'save_pct', label: 'SV%', type: 'decimal' }, + { key: 'gaa', label: 'GAA', type: 'decimal' }, + { key: 'shutouts', label: 'Shutouts', type: 'number' }, + { key: 'toi', label: 'TOI (min)', type: 'decimal' }, + { key: 'faceoff_pct', label: 'FOW%', type: 'decimal' }, + { key: 'power_play_goals', label: 'PPG', type: 'number' }, + { key: 'shorthanded_goals', label: 'SHG', type: 'number' }, + ], + baseball: [ + { key: 'position', label: 'Position', type: 'text' }, + { key: 'batting_avg', label: 'AVG', type: 'decimal' }, + { key: 'home_runs', label: 'HRs', type: 'number' }, + { key: 'rbi', label: 'RBI', type: 'number' }, + { key: 'runs', label: 'Runs', type: 'number' }, + { key: 'hits', label: 'Hits', type: 'number' }, + { key: 'stolen_bases', label: 'SB', type: 'number' }, + { key: 'obp', label: 'OBP', type: 'decimal' }, + { key: 'slg', label: 'SLG', type: 'decimal' }, + { key: 'ops', label: 'OPS', type: 'decimal' }, + { key: 'era', label: 'ERA', type: 'decimal' }, + { key: 'strikeouts_p', label: 'K (Pitching)', type: 'number' }, + { key: 'wins', label: 'Wins', type: 'number' }, + { key: 'whip', label: 'WHIP', type: 'decimal' }, + { key: 'games_played', label: 'Games', type: 'number' }, + { key: 'war', label: 'WAR', type: 'decimal' }, + ], + soccer: [ + { key: 'position', label: 'Position', type: 'text' }, + { key: 'goals', label: 'Goals', type: 'number' }, + { key: 'assists', label: 'Assists', type: 'number' }, + { key: 'games_played', label: 'Matches', type: 'number' }, + { key: 'minutes_played', label: 'Minutes', type: 'number' }, + { key: 'shots', label: 'Shots', type: 'number' }, + { key: 'shots_on_target', label: 'On Target', type: 'number' }, + { key: 'pass_accuracy', label: 'Pass Acc %', type: 'decimal' }, + { key: 'key_passes', label: 'Key Passes', type: 'number' }, + { key: 'dribbles', label: 'Dribbles', type: 'number' }, + { key: 'tackles', label: 'Tackles', type: 'number' }, + { key: 'yellow_cards', label: 'Yellow', type: 'number' }, + { key: 'red_cards', label: 'Red', type: 'number' }, + { key: 'save_pct', label: 'Save %', type: 'decimal' }, + { key: 'clean_sheets', label: 'Clean Sheets', type: 'number' }, + { key: 'xg', label: 'xG', type: 'decimal' }, + ], + basketball: [ + { key: 'position', label: 'Position', type: 'text' }, + { key: 'points_per_game', label: 'PPG', type: 'decimal' }, + { key: 'rebounds_per_game', label: 'RPG', type: 'decimal' }, + { key: 'assists_per_game', label: 'APG', type: 'decimal' }, + { key: 'steals_per_game', label: 'SPG', type: 'decimal' }, + { key: 'blocks_per_game', label: 'BPG', type: 'decimal' }, + { key: 'fg_pct', label: 'FG%', type: 'decimal' }, + { key: 'three_pt_pct', label: '3P%', type: 'decimal' }, + { key: 'ft_pct', label: 'FT%', type: 'decimal' }, + { key: 'games_played', label: 'Games', type: 'number' }, + { key: 'minutes_per_game', label: 'MPG', type: 'decimal' }, + { key: 'turnovers', label: 'TOV', type: 'decimal' }, + { key: 'plus_minus', label: '+/-', type: 'decimal' }, + { key: 'efficiency', label: 'Eff', type: 'decimal' }, + { key: 'true_shooting', label: 'TS%', type: 'decimal' }, + { key: 'usage_rate', label: 'USG%', type: 'decimal' }, + ], +}; + +export const BIOMETRIC_FIELDS = [ + { key: 'height_cm', label: 'Height (cm)', type: 'number' }, + { key: 'weight_kg', label: 'Weight (kg)', type: 'number' }, + { key: 'age', label: 'Age', type: 'number' }, + { key: 'reach_cm', label: 'Reach/Wingspan (cm)', type: 'number' }, + { key: 'dominant_hand', label: 'Dominant Hand', type: 'text' }, + { key: 'dominant_foot', label: 'Dominant Foot', type: 'text' }, + { key: 'body_fat_pct', label: 'Body Fat %', type: 'decimal' }, + { key: 'vo2_max', label: 'VO2 Max', type: 'decimal' }, + { key: 'vertical_jump_cm', label: 'Vertical Jump (cm)', type: 'number' }, + { key: '40_yard_dash', label: '40-Yard Dash (s)', type: 'decimal' }, + { key: 'bench_press_reps', label: 'Bench Press Reps (225lb)', type: 'number' }, + { key: 'years_pro', label: 'Years Pro', type: 'number' }, +]; + +const positions = { + football: ['QB', 'RB', 'WR', 'TE', 'OL', 'DE', 'DT', 'LB', 'CB', 'S', 'K'], + hockey: ['C', 'LW', 'RW', 'D', 'G'], + baseball: ['SP', 'RP', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'], + soccer: ['GK', 'CB', 'LB', 'RB', 'CDM', 'CM', 'CAM', 'LW', 'RW', 'ST'], + basketball: ['PG', 'SG', 'SF', 'PF', 'C'], +}; + +const firstNames = ['Marcus', 'Tyler', 'Jordan', 'Devon', 'Chase', 'Zach', 'Ryan', 'Cole', 'Drew', 'Cody', + 'Logan', 'Austin', 'Blake', 'Jalen', 'Myles', 'Trey', 'Dylan', 'Caleb', 'Eli', 'Noah', + 'Liam', 'Aiden', 'Cameron', 'Hunter', 'Bryce', 'Jake', 'Luke', 'Owen', 'Seth', 'Finn']; +const lastNames = ['Thompson', 'Williams', 'Johnson', 'Davis', 'Martinez', 'Anderson', 'Taylor', 'Jackson', + 'White', 'Harris', 'Martin', 'Garcia', 'Walker', 'Robinson', 'Lewis', 'Lee', 'Allen', + 'Young', 'Hernandez', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores', + 'Green', 'Adams', 'Nelson', 'Baker']; + +const r = (min, max, dec = 0) => { + const v = Math.random() * (max - min) + min; + return dec > 0 ? parseFloat(v.toFixed(dec)) : Math.round(v); +}; +const pick = arr => arr[Math.floor(Math.random() * arr.length)]; +const avatarColors = ['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899','#06b6d4','#84cc16']; + +function genBiometrics(sport) { + const heightMap = { football: [175,205], hockey:[175,198], baseball:[170,200], soccer:[165,195], basketball:[185,220] }; + const weightMap = { football: [85,140], hockey:[82,105], baseball:[82,110], soccer:[70,90], basketball:[85,120] }; + const [hMin, hMax] = heightMap[sport]; + const [wMin, wMax] = weightMap[sport]; + const h = r(hMin, hMax); + return { + height_cm: h, + weight_kg: r(wMin, wMax), + age: r(20, 38), + reach_cm: h + r(5, 20), + dominant_hand: Math.random() > 0.1 ? 'Right' : 'Left', + dominant_foot: Math.random() > 0.15 ? 'Right' : 'Left', + body_fat_pct: r(6, 18, 1), + vo2_max: r(48, 72, 1), + vertical_jump_cm: r(55, 90), + '40_yard_dash': r(4.3, 5.2, 2), + bench_press_reps: r(10, 35), + years_pro: r(1, 18), + }; +} + +function genFootballStats(pos) { + const isQB = pos === 'QB', isRB = pos === 'RB', isWR = pos === 'WR' || pos === 'TE'; + const isDef = ['DE','DT','LB','CB','S'].includes(pos); + const gp = r(10, 17); + return { + position: pos, + games_played: gp, + touchdowns: isQB ? r(18,45) : isRB ? r(6,18) : isWR ? r(4,14) : isDef ? 0 : r(0,3), + passing_yards: isQB ? r(2800,5200) : 0, + rushing_yards: isQB ? r(100,600) : isRB ? r(600,1800) : 0, + receiving_yards: isWR ? r(600,1700) : isRB ? r(150,600) : 0, + completions: isQB ? r(230,420) : 0, + attempts: isQB ? r(380,650) : 0, + completion_pct: isQB ? r(58,72,1) : 0, + interceptions: isQB ? r(4,18) : isDef ? r(0,6) : 0, + sacks: isDef ? r(2,18,1) : isQB ? r(20,60,1) : 0, + tackles: isDef ? r(40,120) : isRB ? r(5,20) : 0, + receptions: isWR ? r(40,120) : isRB ? r(20,70) : 0, + fumbles: r(0,5), + field_goals: pos === 'K' ? r(18,40) : 0, + passer_rating: isQB ? r(78,118,1) : 0, + }; +} + +function genHockeyStats(pos) { + const isG = pos === 'G'; + const gp = r(55,82); + const shots = r(80, 280); + const goals = isG ? 0 : r(5, 55); + return { + position: pos, + games_played: gp, + goals, + assists: isG ? 0 : r(8, 70), + points: isG ? 0 : goals + r(8, 70), + plus_minus: isG ? 0 : r(-20, 38), + penalty_minutes: r(4, 120), + shots, + shot_pct: isG ? 0 : r(6, 22, 1), + save_pct: isG ? r(0.900, 0.935, 3) : 0, + gaa: isG ? r(1.8, 3.5, 2) : 0, + shutouts: isG ? r(1, 12) : 0, + toi: isG ? 0 : r(14, 26, 1), + faceoff_pct: pos === 'C' ? r(42, 58, 1) : 0, + power_play_goals: isG ? 0 : r(1, 18), + shorthanded_goals: isG ? 0 : r(0, 5), + }; +} + +function genBaseballStats(pos) { + const isPitcher = ['SP','RP'].includes(pos); + const gp = isPitcher ? r(25, 35) : r(100, 162); + const hits = isPitcher ? 0 : r(80, 210); + const hr = isPitcher ? 0 : r(3, 55); + return { + position: pos, + games_played: gp, + batting_avg: isPitcher ? 0 : r(0.220, 0.350, 3), + home_runs: hr, + rbi: isPitcher ? 0 : r(25, 120), + runs: isPitcher ? 0 : r(40, 120), + hits, + stolen_bases: isPitcher ? 0 : r(0, 45), + obp: isPitcher ? 0 : r(0.290, 0.430, 3), + slg: isPitcher ? 0 : r(0.360, 0.620, 3), + ops: isPitcher ? 0 : r(0.650, 1.050, 3), + era: isPitcher ? r(2.10, 5.80, 2) : 0, + strikeouts_p: isPitcher ? r(80, 280) : 0, + wins: isPitcher ? r(4, 22) : 0, + whip: isPitcher ? r(0.92, 1.60, 2) : 0, + war: r(0.5, 8.5, 1), + }; +} + +function genSoccerStats(pos) { + const isGK = pos === 'GK'; + const isDef = ['CB','LB','RB'].includes(pos); + const gp = r(20, 38); + const min = gp * r(60, 90); + return { + position: pos, + games_played: gp, + minutes_played: min, + goals: isGK ? 0 : isDef ? r(0, 4) : r(3, 32), + assists: isGK ? 0 : isDef ? r(1, 8) : r(2, 18), + shots: isGK ? 0 : r(15, 120), + shots_on_target: isGK ? 0 : r(8, 60), + pass_accuracy: r(68, 94, 1), + key_passes: isGK ? 0 : r(10, 80), + dribbles: isGK ? 0 : r(10, 120), + tackles: isDef ? r(40, 120) : r(5, 50), + yellow_cards: r(0, 8), + red_cards: r(0, 2), + save_pct: isGK ? r(65, 82, 1) : 0, + clean_sheets: isGK ? r(4, 18) : 0, + xg: isGK ? 0 : r(1.5, 22, 1), + }; +} + +function genBasketballStats(pos) { + const isC = pos === 'C' || pos === 'PF'; + const isPG = pos === 'PG'; + const gp = r(50, 82); + return { + position: pos, + games_played: gp, + points_per_game: r(8, 34, 1), + rebounds_per_game: isC ? r(6, 15, 1) : r(2, 8, 1), + assists_per_game: isPG ? r(5, 12, 1) : r(1, 6, 1), + steals_per_game: r(0.5, 2.5, 1), + blocks_per_game: isC ? r(0.5, 3.5, 1) : r(0.1, 1.2, 1), + fg_pct: r(40, 62, 1), + three_pt_pct: isC ? r(20, 38, 1) : r(30, 45, 1), + ft_pct: r(65, 94, 1), + minutes_per_game: r(22, 38, 1), + turnovers: r(1.0, 4.5, 1), + plus_minus: r(-8, 18, 1), + efficiency: r(12, 32, 1), + true_shooting: r(50, 68, 1), + usage_rate: r(16, 35, 1), + }; +} + +function genSportStats(sport, pos) { + switch(sport) { + case 'football': return genFootballStats(pos); + case 'hockey': return genHockeyStats(pos); + case 'baseball': return genBaseballStats(pos); + case 'soccer': return genSoccerStats(pos); + case 'basketball': return genBasketballStats(pos); + default: return {}; + } +} + +const socialPlatforms = ['facebook', 'instagram', 'bluesky', 'snapchat']; +function genSocials(name) { + const handle = name.toLowerCase().replace(' ', '_') + r(10,99); + const included = socialPlatforms.filter(() => Math.random() > 0.4); + const result = {}; + included.forEach(p => { result[p] = `@${handle}`; }); + return result; +} + +const bios = [ + 'Dedicated athlete with a passion for the game and improving every day.', + 'Competitive spirit who gives 110% on and off the field.', + 'Team player first, stats second. Love the game, love my teammates.', + 'Training hard to be the best version of myself every season.', + 'Multiple-sport athlete who believes cross-training builds champions.', + 'Community advocate and sports mentor for youth programs.', + 'Playing to inspire the next generation of athletes.', + 'Fueled by competition and the pursuit of excellence.', + 'Veteran player with years of experience and a championship mindset.', + 'Rising star hungry for the next level.', +]; + +let idCounter = 100; +function genUser(firstName, lastName, primarySport) { + const pos = pick(positions[primarySport]); + const sports = [primarySport]; + // 30% chance of a second sport + if (Math.random() < 0.3) { + const others = Object.keys(SPORTS).filter(s => s !== primarySport); + sports.push(pick(others)); + } + const statsMap = {}; + sports.forEach(s => { + const p = pick(positions[s]); + statsMap[s] = genSportStats(s, p); + }); + const name = `${firstName} ${lastName}`; + return { + id: String(++idCounter), + firstName, + lastName, + name, + email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`, + phone: `+1 (${r(200,999)}) ${r(200,999)}-${r(1000,9999)}`, + city: pick(['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'Toronto', 'Vancouver', 'Dallas', 'Miami', 'Boston', 'Seattle', 'Denver', 'Atlanta', 'Minneapolis', 'Detroit']), + country: Math.random() > 0.2 ? 'USA' : pick(['Canada', 'Mexico', 'UK', 'Brazil', 'Australia']), + bio: pick(bios), + socials: genSocials(name), + avatarColor: pick(avatarColors), + primarySport, + sports, + biometrics: genBiometrics(primarySport), + sportStats: statsMap, + joinDate: new Date(Date.now() - r(30, 730) * 86400000).toISOString().split('T')[0], + }; +} + +// Generate 25 per sport, some multi-sport +const sportUsers = {}; +let nameIdx = 0; +Object.keys(SPORTS).forEach(sport => { + sportUsers[sport] = []; + for (let i = 0; i < 25; i++) { + const fn = firstNames[(nameIdx) % firstNames.length]; + const ln = lastNames[(nameIdx + 5) % lastNames.length]; + nameIdx++; + sportUsers[sport].push(genUser(fn, ln, sport)); + } +}); + +// Merge all, deduplicate by id +const allMap = {}; +Object.values(sportUsers).flat().forEach(u => { allMap[u.id] = u; }); +export const SEED_USERS = Object.values(allMap); + +export function getUsersBySport(sport) { + return SEED_USERS.filter(u => u.sports.includes(sport)); +} diff --git a/src/hooks/useStore.jsx b/src/hooks/useStore.jsx new file mode 100644 index 0000000..b3494be --- /dev/null +++ b/src/hooks/useStore.jsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import { SEED_USERS } from '../data/seedData.js'; + +const StoreContext = createContext(null); + +const STORAGE_KEY = 'statsphere_users_v1'; + +function loadUsers() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch {} + return SEED_USERS; +} + +function saveUsers(users) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(users)); } catch {} +} + +export function StoreProvider({ children }) { + const [users, setUsers] = useState(loadUsers); + const [currentUser, setCurrentUser] = useState(null); + + useEffect(() => { saveUsers(users); }, [users]); + + function addUser(user) { + const newUser = { ...user, id: String(Date.now()), joinDate: new Date().toISOString().split('T')[0] }; + setUsers(prev => [...prev, newUser]); + return newUser; + } + + function updateUser(id, updates) { + setUsers(prev => prev.map(u => u.id === id ? { ...u, ...updates } : u)); + } + + function deleteUser(id) { + setUsers(prev => prev.filter(u => u.id !== id)); + } + + function getUserById(id) { + return users.find(u => u.id === id); + } + + function getUsersBySport(sport) { + return users.filter(u => u.sports && u.sports.includes(sport)); + } + + function resetToSeed() { + setUsers(SEED_USERS); + } + + return ( + + {children} + + ); +} + +export function useStore() { + return useContext(StoreContext); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..ae0a73f --- /dev/null +++ b/src/index.css @@ -0,0 +1,312 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-base: #0a0f1e; + --bg-card: #111827; + --bg-card2: #1a2235; + --bg-input: #1e2d45; + --accent: #00d4ff; + --accent2: #ff6b35; + --accent3: #a855f7; + --text-primary: #f0f4ff; + --text-secondary: #8899bb; + --text-muted: #4a5875; + --border: #1e2d45; + --border-bright: #2a3d5a; + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --font-display: 'Barlow Condensed', sans-serif; + --font-body: 'Barlow', sans-serif; + --nav-h: 60px; + --bottom-nav-h: 64px; + --radius: 12px; + --radius-sm: 8px; +} + +html { height: 100%; font-size: 16px; } + +body { + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-body); + min-height: 100%; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +#root { min-height: 100vh; display: flex; flex-direction: column; } + +a { color: inherit; text-decoration: none; } + +button { + font-family: var(--font-body); + cursor: pointer; + border: none; + background: none; + color: inherit; +} + +input, select, textarea { + font-family: var(--font-body); + font-size: 15px; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--border-bright); + border-radius: var(--radius-sm); + padding: 10px 14px; + width: 100%; + outline: none; + transition: border-color 0.2s; +} + +input:focus, select:focus, textarea:focus { + border-color: var(--accent); +} + +select option { background: var(--bg-card); } + +textarea { resize: vertical; min-height: 80px; } + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg-base); } +::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; } + +.page { padding: calc(var(--nav-h) + 20px) 16px calc(var(--bottom-nav-h) + 20px); max-width: 900px; margin: 0 auto; width: 100%; } + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.card2 { + background: var(--bg-card2); + border: 1px solid var(--border-bright); + border-radius: var(--radius-sm); + padding: 16px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 600; + font-family: var(--font-display); + letter-spacing: 0.05em; + text-transform: uppercase; + transition: all 0.2s; + cursor: pointer; +} + +.btn-primary { + background: var(--accent); + color: #000; +} +.btn-primary:hover { background: #00b8d9; } + +.btn-secondary { + background: transparent; + border: 1px solid var(--border-bright); + color: var(--text-primary); +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent); } + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + font-family: var(--font-display); +} + +.sport-football { background: #1a3a1a; color: #4ade80; border: 1px solid #166534; } +.sport-hockey { background: #1a2e4a; color: #60a5fa; border: 1px solid #1e40af; } +.sport-baseball { background: #3a1a1a; color: #f87171; border: 1px solid #991b1b; } +.sport-soccer { background: #2a1a3a; color: #c084fc; border: 1px solid #7e22ce; } +.sport-basketball { background: #3a2a1a; color: #fb923c; border: 1px solid #9a3412; } + +.section-title { + font-family: var(--font-display); + font-size: 28px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 4px; +} + +.section-sub { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 24px; +} + +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } + +@media (max-width: 600px) { + .grid-2 { grid-template-columns: 1fr; } + .grid-3 { grid-template-columns: 1fr 1fr; } + .page { padding-left: 12px; padding-right: 12px; } +} + +.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; } + +.label { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + font-family: var(--font-display); + margin-bottom: 6px; + display: block; +} + +.form-group { margin-bottom: 16px; } + +.avatar { + border-radius: 50%; + object-fit: cover; + background: var(--bg-input); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-weight: 800; + color: var(--accent); + flex-shrink: 0; +} + +.rank-badge { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-weight: 800; + font-size: 14px; + flex-shrink: 0; +} +.rank-1 { background: #ffd700; color: #000; } +.rank-2 { background: #c0c0c0; color: #000; } +.rank-3 { background: #cd7f32; color: #000; } +.rank-n { background: var(--bg-input); color: var(--text-secondary); } + +.stat-val { + font-family: var(--font-display); + font-size: 24px; + font-weight: 800; + color: var(--accent); + line-height: 1; +} + +.stat-lbl { + font-size: 11px; + color: var(--text-muted); + letter-spacing: 0.06em; + text-transform: uppercase; + font-family: var(--font-display); +} + +table { width: 100%; border-collapse: collapse; font-size: 14px; } +th { + text-align: left; + padding: 10px 12px; + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + cursor: pointer; + user-select: none; + white-space: nowrap; +} +th:hover { color: var(--accent); } +td { + padding: 12px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +tr:hover td { background: rgba(0,212,255,0.03); } +tr:last-child td { border-bottom: none; } + +.tab-bar { + display: flex; + gap: 4px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px; + margin-bottom: 20px; + overflow-x: auto; +} +.tab { + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + font-family: var(--font-display); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; +} +.tab.active { + background: var(--accent); + color: #000; +} + +.toast { + position: fixed; + bottom: calc(var(--bottom-nav-h) + 16px); + left: 50%; + transform: translateX(-50%); + background: var(--success); + color: #000; + padding: 10px 24px; + border-radius: 24px; + font-weight: 600; + font-size: 14px; + z-index: 1000; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.5s forwards; +} + +@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } +@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(10px); } } + +.pwa-banner { + position: fixed; + bottom: calc(var(--bottom-nav-h) + 12px); + left: 12px; + right: 12px; + background: var(--bg-card2); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: 16px; + z-index: 900; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 32px rgba(0,212,255,0.15); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/src/pages/AthleteDetail.jsx b/src/pages/AthleteDetail.jsx new file mode 100644 index 0000000..367a966 --- /dev/null +++ b/src/pages/AthleteDetail.jsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js'; +import Avatar from '../components/Avatar.jsx'; +import SportBadge from '../components/SportBadge.jsx'; + +export default function AthleteDetail() { + const { id } = useParams(); + const { getUserById, deleteUser } = useStore(); + const navigate = useNavigate(); + const [activeSport, setActiveSport] = useState(null); + const user = getUserById(id); + + if (!user) return ( +
+
🏟️
+
Athlete not found
+ Back to Athletes +
+ ); + + const displaySport = activeSport || user.primarySport || user.sports?.[0]; + const stats = user.sportStats?.[displaySport] || {}; + const statDefs = SPORT_STATS_DEFS[displaySport] || []; + + function handleDelete() { + if (confirm(`Delete ${user.name}?`)) { deleteUser(id); navigate('/athletes'); } + } + + const socialIcons = { facebook: '📘', instagram: '📷', bluesky: '🦋', snapchat: '👻' }; + + return ( +
+ {/* Header card */} +
+
+ +
+

{user.name}

+
{user.city}, {user.country} · Joined {user.joinDate}
+
+ {user.sports?.map(s => )} +
+ {user.bio &&

"{user.bio}"

} +
+
+ Edit + +
+
+ + {/* Social media */} + {user.socials && Object.keys(user.socials).length > 0 && ( +
+ {Object.entries(user.socials).map(([platform, handle]) => ( +
+ {socialIcons[platform] || '🔗'} + {handle} +
+ ))} +
+ )} +
+ + {/* Contact */} +
+
Contact
+
+
Email
{user.email}
+
Phone
{user.phone}
+
+
+ + {/* Biometrics */} +
+
Biometrics
+
+ {BIOMETRIC_FIELDS.map(f => { + const val = user.biometrics?.[f.key]; + if (!val && val !== 0) return null; + return ( +
+
{f.label}
+
+ {f.type === 'decimal' ? Number(val).toFixed(1) : val} +
+
+ ); + })} +
+
+ + {/* Sport stats */} +
+
Sport Statistics
+ + {user.sports?.length > 1 && ( +
+ {user.sports.map(s => ( + + ))} +
+ )} + +
+ {statDefs.map(def => { + const val = stats[def.key]; + if (val === undefined || val === null) return null; + const display = def.type === 'decimal' ? Number(val).toFixed(2) : String(val); + if (display === '0' && def.type !== 'text') return null; + return ( +
+
{def.label}
+
{display}
+
+ ); + })} +
+
+ + ← Back to Athletes +
+ ); +} diff --git a/src/pages/Athletes.jsx b/src/pages/Athletes.jsx new file mode 100644 index 0000000..e479032 --- /dev/null +++ b/src/pages/Athletes.jsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS } from '../data/seedData.js'; +import Avatar from '../components/Avatar.jsx'; +import SportBadge from '../components/SportBadge.jsx'; + +export default function Athletes() { + const { users } = useStore(); + const [search, setSearch] = useState(''); + const [sport, setSport] = useState('all'); + + const filtered = users.filter(u => { + const matchSearch = !search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.city?.toLowerCase().includes(search.toLowerCase()); + const matchSport = sport === 'all' || u.sports?.includes(sport); + return matchSearch && matchSport; + }); + + return ( +
+
+

Athletes

+ + Add +
+

All registered athletes

+ +
+
+ setSearch(e.target.value)} placeholder="Search name or city..." /> +
+ +
+ +
+ {filtered.length} athlete{filtered.length !== 1 ? 's' : ''} +
+ +
+ {filtered.map(u => ( + +
{ e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.transform = 'translateY(-2px)'; }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.transform = ''; }} + > +
+ +
+
{u.name}
+
{u.city}, {u.country}
+
+
+
+ {u.sports?.map(s => )} +
+
+
+
Age
+
{u.biometrics?.age}
+
+
+
Height
+
{u.biometrics?.height_cm}cm
+
+
+
Years Pro
+
{u.biometrics?.years_pro}
+
+
+
+ + ))} + {filtered.length === 0 && ( +
No athletes found
+ )} +
+
+ ); +} diff --git a/src/pages/Filter.jsx b/src/pages/Filter.jsx new file mode 100644 index 0000000..6da8bd7 --- /dev/null +++ b/src/pages/Filter.jsx @@ -0,0 +1,248 @@ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js'; +import Avatar from '../components/Avatar.jsx'; +import SportBadge from '../components/SportBadge.jsx'; + +export default function Filter() { + const { users } = useStore(); + const [query, setQuery] = useState(''); + const [sport, setSport] = useState('all'); + const [position, setPosition] = useState(''); + const [statFilter, setStatFilter] = useState({ key: '', min: '', max: '', sport: 'football' }); + const [bioFilter, setBioFilter] = useState({ key: '', min: '', max: '' }); + const [rowLimit, setRowLimit] = useState(25); + const [sortKey, setSortKey] = useState('name'); + const [sortDir, setSortDir] = useState('asc'); + + const allPositions = useMemo(() => { + if (sport === 'all') return []; + return [...new Set(users.flatMap(u => u.sportStats?.[sport] ? [u.sportStats[sport].position] : []).filter(Boolean))].sort(); + }, [sport, users]); + + const statDefs = sport !== 'all' ? (SPORT_STATS_DEFS[sport] || []) : []; + + const results = useMemo(() => { + let list = users; + + if (query) { + const q = query.toLowerCase(); + list = list.filter(u => + u.name?.toLowerCase().includes(q) || + u.city?.toLowerCase().includes(q) || + u.country?.toLowerCase().includes(q) || + u.bio?.toLowerCase().includes(q) + ); + } + + if (sport !== 'all') { + list = list.filter(u => u.sports?.includes(sport)); + } + + if (position && sport !== 'all') { + list = list.filter(u => u.sportStats?.[sport]?.position === position); + } + + if (statFilter.key && statFilter.sport && (statFilter.min !== '' || statFilter.max !== '')) { + list = list.filter(u => { + const val = Number(u.sportStats?.[statFilter.sport]?.[statFilter.key]); + if (isNaN(val)) return false; + if (statFilter.min !== '' && val < Number(statFilter.min)) return false; + if (statFilter.max !== '' && val > Number(statFilter.max)) return false; + return true; + }); + } + + if (bioFilter.key && (bioFilter.min !== '' || bioFilter.max !== '')) { + list = list.filter(u => { + const val = Number(u.biometrics?.[bioFilter.key]); + if (isNaN(val)) return false; + if (bioFilter.min !== '' && val < Number(bioFilter.min)) return false; + if (bioFilter.max !== '' && val > Number(bioFilter.max)) return false; + return true; + }); + } + + list = [...list].sort((a, b) => { + let va, vb; + if (sortKey === 'name') { va = a.name || ''; vb = b.name || ''; } + else if (sortKey === 'age') { va = a.biometrics?.age || 0; vb = b.biometrics?.age || 0; } + else if (sortKey === 'joinDate') { va = a.joinDate || ''; vb = b.joinDate || ''; } + else { va = 0; vb = 0; } + if (typeof va === 'string') return sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va); + return sortDir === 'asc' ? va - vb : vb - va; + }); + + return list.slice(0, rowLimit); + }, [users, query, sport, position, statFilter, bioFilter, sortKey, sortDir, rowLimit]); + + function clearFilters() { + setQuery(''); setSport('all'); setPosition(''); + setStatFilter({ key: '', min: '', max: '', sport: 'football' }); + setBioFilter({ key: '', min: '', max: '' }); + } + + return ( +
+

Search & Filter

+

Find athletes by any stat, biometric or sport

+ + {/* Main search */} +
+
+ + setQuery(e.target.value)} placeholder="e.g. Marcus Thompson, Chicago..." /> +
+ +
+
+ + +
+ {sport !== 'all' && ( +
+ + +
+ )} +
+
+ + {/* Stat filter */} +
+
+ Sport Stat Filter +
+
+
+ + +
+
+ + +
+
+
+ + setStatFilter(f => ({ ...f, min: e.target.value }))} placeholder="0" /> +
+
+ + setStatFilter(f => ({ ...f, max: e.target.value }))} placeholder="∞" /> +
+
+
+
+ + {/* Bio filter */} +
+
+ Biometric Filter +
+
+
+ + +
+
+
+ + setBioFilter(f => ({ ...f, min: e.target.value }))} placeholder="0" /> +
+
+ + setBioFilter(f => ({ ...f, max: e.target.value }))} placeholder="∞" /> +
+
+
+
+
+ + {/* Result controls */} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {/* Results */} +
+ {results.length} result{results.length !== 1 ? 's' : ''} +
+ +
+ {results.map((u, i) => ( + +
e.currentTarget.style.background = 'rgba(0,212,255,0.03)'} + onMouseLeave={e => e.currentTarget.style.background = ''} + > + +
+
{u.name}
+
+ {u.biometrics?.age} yrs · {u.biometrics?.height_cm}cm · {u.city}, {u.country} +
+
+
+ {u.sports?.map(s => )} +
+
+ + ))} + {results.length === 0 && ( +
+ No athletes match your filters +
+ )} +
+
+ ); +} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx new file mode 100644 index 0000000..440bb0b --- /dev/null +++ b/src/pages/Home.jsx @@ -0,0 +1,113 @@ +import { Link } from 'react-router-dom'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS } from '../data/seedData.js'; +import Avatar from '../components/Avatar.jsx'; + +export default function Home() { + const { users } = useStore(); + + const sportCounts = Object.keys(SPORTS).map(s => ({ + sport: SPORTS[s], + count: users.filter(u => u.sports?.includes(s)).length, + })); + + const recent = [...users].sort((a, b) => b.joinDate?.localeCompare(a.joinDate || '') || 0).slice(0, 5); + + return ( +
+ {/* Hero */} +
+
+
+ YOUR STATS,
+ YOUR LEGACY +
+

+ Track biometrics and performance stats across multiple sports. Compare with athletes worldwide. +

+
+ Register Athlete + View Leaders +
+
+ + {/* Stats overview */} +
+
+
{users.length}
+
Athletes
+
+
+
{Object.keys(SPORTS).length}
+
Sports
+
+
+
{users.filter(u => u.sports?.length > 1).length}
+
Multi-Sport
+
+
+ + {/* Sports */} +

Sports

+

Browse stat leaders by sport

+
+ {sportCounts.map(({ sport, count }) => ( + +
{ e.currentTarget.style.borderColor = sport.color; e.currentTarget.style.transform = 'translateY(-2px)'; }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.transform = ''; }} + > +
{sport.emoji}
+
{sport.name}
+
{count} athletes
+
+ + ))} +
+ + {/* Recent athletes */} +

Recent Athletes

+

Newly registered

+
+ {recent.map((u, i) => ( + +
e.currentTarget.style.background = 'rgba(0,212,255,0.04)'} + onMouseLeave={e => e.currentTarget.style.background = ''} + > + +
+
{u.name}
+
{u.city}, {u.country}
+
+
+ {u.sports?.map(s => ( + {SPORTS[s]?.emoji} + ))} +
+
+ + ))} +
+
+ ); +} diff --git a/src/pages/Leaders.jsx b/src/pages/Leaders.jsx new file mode 100644 index 0000000..a19da28 --- /dev/null +++ b/src/pages/Leaders.jsx @@ -0,0 +1,159 @@ +import { useState, useMemo } from 'react'; +import { useSearchParams, Link } from 'react-router-dom'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS, SPORT_STATS_DEFS } from '../data/seedData.js'; +import Avatar from '../components/Avatar.jsx'; + +export default function Leaders() { + const { getUsersBySport } = useStore(); + const [params, setParams] = useSearchParams(); + const [sport, setSport] = useState(params.get('sport') || 'football'); + const [sortKey, setSortKey] = useState(''); + const [sortDir, setSortDir] = useState('desc'); + const [rowLimit, setRowLimit] = useState(25); + + const statDefs = SPORT_STATS_DEFS[sport] || []; + const numericStats = statDefs.filter(s => s.type !== 'text'); + const activeSortKey = sortKey || (numericStats[1]?.key || numericStats[0]?.key); + + const athletes = useMemo(() => { + const list = getUsersBySport(sport); + return list + .filter(u => u.sportStats?.[sport]) + .sort((a, b) => { + const va = Number(a.sportStats[sport][activeSortKey]) || 0; + const vb = Number(b.sportStats[sport][activeSortKey]) || 0; + return sortDir === 'desc' ? vb - va : va - vb; + }) + .slice(0, rowLimit); + }, [sport, activeSortKey, sortDir, rowLimit, getUsersBySport]); + + function handleSort(key) { + if (key === activeSortKey) setSortDir(d => d === 'desc' ? 'asc' : 'desc'); + else { setSortKey(key); setSortDir('desc'); } + } + + function handleSport(s) { + setSport(s); + setSortKey(''); + setSortDir('desc'); + setParams({ sport: s }); + } + + const fmtVal = (val, type) => { + if (val === 0 || val === null || val === undefined) return '—'; + if (type === 'decimal') return Number(val).toFixed(2); + return val; + }; + + const displayStats = numericStats.slice(0, 10); + + return ( +
+

Stat Leaders

+

Top performers ranked by selected stat

+ + {/* Sport tabs */} +
+ {Object.values(SPORTS).map(s => ( + + ))} +
+ + {/* Controls */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Table */} +
+
+ + + + + + + {displayStats.map(s => ( + + ))} + + + + {athletes.map((u, i) => { + const stats = u.sportStats[sport] || {}; + const rank = i + 1; + return ( + + + + + {displayStats.map(s => ( + + ))} + + ); + })} + {athletes.length === 0 && ( + + )} + +
#Athlete handleSort('position')} style={{ cursor: 'pointer' }}>Pos handleSort(s.key)} style={{ + color: activeSortKey === s.key ? 'var(--accent)' : undefined, + }}> + {s.label} {activeSortKey === s.key ? (sortDir === 'desc' ? '↓' : '↑') : ''} +
+
{rank}
+
+ + +
+
{u.name}
+
{u.city}
+
+ +
+ + {stats.position || '—'} + + + {fmtVal(stats[s.key], s.type)} +
No athletes found
+
+
+ +
+ Showing {athletes.length} athletes · Sorted by {numericStats.find(s => s.key === activeSortKey)?.label} +
+
+ ); +} diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx new file mode 100644 index 0000000..3de2364 --- /dev/null +++ b/src/pages/Register.jsx @@ -0,0 +1,316 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { useStore } from '../hooks/useStore.jsx'; +import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js'; + +const STEPS = ['Personal Info', 'Biometrics', 'Sport Stats', 'Review']; + +const positions = { + football: ['QB', 'RB', 'WR', 'TE', 'OL', 'DE', 'DT', 'LB', 'CB', 'S', 'K'], + hockey: ['C', 'LW', 'RW', 'D', 'G'], + baseball: ['SP', 'RP', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'], + soccer: ['GK', 'CB', 'LB', 'RB', 'CDM', 'CM', 'CAM', 'LW', 'RW', 'ST'], + basketball: ['PG', 'SG', 'SF', 'PF', 'C'], +}; + +const COLORS = ['#00d4ff', '#ff6b35', '#a855f7', '#22c55e', '#f59e0b', '#ec4899']; + +function emptyForm() { + return { + firstName: '', lastName: '', email: '', phone: '', + city: '', country: '', bio: '', + socials: { facebook: '', instagram: '', bluesky: '', snapchat: '' }, + avatarColor: '#00d4ff', + profileImage: '', + primarySport: 'football', + sports: ['football'], + biometrics: {}, + sportStats: {}, + }; +} + +export default function Register() { + const [params] = useSearchParams(); + const editId = params.get('edit'); + const { addUser, updateUser, getUserById } = useStore(); + const navigate = useNavigate(); + const [step, setStep] = useState(0); + const [form, setForm] = useState(emptyForm); + const [toast, setToast] = useState(''); + + useEffect(() => { + if (editId) { + const u = getUserById(editId); + if (u) setForm({ ...emptyForm(), ...u, socials: { facebook: '', instagram: '', bluesky: '', snapchat: '', ...(u.socials || {}) } }); + } + }, [editId]); + + function setField(path, value) { + setForm(f => { + const clone = JSON.parse(JSON.stringify(f)); + const parts = path.split('.'); + let cur = clone; + for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]]; + cur[parts[parts.length - 1]] = value; + return clone; + }); + } + + function toggleSport(s) { + setForm(f => { + const sports = f.sports.includes(s) ? f.sports.filter(x => x !== s) : [...f.sports, s]; + if (sports.length === 0) return f; + const primarySport = sports.includes(f.primarySport) ? f.primarySport : sports[0]; + return { ...f, sports, primarySport }; + }); + } + + function handleImageUpload(e) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => setField('profileImage', ev.target.result); + reader.readAsDataURL(file); + } + + function handleSubmit() { + if (!form.firstName || !form.lastName || !form.email) { + setToast('Please fill in required fields'); setTimeout(() => setToast(''), 3000); return; + } + if (editId) { + updateUser(editId, { ...form, name: `${form.firstName} ${form.lastName}` }); + } else { + addUser({ ...form, name: `${form.firstName} ${form.lastName}` }); + } + setToast(editId ? 'Athlete updated!' : 'Athlete registered!'); + setTimeout(() => navigate('/athletes'), 1800); + } + + const activeSports = form.sports || []; + const currentSportForStats = activeSports[0] || 'football'; + + return ( +
+
+

{editId ? 'Edit Athlete' : 'Register Athlete'}

+
+ + {/* Step indicator */} +
+ {STEPS.map((s, i) => ( + + ))} +
+ + {/* Step 1: Personal Info */} + {step === 0 && ( +
+
+
Personal Information
+ + {/* Profile image */} +
+
+ {form.profileImage + ? Profile + : {(form.firstName?.[0] || '') + (form.lastName?.[0] || '')} + } +
+
+ + +
+ {COLORS.map(c => ( +
+
+
+ +
+
+ + setField('firstName', e.target.value)} placeholder="First name" /> +
+
+ + setField('lastName', e.target.value)} placeholder="Last name" /> +
+
+
+
+ + setField('email', e.target.value)} placeholder="email@example.com" /> +
+
+ + setField('phone', e.target.value)} placeholder="+1 (555) 000-0000" /> +
+
+
+
+ + setField('city', e.target.value)} placeholder="City" /> +
+
+ + setField('country', e.target.value)} placeholder="Country" /> +
+
+
+ +