Files
playersedge/vibecode-prompt.md
2026-04-07 16:42:17 -04:00

721 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# StatSphere 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. All data is stored in the browser via `localStorage`. No backend or database is required.
---
## 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 700900, uppercase, letter-spacing 0.020.1em
- **Body:** `Barlow` — weight 400600
- Section titles: 28px, weight 800, uppercase, `--font-display`
- Stat values: 2024px, 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)
```
**Football (16 stats):** position (text), touchdowns, passing_yards, rushing_yards, receiving_yards, completions, attempts, completion_pct (decimal), interceptions, sacks (decimal), tackles, receptions, fumbles, field_goals, games_played, passer_rating (decimal)
**Hockey (16 stats):** 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,
age: Number,
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','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']
```
---
## 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 175205, w 85140
- Hockey: h 175198, w 82105
- Baseball: h 170200, w 82110
- Soccer: h 165195, w 7090
- Basketball: h 185220, w 85120
**Common biometrics:** age 2038, reach = height + 520, body_fat_pct 618%, vo2_max 4872, vertical_jump 5590cm, 40_yard_dash 4.305.20s, bench_press_reps 1035, years_pro 118
**Stat generation is position-aware.** Key rules:
- Football: QBs get passing_yards (28005200), 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.9000.935), gaa (1.83.5), shutouts (112); skaters get goals (555), assists, points; Centers get faceoff_pct (4258%)
- Baseball: Pitchers (SP/RP) get era (2.105.80), wins (422), whip (0.921.60), strikeouts_p; position players get batting_avg (0.2200.350), home_runs, obp, slg, ops
- Soccer: GK gets save_pct (6582%), 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 04 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 `<img>` 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: `<span className={\`badge sport-${sport}\`}>{emoji} {name}</span>`
### `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 36px72px, 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: Age, Height (cm), Years Pro — 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:** `<textarea>` maxLength 300, character counter bottom-right
**Social media section (labeled "optional"):**
- 4 inputs in 2-column grid: Facebook, Instagram, Bluesky, Snapchat — all accept `@handle` format
**Sports selection:**
- Instruction text: "Select all sports you compete in:"
- 5 sport badge buttons (toggle on/off); at least 1 must remain selected
- Active badges: full opacity, scale 1.05; inactive: opacity 0.4
- At least one sport always stays selected
---
#### Step 2 — Biometrics
All 12 biometric fields in 2-column grid:
- `text` type fields (`dominant_hand`, `dominant_foot`): `<select>` with options Right / Left / Both
- All other fields: `<input type="number">`, step 0.1 for decimal, step 1 for integer
---
#### Step 3 — Sport Stats
One card section per selected sport. Each card:
- Header: `{emoji} {sport name} Stats`
- 2-column grid of all 16 stat fields:
- `position` field: `<select>` populated with positions for that sport
- Decimal stats: `<input type="number" step="0.01">`
- Integer stats: `<input type="number" step="1">`
---
#### Step 4 — Review
Summary view:
- Avatar preview (64px) + full name + email + city/country
- Sport badges
- Bio in italic quotes
- Count: "N biometric fields filled · N sport stats filled"
- Submit / Save button
---
#### Form State Management
Single `form` object in state. `setField(dotPath, value)` function handles nested updates by deep-cloning and traversing the path (e.g. `setField('sportStats.football.touchdowns', 42)` or `setField('socials.instagram', '@handle')`).
**Validation on submit:** check `firstName`, `lastName`, `email` are non-empty — show toast on failure.
**Toast:** fixed bottom pill, green background, "Athlete registered!" or "Athlete updated!", auto-hides after 2.5s with CSS keyframe animation. On success, navigate to `/athletes` after 1.8s.
**Edit mode:** on mount if `editId` present, hydrate form from `getUserById(editId)`. Merge existing socials with empty defaults so all 4 fields always exist.
---
## PWA Configuration (`vite.config.js`)
```js
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'StatSphere Athlete Stats Platform',
short_name: 'StatSphere',
description: 'Track and compare athlete stats across multiple sports',
theme_color: '#0a0f1e',
background_color: '#0a0f1e',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
}
})
```
## HTML Meta Tags (`index.html`)
Include:
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0f1e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="StatSphere" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
```
---
## Responsive Design
- **Mobile-first** layout via the `.page` class and CSS Grid `auto-fill` columns
- **Navigation:** desktop horizontal top bar ≥768px; mobile bottom bar <768px
- **Bottom nav height:** CSS variable `--bottom-nav-h` — 64px on mobile, 0px on desktop (set via `@media` rules in Nav component)
- **Tables:** horizontally scrollable wrapper `overflow-x: auto` on mobile
- **Grids:** `.grid-2` collapses to 1-col, `.grid-3` collapses to 2-col below 600px
- **Hero text:** `clamp(36px, 8vw, 72px)` for fluid scaling
---
## Ubuntu 24.04 LXC Install Script (`install.sh`)
A bash script that:
1. `apt-get update` + install `curl git nginx`
2. Install Node.js 20 LTS via NodeSource setup script
3. Create system user `statsphere`
4. Copy app to `/opt/statsphere`, set ownership
5. `npm install` as statsphere user
6. `npm run build` as statsphere user
7. Write `/etc/nginx/sites-available/statsphere` with:
- `listen 80` + `listen [::]:80`
- `root /opt/statsphere/dist`
- `try_files $uri $uri/ /index.html` (SPA fallback)
- Gzip on for js/css/html/json/svg/xml
- 1-year cache headers for static assets
- No-cache for `sw.js`
8. Enable site, remove default, `nginx -t && systemctl restart nginx`
9. Create optional `statsphere-dev.service` systemd unit running `npm run preview` on port 4173
10. Print success message with server IP
---
## Data Persistence Notes
- All data lives in `localStorage` key `statsphere_users_v1`
- To reset: `localStorage.removeItem('statsphere_users_v1'); location.reload()`
- Profile images stored as base64 data URLs — large images may approach localStorage limits; no validation enforced
- No authentication; any user can edit/delete any athlete
---
## Extensibility Notes
To add a new sport in the future:
1. Add entry to `SPORTS` in `seedData.js`
2. Add stat definitions array to `SPORT_STATS_DEFS`
3. Add position list to `positions` in both `seedData.js` and `Register.jsx`
4. Optionally add seed data generator function and call it in the generation loop
5. Add sport CSS class `.sport-{id}` to `index.css`
No routing, component, or page changes required.