# PlayersEdge – Athlete Stats Platform
## Vibe-Coding Prompt
---
## Project Overview
Build **StatSphere**, a full-featured Progressive Web App (PWA) for tracking athlete biometric and sport-specific statistics. The app supports five sports at launch with a data model designed for easy expansion. Athletes can be registered in multiple sports simultaneously. The platform features a stat leaders board, advanced filtering, athlete profiles, and a multi-step registration form. A postgres database will be used for all user personal, biometric and stat data. Users must be able to login to use the site for their own profile and stats data management. Registration should be done via a multi-step form, which includes user login (email address as user name and password (with validation for both)). Initial registion should not include sports stats data.
There should be user roles to select from when registering: athelete, manager (manages multiple individual athletes), team_manager (manages a specific team of atheletes), agent (can get personal contact information of the athlete), administrator. There will be a cost for to use this web app based on the role (which will take the user to a payment page), the current cost will be zero ($0) dollars at checkout, but checkout will be a dummy process and will assume the amount was paid and registration will proceed. The athletes account must be created first (manager and team_manager can use a CSV to bulk create the accounts). An email will be sent to the athlete to 1. validate/complete account profile details (and email address). 2. change password (if account was created by a manager) 3. authorize manager or team_manager (if account was created by a manager). Once authorized, the manager/team_manager can update do everything on behalf of the athlete except change personal details (email, lastname, firstname, dob, address, phone and social media feeds) after the athlete has approved them.
When an athlete/manager logs in, they will be taken to the stats control panel will be able to add sports stats data for themselves. Stats data should be entered on a per match basis. Default stats should be based on the players position, with the option to add any individual stat for the given sport. (example: OG does not have a default for a receiving_touchdown stat, so they should have the option to add it as needed). There should be an option to input a URL or update a document (ie: game sheet) to validate the new stats. The manager can enter a URL for personal stats of each athlete and the website will attempt to scrape the URL weekly for automatic stat data entry. Scraped stat data will be considered validated.
When a manager logs in, the control panel will display a list a athletes they manage, they cannot change contact details once the athlete has confirmed those details. The can manage sports and stats for each sport on behalf of the athlete. There will be a CSV upload option to bulk enter stats for a given athlete. The default positional stats are required. Manual entries for custom stats are required. The manager can enter a URL for personal stats of each athlete and the website will attempt to scrape the URL weekly for automatic stat data entry. Scraped stat data will be considered validated.
When a team_manager logs in, the control panel will display a list teams they manage, clicking the team will display a list of athletes for the team. The manager cannot change contact details for an athlete once the athlete has confirmed those details. They can manage stats on behalf of the athlete for the given team/sport. There will be a CSV upload option to bulk enter stats for a given athlete. The default positional stats are required. Manual entries for custom stats are required. The manager can enter a URL for personal stats of each athlete and the website will attempt to scrape the URL weekly for automatic stat data entry. Scraped stat data will be considered validated.
Part of the profile setup will be the team the athlete plays for and the league the team is member of. There will be a form to search for the league and team (if it either do not exist), the athlete/manager/team_manager will need to add the league name and team name, with the URLs for both. The URLs will be validated by a site scrape to ensure the names match. Once validated, the league and team will be available for other athletes/managers/team_managers to use.
Viewable stats, whether they be for the individual athlete, or a comparison search, should be in table format. The default leaders table must be for the current year, with an options to view specific seasons for the given year (preseason, regular season, playoffs) and the option to view previous years. Athletes are listed by lastname, first inititial, no personal contact information (including social media links, teams or leagues) is displayed unless the logged in user is the athlete themself, a manager/team_manager of the athlete, or an agent. General users (not logged in) can view the leaderboard and and search pages, no personal contact details for any athlete is displayed (other than generic things like city/country/age), and it will only display biometric details and stats.
On an athelete specific details view, the display will be the users profile image aligned to the left, with biometric data (height, weight, age (DOB for logged users), shoots or dominate hand) in a small table beside the profile photo. Then a table beside that showing current year totals for the given sport. A table underneath displaying carreer totals for the given sport. If the postion does not have game stats, then the two tables will show match details (wins, losses, started). Below the profile/current year/career section, will show the rest of the biometric data, and per match stats, with an options for choose a different seasons (preseason, regular season, playoffs) and/or years.
All forms should be logically aligned and labeled, responsive and easy to use. For example, QBs in American football should have passing stats (including passing TDs), then rushing stats (including rushing TDs), etc.
---
## Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Framework | React 18 | Functional components + hooks throughout |
| Routing | React Router v6 | `BrowserRouter`, `Routes`, `Route`, `Link`, `useNavigate`, `useSearchParams` |
| Build tool | Vite 5 | Fast dev server + production build |
| PWA | vite-plugin-pwa + Workbox | Auto service worker, manifest, offline caching |
| Styling | Pure CSS (no framework) | CSS custom properties for theming, no Tailwind |
| Icons | lucide-react | Minimal usage; emoji used for sport icons |
| State | React Context + localStorage | `StoreProvider` wraps the whole app |
| Fonts | Google Fonts | Barlow Condensed (display) + Barlow (body) |
| Web server | Nginx | SPA fallback, static file serving, gzip |
| Target OS | Ubuntu 24.04 LXC | Auto-installer script included |
**`package.json` dependencies:**
```json
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"lucide-react": "^0.460.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5",
"workbox-window": "^7.3.0"
}
}
```
---
## Visual Design System
### Theme: Dark Sports
A high-contrast dark theme inspired by professional sports analytics dashboards.
```css
:root {
--bg-base: #0a0f1e; /* page background — near-black navy */
--bg-card: #111827; /* primary card surface */
--bg-card2: #1a2235; /* secondary card / nested surface */
--bg-input: #1e2d45; /* form input background */
--accent: #00d4ff; /* primary accent — electric cyan */
--accent2: #ff6b35; /* secondary accent — orange */
--accent3: #a855f7; /* tertiary accent — purple */
--text-primary: #f0f4ff; /* near-white body text */
--text-secondary: #8899bb;/* muted labels */
--text-muted: #4a5875; /* de-emphasised text */
--border: #1e2d45; /* card borders */
--border-bright: #2a3d5a; /* input/hover borders */
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--font-display: 'Barlow Condensed', sans-serif;
--font-body: 'Barlow', sans-serif;
--nav-h: 60px;
--bottom-nav-h: 64px; /* 0px on desktop, 64px on mobile */
--radius: 12px;
--radius-sm: 8px;
}
```
### Typography
- **Display / headings:** `Barlow Condensed` — weight 700–900, uppercase, letter-spacing 0.02–0.1em
- **Body:** `Barlow` — weight 400–600
- Section titles: 28px, weight 800, uppercase, `--font-display`
- Stat values: 20–24px, weight 800, `--font-display`, colored `--accent`
- Stat labels: 11px, uppercase, letter-spacing 0.06em, `--text-muted`, `--font-display`
- Table headers: 11px, uppercase, letter-spacing 0.1em, `--text-muted`, `--font-display`
### Sport Color Coding
Each sport has a distinct badge color scheme (background / text / border):
| Sport | Class | BG | Text |
|---|---|---|---|
| American Football | `.sport-football` | `#1a3a1a` | `#4ade80` |
| Hockey | `.sport-hockey` | `#1a2e4a` | `#60a5fa` |
| Baseball | `.sport-baseball` | `#3a1a1a` | `#f87171` |
| Soccer | `.sport-soccer` | `#2a1a3a` | `#c084fc` |
| Basketball | `.sport-basketball` | `#3a2a1a` | `#fb923c` |
Badges use class `.badge` + `.sport-{id}` and are uppercase, 11px, weight 700, `--font-display`.
### Rank Badges
Circular 32px badges for leaderboard ranks:
- Rank 1: gold `#ffd700`, black text
- Rank 2: silver `#c0c0c0`, black text
- Rank 3: bronze `#cd7f32`, black text
- Rank 4+: `--bg-input`, `--text-secondary`
### Reusable CSS Classes
- `.page` — content wrapper: `padding: (nav-h + 20px) 16px (bottom-nav-h + 20px)`, max-width 900px, centered
- `.card` — `background: --bg-card`, 1px border, radius 12px, padding 20px
- `.card2` — `background: --bg-card2`, 1px border, radius 8px, padding 16px
- `.btn` — inline-flex, gap 8px, padding 10px 20px, radius 8px, font-display, 14px, 600 weight, uppercase
- `.btn-primary` — background `--accent`, color black
- `.btn-secondary` — transparent, border `--border-bright`, hover: border + text → `--accent`
- `.btn-danger` — background `--danger`, white text
- `.badge` — inline-flex, padding 3px 10px, radius 20px, 11px, 700 weight, uppercase
- `.label` — 12px, uppercase, letter-spacing 0.08em, `--text-secondary`, `--font-display`, display block, margin-bottom 6px
- `.form-group` — margin-bottom 16px
- `.tab-bar` — flex row, `--bg-card` bg, border, radius 8px, padding 4px, overflow-x auto
- `.tab` — padding 8px 16px, radius 6px, 13px, 700 weight, uppercase; `.active` → background `--accent`, color black
- `.grid-2` — 2-column grid, gap 16px; collapses to 1-col below 600px
- `.grid-3` — 3-column grid, gap 12px; collapses to 2-col below 600px
- `.section-title` — `--font-display`, 28px, 800, uppercase
- `.section-sub` — 14px, `--text-secondary`, margin-bottom 24px
- `.stat-val` — `--font-display`, 24px, 800, `--accent`, line-height 1
- `.stat-lbl` — 11px, `--text-muted`, uppercase, letter-spacing 0.06em, `--font-display`
- `.toast` — fixed bottom (above bottom-nav), centered, success green pill, animated in/out
- `.pwa-banner` — fixed bottom (above bottom-nav), full-width, `--bg-card2`, cyan border, install prompt
### Forms
All `input`, `select`, `textarea` elements:
- Background: `--bg-input`
- Border: 1px solid `--border-bright`
- Border-radius: `--radius-sm`
- Padding: 10px 14px
- Color: `--text-primary`
- Focus: border-color → `--accent`
- Width: 100%
### Tables
- `border-collapse: collapse`, full width, 14px
- `th`: 10px 12px padding, `--font-display`, 11px, 700, uppercase, `--text-muted`, bottom border, pointer cursor, hover → `--accent`
- `td`: 12px 12px padding, bottom border
- Row hover: background `rgba(0,212,255,0.03)`
- Active sort column header: `--accent` colored
- Active sort column values: `--accent`, 16px, weight 800
---
## File Structure
```
statsphere/
├── public/
│ ├── favicon.svg
│ ├── icon-192.png
│ ├── icon-512.png
│ └── apple-touch-icon.png
├── src/
│ ├── components/
│ │ ├── Nav.jsx
│ │ ├── Avatar.jsx
│ │ ├── SportBadge.jsx
│ │ └── PWABanner.jsx
│ ├── data/
│ │ └── seedData.js
│ ├── hooks/
│ │ └── useStore.jsx
│ ├── pages/
│ │ ├── Home.jsx
│ │ ├── Leaders.jsx
│ │ ├── Filter.jsx
│ │ ├── Athletes.jsx
│ │ ├── AthleteDetail.jsx
│ │ └── Register.jsx
│ ├── App.jsx
│ ├── main.jsx
│ └── index.css
├── index.html
├── vite.config.js
├── package.json
├── install.sh
└── README.md
```
---
## Data Model
### Sports Registry (`SPORTS`)
An object keyed by sport ID. Each entry:
```js
{
id: 'football',
name: 'American Football',
emoji: '🏈',
color: '#4ade80',
}
```
Sports: `football`, `hockey`, `baseball`, `soccer`, `basketball`
### Stat Definitions (`SPORT_STATS_DEFS`)
An object keyed by sport ID. Each sport has an array of stat definition objects:
```js
{ key: 'touchdowns', label: 'TDs', type: 'number' }
// type: 'text' | 'number' | 'decimal'
// 'text' = select/dropdown (used for position)
// 'number' = integer
// 'decimal' = float, displayed with toFixed(2)
```
User can add any stat in the sport they are participating in, but in some sports, each position will have default stats. When comparing users in specific positions, the default stats will be used. Clicking on the user name will show all the extended stats entered.
**Football:** All Positions: games_played (text), date, opponent
QB: passing_touchdowns, passing_yards, rushing_yards, receiving_yards, passer_rating (decimal), pass_completions, pass_attempts, completion_pct (decimal), interceptions_thrown, sacks_taken (decimal)
QB, RB, WR, TE, FB: receiving_touchdowns, rushing_touchdowns, rushing_attempts, rushing_yards, receptions, receiving_yards, targets, yards_after_catch, fumbles, fumbles_lost
DT, DE, LB, S, CB: tackles, passes_defended, fumble_roveries, interception_recoveries
P: punts, punt_yards, punt_avg, punt_long, punt_blocked
K: field_goal_distance_success,field_goal_distance_missed, field_goal_percentage (decimal), extra_point_attempts, extra_points_made,
**Hockey:** position (text), goals, assists, points, plus_minus, penalty_minutes, shots, shot_pct (decimal), games_played, save_pct (decimal), gaa (decimal), shutouts, toi (decimal), faceoff_pct (decimal), power_play_goals, shorthanded_goals
**Baseball (16 stats):** position (text), batting_avg (decimal), home_runs, rbi, runs, hits, stolen_bases, obp (decimal), slg (decimal), ops (decimal), era (decimal), strikeouts_p, wins, whip (decimal), games_played, war (decimal)
**Soccer (16 stats):** position (text), goals, assists, games_played, minutes_played, shots, shots_on_target, pass_accuracy (decimal), key_passes, dribbles, tackles, yellow_cards, red_cards, save_pct (decimal), clean_sheets, xg (decimal)
**Basketball (16 stats):** position (text), points_per_game (decimal), rebounds_per_game (decimal), assists_per_game (decimal), steals_per_game (decimal), blocks_per_game (decimal), fg_pct (decimal), three_pt_pct (decimal), ft_pct (decimal), games_played, minutes_per_game (decimal), turnovers (decimal), plus_minus (decimal), efficiency (decimal), true_shooting (decimal), usage_rate (decimal)
### Biometric Fields (`BIOMETRIC_FIELDS`)
12 fields displayed on every athlete profile:
```
height_cm (number), weight_kg (number), age (number), reach_cm (number),
dominant_hand (text), dominant_foot (text), body_fat_pct (decimal),
vo2_max (decimal), vertical_jump_cm (number), 40_yard_dash (decimal),
bench_press_reps (number), years_pro (number)
```
### Athlete User Object
```js
{
id: String, // unique, Date.now() for new users
firstName: String,
lastName: String,
name: String, // firstName + ' ' + lastName
email: String,
phone: String,
city: String,
country: String,
bio: String, // max 300 chars
socials: {
facebook: String, // optional @handle
instagram: String,
bluesky: String,
snapchat: String,
},
avatarColor: String, // hex color for avatar fallback
profileImage: String, // base64 data URL or empty string
primarySport: String, // sport id
sports: String[], // array of sport ids (1 or more)
biometrics: {
height_cm: Number,
weight_kg: Number,
DOB: date (format:yyyy-mm-dd),
reach_cm: Number,
dominant_hand: String,
dominant_foot: String,
body_fat_pct: Number,
vo2_max: Number,
vertical_jump_cm: Number,
'40_yard_dash': Number,
bench_press_reps: Number,
years_pro: Number,
},
sportStats: {
football: { position, touchdowns, passing_yards, ... },
hockey: { position, goals, assists, ... },
// only sports the athlete is registered in
},
joinDate: String, // ISO date string YYYY-MM-DD
}
```
### Positions Per Sport
```js
football: ['QB','RB','WR','TE','OC',"OG","OT",'DE','DT','LB','CB','S','P','K']
hockey: ['C','LW','RW','RD','LD','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']
```
---
## Seed Data Generation
Generate **125 athlete records** (25 per sport). Approximately 30% of athletes play a second sport randomly selected from the remaining four. All data is fake but realistic.
**Name pool:**
- First names (30): 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
- Last names (30): 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
**Biometric ranges by sport (height cm / weight kg):**
- Football: h 175–205, w 85–140
- Hockey: h 175–198, w 82–105
- Baseball: h 170–200, w 82–110
- Soccer: h 165–195, w 70–90
- Basketball: h 185–220, w 85–120
**Common biometrics:** age 20–38, reach = height + 5–20, body_fat_pct 6–18%, vo2_max 48–72, vertical_jump 55–90cm, 40_yard_dash 4.30–5.20s, bench_press_reps 10–35, years_pro 1–18
**Stat generation is position-aware.** Key rules:
- Football: QBs get passing_yards (2800–5200), rushers get rushing_yards, WR/TE get receiving_yards; defenders get tackles/sacks/interceptions; non-QBs get 0 for passing stats
- Hockey: Goalies (G) get save_pct (0.900–0.935), gaa (1.8–3.5), shutouts (1–12); skaters get goals (5–55), assists, points; Centers get faceoff_pct (42–58%)
- Baseball: Pitchers (SP/RP) get era (2.10–5.80), wins (4–22), whip (0.92–1.60), strikeouts_p; position players get batting_avg (0.220–0.350), home_runs, obp, slg, ops
- Soccer: GK gets save_pct (65–82%), clean_sheets; defenders get fewer goals; attackers get more shots/xg
- Basketball: Centers/PFs get higher rebounds; PGs get higher assists; all get per-game averages
**Socials:** each athlete randomly gets 0–4 platforms (60% chance per platform), handle = `@firstname_lastname##`
**Cities:** New York, Los Angeles, Chicago, Houston, Phoenix, Toronto, Vancouver, Dallas, Miami, Boston, Seattle, Denver, Atlanta, Minneapolis, Detroit
**Countries:** 80% USA, 20% Canada/Mexico/UK/Brazil/Australia
**Avatar colors pool:** `['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899','#06b6d4','#84cc16']`
**Bio pool (10 rotating):**
1. "Dedicated athlete with a passion for the game and improving every day."
2. "Competitive spirit who gives 110% on and off the field."
3. "Team player first, stats second. Love the game, love my teammates."
4. "Training hard to be the best version of myself every season."
5. "Multiple-sport athlete who believes cross-training builds champions."
6. "Community advocate and sports mentor for youth programs."
7. "Playing to inspire the next generation of athletes."
8. "Fueled by competition and the pursuit of excellence."
9. "Veteran player with years of experience and a championship mindset."
10. "Rising star hungry for the next level."
Export `SEED_USERS` as the merged, deduplicated array of all generated athletes.
---
## Global State (`useStore.jsx`)
A React Context provider wrapping the entire app. Persists to `localStorage` under key `statsphere_users_v1`. Loads seed data on first run (when localStorage is empty).
**Exposed API:**
```js
{
users, // User[] — full array
addUser(user), // adds new user, generates id + joinDate, returns new user
updateUser(id, updates), // merges updates into matching user
deleteUser(id), // removes user by id
getUserById(id), // returns single user or undefined
getUsersBySport(sport), // returns users where sports array includes sport
resetToSeed(), // restores SEED_USERS to localStorage
}
```
Load order: try `JSON.parse(localStorage.getItem('statsphere_users_v1'))` → fall back to `SEED_USERS`. Save: `useEffect` on `users` changes → `localStorage.setItem(...)`.
---
## Components
### `Nav.jsx`
Two navigation bars rendered simultaneously:
**Desktop top bar** (visible ≥768px):
- Fixed, full-width, height `--nav-h` (60px)
- Background: `rgba(10,15,30,0.95)` with `backdrop-filter: blur(12px)`
- Left: Logo mark (36px square div with gradient `#00d4ff → #a855f7`, 8px radius, "S" text) + wordmark "STAT**SPHERE**" where SPHERE is `--accent` colored
- Right: horizontal nav links — Home, Leaders, Search, Athletes, Register
- Active link: `--accent` colored text + `rgba(0,212,255,0.08)` background
- Font: `--font-display`, 12px, 700, uppercase, letter-spacing 0.08em
**Mobile bottom nav** (visible <768px):
- Fixed, full-width, height `var(--bottom-nav-h)` (64px)
- Background: `rgba(10,15,30,0.97)` with `backdrop-filter: blur(12px)`
- 5 equal-width items: ⚡ Home, 🏆 Leaders, 🔍 Search, 👤 Athletes, ➕ Register
- Each item: icon (20px emoji) above label (10px, uppercase, `--font-display`)
- Active: `--accent` color; inactive: `--text-muted`
- Accounts for `env(safe-area-inset-bottom)` padding
Routes: `/` `/leaders` `/filter` `/athletes` `/register`
### `Avatar.jsx`
Props: `user` object, `size` (number, default 40)
If `user.profileImage` is set: render `
` with `border-radius: 50%`, `object-fit: cover`
Else: render div with:
- `border-radius: 50%`
- Background: `${user.avatarColor}22` (10% opacity)
- Border: `2px solid ${user.avatarColor}44` (25% opacity)
- Text: initials (`firstName[0] + lastName[0]`), `--font-display`, 800 weight, colored `user.avatarColor`
- Font size: `size * 0.35`
### `SportBadge.jsx`
Props: `sport` (string id)
Renders: `{emoji} {name}`
### `PWABanner.jsx`
Listens for the browser `beforeinstallprompt` event. If not already dismissed (check `localStorage.getItem('pwa_dismissed')`), shows a fixed banner above the bottom nav when the event fires.
Banner content:
- ⚡ icon
- Title: "Install StatSphere"
- Subtitle: "Add to home screen for the best experience"
- "Install" button: calls `prompt.prompt()` on click
- "×" dismiss button: sets `localStorage.setItem('pwa_dismissed','1')` and hides
Styling: `.pwa-banner` class — `--bg-card2` background, 1px `--accent` border, 12px radius, box-shadow `0 4px 32px rgba(0,212,255,0.15)`
---
## Pages
### `Home.jsx` — Dashboard (`/`)
**Hero section:**
- Full-width, centered
- Radial gradient overlay: `rgba(0,212,255,0.08)` at top, transparent by 60%
- Heading (responsive font clamp 36px–72px, 900 weight): "YOUR STATS," (white) + line break + "YOUR LEGACY" (cyan)
- Subtitle: 16px, `--text-secondary`, max-width 500px
- Two CTA buttons: "Register Athlete" (primary) + "View Leaders" (secondary)
**Stats overview:** 3-column `.grid-3` of `.card` tiles — Total Athletes, Number of Sports (5), Multi-Sport Athletes count
**Sports grid:**
- `repeat(auto-fill, minmax(160px, 1fr))` grid
- Each sport is a card with: emoji (36px), sport name (13px, uppercase, sport's color), athlete count (12px, muted)
- Links to `/leaders?sport={id}`
- Hover: border-color → sport.color, translateY(-2px)
**Recent Athletes:** last 5 athletes by joinDate, shown as a stacked list inside a single `.card`. Each row: Avatar (42px) + name/city + sport badges. Links to `/athletes/:id`.
---
### `Leaders.jsx` — Stat Leaders (`/leaders`)
**Controls:**
- Sport tab bar (5 tabs, horizontal scroll on mobile)
- "Sort By" select: all numeric stats for the current sport
- "Direction" select: "High → Low" / "Low → High"
- "Show" select: 25 (default) / 50 / 100
**Behavior:**
- Reads `?sport=` query param on mount to set initial sport
- When sport changes, resets sort key to the second numeric stat for that sport (first meaningful stat after position)
- Filtered list = `getUsersBySport(sport)` → only users with `sportStats[sport]` defined → sorted by selected stat → sliced to row limit
**Table columns:**
- `#` — rank badge (1/2/3 gold/silver/bronze, rest neutral)
- Athlete — Avatar (34px) + name (link to profile) + city
- Pos — position string from sportStats
- 10 numeric stat columns (first 10 numeric stats for the sport, skipping `position`)
**Active sort column:** header colored `--accent` with ↑/↓ arrow; cell values colored `--accent`, 16px, 800 weight
**Value formatting:** `0` or null/undefined → display `—`; type `decimal` → `.toFixed(2)`; type `number` → raw value
**Table is horizontally scrollable** on mobile via `overflow-x: auto` wrapper
Footer: "Showing N athletes · Sorted by {stat label}"
---
### `Filter.jsx` — Search & Filter (`/filter`)
Three filter card sections:
**Section 1 — General Search:**
- Text input: search by name, city, country, bio (case-insensitive substring)
- "Sport" select: All Sports + 5 sports
- "Position" select: appears only when a specific sport is selected; populated dynamically from unique positions found in that sport's data
**Section 2 — Sport Stat Filter:**
- "Sport" select (defaults to football)
- "Stat" select: numeric stats for the selected sport
- Min / Max number inputs (2-column inline grid)
- Filters athletes where `sportStats[filterSport][filterKey]` is within [min, max]
**Section 3 — Biometric Filter:**
- "Biometric" select: all non-text biometric fields
- Min / Max number inputs
**Result controls:**
- Sort By: Name / Age / Join Date
- Order: A→Z/Low→High or Z→A/High→Low
- Show: 25 / 50 / 100
- "Clear All" button resets every filter state
**Results list:** flat stacked list inside `.card`, same row format as Home recent athletes (Avatar + name/city/age/height + sport badges). Links to athlete profile. Shows count above list. Empty state: "No athletes match your filters"
Filtering is applied in order: text search → sport → position → stat filter → biometric filter → sort → slice.
---
### `Athletes.jsx` — Athlete List (`/athletes`)
**Controls:** Text search (name or city) + sport dropdown filter, shown inline
**Grid:** `repeat(auto-fill, minmax(280px, 1fr))`
**Each card:**
- Avatar (48px) + name + city/country
- Sport badges row
- 3 quick-stat tiles in a flex row: Date of Birth, Height (cm), Years Played — each with `--font-display` value in `--accent` and tiny uppercase label
- Hover: border-color → `--accent`, translateY(-2px)
- Links to `/athletes/:id`
"+ Add" button (primary, small) top-right links to `/register`
Empty state: centered message
---
### `AthleteDetail.jsx` — Athlete Profile (`/athletes/:id`)
If athlete not found: 🏟️ emoji + "Athlete not found" + back link
**Header card:**
- Gradient background: `linear-gradient(135deg, --bg-card 0%, --bg-card2 100%)`
- Avatar (80px) + name (32px, 900 weight) + city/country + join date
- Sport badges row
- Bio in italic quotes
- Edit link → `/register?edit={id}` (secondary button)
- Delete button (danger) with `confirm()` dialog → deletes and navigates to `/athletes`
Social media row (if any socials exist):
- Separator line above
- Icons: 📘 facebook, 📷 instagram, 🦋 bluesky, 👻 snapchat
- Handle text next to each icon
**Contact card:**
- Email (colored `--accent`) + Phone in 2-column grid
**Biometrics card:**
- `repeat(auto-fill, minmax(130px, 1fr))` grid
- Each field as a `.card2` with `.stat-lbl` + `.stat-val`
- Decimal fields: `.toFixed(1)` — skip fields with no value
**Sport Stats card:**
- If athlete has >1 sport: show sport tab bar to switch between sports
- Active sport stats shown as `repeat(auto-fill, minmax(130px, 1fr))` grid of `.card2` tiles
- Skip stats with value `0` or undefined (avoids showing irrelevant stats for wrong position)
- `text` type stats (position): show as `.stat-val` at 16px
- `decimal` type: `.toFixed(2)`
Back link → `/athletes`
---
### `Register.jsx` — Registration / Edit Form (`/register`)
**Dual mode:** if `?edit={id}` query param is present, loads existing user data and updates on submit. Otherwise creates new user.
**4-step wizard:** step indicator bar at top shows all 4 steps with completion state (✓ for completed, number for incomplete). Clicking a completed step navigates back to it.
Steps: `['Personal Info', 'Biometrics', 'Sport Stats', 'Review']`
---
#### Step 1 — Personal Info
**Profile photo section:**
- 80px circular avatar preview (shows initials or uploaded image)
- File input for photo upload → reads as base64 DataURL → stored in `form.profileImage`
- 6 avatar color swatches (circular buttons): `['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899']` — active swatch has 3px white border
**Personal fields (2-column grid):**
- First Name * (required)
- Last Name * (required)
- Email * (required)
- Phone
- City
- Country
**Bio field:** `