v0.0.2 new updated to the vibecode-prompt
This commit is contained in:
86
CLAUDE.md
86
CLAUDE.md
@@ -1,4 +1,4 @@
|
|||||||
# CLAUDE.md — PlayersEdge
|
# CLAUDE.md — StatSphere
|
||||||
|
|
||||||
This file tells Claude how to work in this codebase. Read it fully before making any changes.
|
This file tells Claude how to work in this codebase. Read it fully before making any changes.
|
||||||
|
|
||||||
@@ -6,13 +6,25 @@ This file tells Claude how to work in this codebase. Read it fully before making
|
|||||||
|
|
||||||
## Current version
|
## Current version
|
||||||
|
|
||||||
- `Version:` 0.0.1
|
- `Version:` 0.0.2
|
||||||
|
|
||||||
|
### Bumping the version
|
||||||
|
|
||||||
|
When releasing a new version, update **all three** of these together — they must stay in sync:
|
||||||
|
|
||||||
|
1. `src/version.js` — `export const APP_VERSION = 'X.Y.Z';`
|
||||||
|
2. `package.json` — `"version": "X.Y.Z"`
|
||||||
|
3. This file — `Version:` line above
|
||||||
|
|
||||||
|
The version is displayed in the app as a fixed bottom-right label via `src/components/Footer.jsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What This Project Is
|
## 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.
|
StatSphere is a 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. The app is built with React 18 + Vite and deployed as static files behind Nginx on Ubuntu 24.04.
|
||||||
|
|
||||||
|
**Current architecture:** All data lives in `localStorage` — there is no backend, no database, no API, and no server-side email or scraping. Authentication is simulated client-side. This is an intermediate state; the long-term direction (per `vibecode-prompt.md`) adds Postgres, real auth, per-match stats entry, email validation, CSV upload, URL scraping, and role-based access control. Do not add those backend features unless explicitly asked — build against the current localStorage architecture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,20 +47,25 @@ There are no tests. There is no linter config. There is no TypeScript.
|
|||||||
All app state flows through a single React Context defined in `src/hooks/useStore.jsx`.
|
All app state flows through a single React Context defined in `src/hooks/useStore.jsx`.
|
||||||
|
|
||||||
- `StoreProvider` wraps the entire app in `App.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
|
- Users are loaded from `localStorage` key `statsphere_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`
|
- Auth state is loaded from `localStorage` key `statsphere_auth_v1`
|
||||||
- There is no per-session state beyond what's in localStorage — refreshing the page preserves all data
|
- Every change to `users` or `auth` is auto-saved to `localStorage` via `useEffect`
|
||||||
|
|
||||||
**Do not use component-level state for anything that needs to persist.** Route it through `useStore`.
|
**Do not use component-level state for anything that needs to persist.** Route it through `useStore`.
|
||||||
|
|
||||||
**The store API:**
|
**The store API:**
|
||||||
```js
|
```js
|
||||||
const { users, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore();
|
const { users, auth, login, logout, register, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore();
|
||||||
```
|
```
|
||||||
|
|
||||||
- `addUser(user)` — generates `id` (Date.now()) and `joinDate` automatically
|
- `auth` — `{ currentUser: User|null, isLoggedIn: Boolean }`
|
||||||
|
- `login(email, password)` — returns `{ success, user }` or `{ success: false, error }`; sets auth state on success
|
||||||
|
- `logout()` — clears auth state
|
||||||
|
- `register(userData)` — checks for duplicate email, calls `addUser`, returns `{ success, user }` or `{ success: false, error }`
|
||||||
|
- `addUser(user)` — generates `id` (Date.now()) and `joinDate` automatically; returns new user
|
||||||
- `updateUser(id, updates)` — shallow-merges updates into the matching user
|
- `updateUser(id, updates)` — shallow-merges updates into the matching user
|
||||||
- `deleteUser(id)` — removes by id
|
- `deleteUser(id)` — removes by id
|
||||||
|
- `getUserById(id)` — returns single user or undefined
|
||||||
- `getUsersBySport(sport)` — filters `users` where `u.sports.includes(sport)`
|
- `getUsersBySport(sport)` — filters `users` where `u.sports.includes(sport)`
|
||||||
- `resetToSeed()` — replaces all users with `SEED_USERS`
|
- `resetToSeed()` — replaces all users with `SEED_USERS`
|
||||||
|
|
||||||
@@ -58,19 +75,34 @@ React Router v6. All routes are defined once in `App.jsx`. The Nginx config uses
|
|||||||
|
|
||||||
```
|
```
|
||||||
/ → Home.jsx
|
/ → Home.jsx
|
||||||
/leaders → Leaders.jsx (accepts ?sport= query param)
|
/leaders → Leaders.jsx (accepts ?sport= query param)
|
||||||
/filter → Filter.jsx
|
/filter → Filter.jsx
|
||||||
/athletes → Athletes.jsx
|
/athletes → Athletes.jsx
|
||||||
/athletes/:id → AthleteDetail.jsx
|
/athletes/:id → AthleteDetail.jsx
|
||||||
/register → Register.jsx (accepts ?edit={id} query param)
|
/register → Register.jsx (accepts ?edit={id} query param)
|
||||||
|
/login → Login.jsx (redirects to /dashboard if already logged in)
|
||||||
|
/dashboard → Dashboard.jsx (redirects to /login if not authenticated; role-aware content)
|
||||||
|
|
||||||
|
— auth-protected placeholder routes (coming soon) —
|
||||||
|
/stats-entry → StatsEntry.jsx
|
||||||
|
/manage-athletes → ManageAthletes.jsx
|
||||||
|
/bulk-upload → BulkUpload.jsx
|
||||||
|
/manage-teams → ManageTeams.jsx
|
||||||
|
/team-roster → TeamRoster.jsx
|
||||||
|
/my-clients → MyClients.jsx
|
||||||
|
/client-contacts → ClientContacts.jsx
|
||||||
|
/admin/users → AdminUsers.jsx (administrator only)
|
||||||
|
/admin/settings → AdminSettings.jsx (administrator only)
|
||||||
|
/admin/reports → AdminReports.jsx (administrator only)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Layer
|
### Data Layer
|
||||||
|
|
||||||
`src/data/seedData.js` is the single source of truth for:
|
`src/data/seedData.js` is the single source of truth for:
|
||||||
|
- `USER_ROLES` — role registry object: `athlete`, `manager`, `team_manager`, `agent`, `administrator`
|
||||||
- `SPORTS` — sport registry object
|
- `SPORTS` — sport registry object
|
||||||
- `SPORT_STATS_DEFS` — stat definitions per sport (key, label, type)
|
- `SPORT_STATS_DEFS` — stat definitions per sport (key, label, type)
|
||||||
- `BIOMETRIC_FIELDS` — 12 biometric field definitions
|
- `BIOMETRIC_FIELDS` — 13 biometric field definitions (includes both `DOB` date field and `age` number field)
|
||||||
- `SEED_USERS` — 125 generated fake athletes
|
- `SEED_USERS` — 125 generated fake athletes
|
||||||
- `getUsersBySport(sport)` — exported helper (also re-implemented in the store)
|
- `getUsersBySport(sport)` — exported helper (also re-implemented in the store)
|
||||||
|
|
||||||
@@ -93,6 +125,8 @@ Stat definition shape:
|
|||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
id: String, // Date.now() string for new users; '101'–'225' for seed users
|
id: String, // Date.now() string for new users; '101'–'225' for seed users
|
||||||
|
role: String, // 'athlete' | 'manager' | 'team_manager' | 'agent' | 'administrator'
|
||||||
|
password: String, // plain text for now — no hashing in localStorage implementation
|
||||||
firstName: String,
|
firstName: String,
|
||||||
lastName: String,
|
lastName: String,
|
||||||
name: String, // firstName + ' ' + lastName — must be kept in sync
|
name: String, // firstName + ' ' + lastName — must be kept in sync
|
||||||
@@ -112,8 +146,10 @@ Stat definition shape:
|
|||||||
primarySport: String, // one of the SPORTS keys
|
primarySport: String, // one of the SPORTS keys
|
||||||
sports: String[], // array of sport keys — always contains primarySport
|
sports: String[], // array of sport keys — always contains primarySport
|
||||||
biometrics: {
|
biometrics: {
|
||||||
height_cm, weight_kg, age, reach_cm,
|
height_cm, weight_kg,
|
||||||
dominant_hand, dominant_foot,
|
DOB: String, // date of birth, YYYY-MM-DD format
|
||||||
|
age: Number, // computed from DOB at generation time — kept for display convenience
|
||||||
|
reach_cm, dominant_hand, dominant_foot,
|
||||||
body_fat_pct, vo2_max, vertical_jump_cm,
|
body_fat_pct, vo2_max, vertical_jump_cm,
|
||||||
'40_yard_dash', bench_press_reps, years_pro
|
'40_yard_dash', bench_press_reps, years_pro
|
||||||
},
|
},
|
||||||
@@ -123,6 +159,7 @@ Stat definition shape:
|
|||||||
// only present for sports in the user's sports array
|
// only present for sports in the user's sports array
|
||||||
},
|
},
|
||||||
joinDate: String, // YYYY-MM-DD
|
joinDate: String, // YYYY-MM-DD
|
||||||
|
lastLogin: String, // ISO datetime string, set on login
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -302,7 +339,7 @@ If the stat should appear in seed data, also update the relevant `gen{Sport}Stat
|
|||||||
|
|
||||||
The PWA is configured in `vite.config.js` via `vite-plugin-pwa`. The service worker is auto-generated by Workbox and caches all `{js,css,html,ico,png,svg,woff2}` files. `registerType: 'autoUpdate'` means the SW updates silently on rebuild.
|
The PWA is configured in `vite.config.js` via `vite-plugin-pwa`. The service worker is auto-generated by Workbox and caches all `{js,css,html,ico,png,svg,woff2}` files. `registerType: 'autoUpdate'` means the SW updates silently on rebuild.
|
||||||
|
|
||||||
The install prompt (`PWABanner.jsx`) listens for `beforeinstallprompt`. Once dismissed, it sets `localStorage.getItem('pwa_dismissed')` and never shows again. This is separate from `PlayersEdge_users_v1`.
|
The install prompt (`PWABanner.jsx`) listens for `beforeinstallprompt`. Once dismissed, it sets `localStorage.getItem('pwa_dismissed')` and never shows again. This is separate from `statsphere_users_v1` and `statsphere_auth_v1`.
|
||||||
|
|
||||||
PWA icons (`public/icon-192.png`, `public/icon-512.png`, `public/apple-touch-icon.png`) are placeholder PNGs. Replace them with real images for production.
|
PWA icons (`public/icon-192.png`, `public/icon-512.png`, `public/apple-touch-icon.png`) are placeholder PNGs. Replace them with real images for production.
|
||||||
|
|
||||||
@@ -323,14 +360,33 @@ npm run build
|
|||||||
# dist/ is ready — rsync or cp to web root
|
# dist/ is ready — rsync or cp to web root
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### When to rerun install.sh
|
||||||
|
|
||||||
|
**You do not need to rerun `install.sh` for normal code changes.** Only rerun it when:
|
||||||
|
|
||||||
|
| Change | Action required |
|
||||||
|
|---|---|
|
||||||
|
| New package added to `dependencies` or `devDependencies` in `package.json` | `npm install` on the server (or full `install.sh` rerun) |
|
||||||
|
| Node.js version requirement changes | Full `install.sh` rerun |
|
||||||
|
| Nginx config changes | Full `install.sh` rerun (or manually edit `/etc/nginx/sites-available/statsphere`) |
|
||||||
|
| New system-level dependency (e.g. ImageMagick, a native Node addon) | Full `install.sh` rerun |
|
||||||
|
|
||||||
|
**Claude will flag this:** Any time a task adds or removes a package from `package.json`, Claude will explicitly note that `npm install` (or `install.sh`) must be rerun on the server before the next deployment.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Limitations / Things to Be Aware Of
|
## Known Limitations / Things to Be Aware Of
|
||||||
|
|
||||||
- **Profile images are base64 in localStorage.** Large images can approach the ~5MB localStorage limit. There is no file size validation.
|
- **Profile images are base64 in localStorage.** Large images can approach the ~5MB localStorage limit. There is no file size validation.
|
||||||
- **No authentication.** Any visitor can edit or delete any athlete.
|
- **Passwords are stored plain text.** The localStorage auth implementation has no hashing. This is intentional for the current client-side-only phase.
|
||||||
|
- **Auth is client-side only.** Anyone who clears localStorage or inspects it can bypass auth. This is a known limitation of the localStorage architecture.
|
||||||
- **`40_yard_dash` key has a leading digit.** Access it as `biometrics['40_yard_dash']`, not `biometrics.40_yard_dash`. This is intentional — the label reads naturally in the UI.
|
- **`40_yard_dash` key has a leading digit.** Access it as `biometrics['40_yard_dash']`, not `biometrics.40_yard_dash`. This is intentional — the label reads naturally in the UI.
|
||||||
- **`user.name` is not auto-derived.** When updating `firstName` or `lastName`, also update `name`. The `addUser` function handles this; `updateUser` does not — it takes whatever you pass.
|
- **`user.name` is not auto-derived.** When updating `firstName` or `lastName`, also update `name`. The `addUser` function handles this; `updateUser` does not — it takes whatever you pass.
|
||||||
|
- **`user.age` and `user.biometrics.DOB` both exist.** `DOB` is the canonical date of birth (YYYY-MM-DD); `age` is derived from it at seed-generation time. Display `DOB` for logged-in users where personal details are shown; display `age` (or compute from DOB) for public views.
|
||||||
- **Seed data is regenerated randomly on every `npm run build`** (if localStorage is cleared). The randomness is seeded by `Math.random()` with no fixed seed. This is intentional — seed data is fake and disposable.
|
- **Seed data is regenerated randomly on every `npm run build`** (if localStorage is cleared). The randomness is seeded by `Math.random()` with no fixed seed. This is intentional — seed data is fake and disposable.
|
||||||
- **The Leaders table shows the first 10 numeric stats** for the selected sport. If a sport has stats that should be prioritised in the table, order them first in `SPORT_STATS_DEFS` (after the `position` text field).
|
- **The Leaders table shows the first 10 numeric stats** for the selected sport. If a sport has stats that should be prioritised in the table, order them first in `SPORT_STATS_DEFS` (after the `position` text field).
|
||||||
- **`fmtVal` in Leaders shows `—` for `0`** — this is a display choice, not a bug. Stats that are genuinely 0 (e.g. a QB's sack count as a defender stat) display as `—` to reduce noise.
|
- **`fmtVal` in Leaders shows `—` for `0`** — this is a display choice, not a bug. Stats that are genuinely 0 (e.g. a QB's sack count as a defender stat) display as `—` to reduce noise.
|
||||||
|
- **Dashboard placeholder routes exist but are not yet implemented.** `/stats-entry`, `/manage-athletes`, `/bulk-upload`, `/manage-teams`, `/team-roster`, `/my-clients`, `/client-contacts`, `/admin/users`, `/admin/settings`, `/admin/reports` all have stub pages that show "Coming soon." They are the starting point for the features described in `vibecode-prompt.md`.
|
||||||
|
- **Sport stats are not collected at registration.** The Register wizard (Personal Info → Biometrics → Review) skips sport stats. Athletes add stats after account setup via `/stats-entry`.
|
||||||
|
- **Football positions** in `seedData.js` (internal `positions` object): `QB, RB, WR, TE, FB, OC, OG, OT, DE, DT, LB, CB, S, P, K`. Register.jsx does not have its own positions object — sport stats are entered post-registration.
|
||||||
|
- **Hockey positions** in `seedData.js`: `C, LW, RW, RD, LD, G`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "statsphere",
|
"name": "statsphere",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "0.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
26
src/App.jsx
26
src/App.jsx
@@ -2,12 +2,25 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|||||||
import { StoreProvider } from './hooks/useStore.jsx';
|
import { StoreProvider } from './hooks/useStore.jsx';
|
||||||
import Nav from './components/Nav.jsx';
|
import Nav from './components/Nav.jsx';
|
||||||
import PWABanner from './components/PWABanner.jsx';
|
import PWABanner from './components/PWABanner.jsx';
|
||||||
|
import Footer from './components/Footer.jsx';
|
||||||
import Home from './pages/Home.jsx';
|
import Home from './pages/Home.jsx';
|
||||||
import Leaders from './pages/Leaders.jsx';
|
import Leaders from './pages/Leaders.jsx';
|
||||||
import Filter from './pages/Filter.jsx';
|
import Filter from './pages/Filter.jsx';
|
||||||
import Athletes from './pages/Athletes.jsx';
|
import Athletes from './pages/Athletes.jsx';
|
||||||
import AthleteDetail from './pages/AthleteDetail.jsx';
|
import AthleteDetail from './pages/AthleteDetail.jsx';
|
||||||
import Register from './pages/Register.jsx';
|
import Register from './pages/Register.jsx';
|
||||||
|
import Login from './pages/Login.jsx';
|
||||||
|
import Dashboard from './pages/Dashboard.jsx';
|
||||||
|
import StatsEntry from './pages/StatsEntry.jsx';
|
||||||
|
import ManageAthletes from './pages/ManageAthletes.jsx';
|
||||||
|
import BulkUpload from './pages/BulkUpload.jsx';
|
||||||
|
import ManageTeams from './pages/ManageTeams.jsx';
|
||||||
|
import TeamRoster from './pages/TeamRoster.jsx';
|
||||||
|
import MyClients from './pages/MyClients.jsx';
|
||||||
|
import ClientContacts from './pages/ClientContacts.jsx';
|
||||||
|
import AdminUsers from './pages/AdminUsers.jsx';
|
||||||
|
import AdminSettings from './pages/AdminSettings.jsx';
|
||||||
|
import AdminReports from './pages/AdminReports.jsx';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -21,8 +34,21 @@ export default function App() {
|
|||||||
<Route path="/athletes" element={<Athletes />} />
|
<Route path="/athletes" element={<Athletes />} />
|
||||||
<Route path="/athletes/:id" element={<AthleteDetail />} />
|
<Route path="/athletes/:id" element={<AthleteDetail />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/stats-entry" element={<StatsEntry />} />
|
||||||
|
<Route path="/manage-athletes" element={<ManageAthletes />} />
|
||||||
|
<Route path="/bulk-upload" element={<BulkUpload />} />
|
||||||
|
<Route path="/manage-teams" element={<ManageTeams />} />
|
||||||
|
<Route path="/team-roster" element={<TeamRoster />} />
|
||||||
|
<Route path="/my-clients" element={<MyClients />} />
|
||||||
|
<Route path="/client-contacts" element={<ClientContacts />} />
|
||||||
|
<Route path="/admin/users" element={<AdminUsers />} />
|
||||||
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<PWABanner />
|
<PWABanner />
|
||||||
|
<Footer />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
21
src/components/Footer.jsx
Normal file
21
src/components/Footer.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { APP_VERSION } from '../version.js';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'calc(var(--bottom-nav-h) + 8px)',
|
||||||
|
right: '12px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 50,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}>
|
||||||
|
v{APP_VERSION}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,32 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
const navItems = [
|
const publicNavItems = [
|
||||||
{ to: '/', label: 'Home', icon: '⚡' },
|
{ to: '/', label: 'Home', icon: '⚡' },
|
||||||
{ to: '/leaders', label: 'Leaders', icon: '🏆' },
|
{ to: '/leaders', label: 'Leaders', icon: '🏆' },
|
||||||
{ to: '/filter', label: 'Search', icon: '🔍' },
|
{ to: '/filter', label: 'Search', icon: '🔍' },
|
||||||
{ to: '/athletes', label: 'Athletes', icon: '👤' },
|
{ to: '/athletes', label: 'Athletes', icon: '👤' },
|
||||||
{ to: '/register', label: 'Register', icon: '➕' },
|
];
|
||||||
|
|
||||||
|
const authNavItems = [
|
||||||
|
{ to: '/dashboard', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ to: '/leaders', label: 'Leaders', icon: '🏆' },
|
||||||
|
{ to: '/filter', label: 'Search', icon: '🔍' },
|
||||||
|
{ to: '/athletes', label: 'Athletes', icon: '👤' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Nav() {
|
export default function Nav() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { auth, logout } = useStore();
|
||||||
|
const { isLoggedIn } = auth;
|
||||||
|
|
||||||
|
const navItems = isLoggedIn ? authNavItems : publicNavItems;
|
||||||
|
|
||||||
|
function handleLogout(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
logout();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -47,6 +64,48 @@ export default function Nav() {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}>{n.label}</Link>
|
}}>{n.label}</Link>
|
||||||
))}
|
))}
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<Link to="/login" onClick={handleLogout} style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>Sign Out</Link>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: pathname === '/login' ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
background: pathname === '/login' ? 'rgba(0,212,255,0.08)' : 'transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>Sign In</Link>
|
||||||
|
)}
|
||||||
|
{!isLoggedIn && (
|
||||||
|
<Link to="/register" style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: pathname === '/register' ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
background: pathname === '/register' ? 'rgba(0,212,255,0.08)' : 'transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>Register</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -73,6 +132,19 @@ export default function Nav() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{!isLoggedIn && (
|
||||||
|
<Link to="/register" style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', gap: 3, padding: '8px 4px',
|
||||||
|
color: pathname === '/register' ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
transition: 'color 0.15s',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 20 }}>➕</span>
|
||||||
|
<span style={{ fontSize: 10, fontFamily: 'var(--font-display)', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||||
|
Register
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export const USER_ROLES = {
|
||||||
|
athlete: { id: 'athlete', name: 'Athlete', cost: 0 },
|
||||||
|
manager: { id: 'manager', name: 'Manager', cost: 0 },
|
||||||
|
team_manager: { id: 'team_manager', name: 'Team Manager', cost: 0 },
|
||||||
|
agent: { id: 'agent', name: 'Agent', cost: 0 },
|
||||||
|
administrator: { id: 'administrator', name: 'Administrator', cost: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
export const SPORTS = {
|
export const SPORTS = {
|
||||||
football: { id: 'football', name: 'American Football', emoji: '🏈', color: '#4ade80' },
|
football: { id: 'football', name: 'American Football', emoji: '🏈', color: '#4ade80' },
|
||||||
hockey: { id: 'hockey', name: 'Hockey', emoji: '🏒', color: '#60a5fa' },
|
hockey: { id: 'hockey', name: 'Hockey', emoji: '🏒', color: '#60a5fa' },
|
||||||
@@ -102,6 +110,7 @@ export const SPORT_STATS_DEFS = {
|
|||||||
export const BIOMETRIC_FIELDS = [
|
export const BIOMETRIC_FIELDS = [
|
||||||
{ key: 'height_cm', label: 'Height (cm)', type: 'number' },
|
{ key: 'height_cm', label: 'Height (cm)', type: 'number' },
|
||||||
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number' },
|
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number' },
|
||||||
|
{ key: 'DOB', label: 'Date of Birth', type: 'date' },
|
||||||
{ key: 'age', label: 'Age', type: 'number' },
|
{ key: 'age', label: 'Age', type: 'number' },
|
||||||
{ key: 'reach_cm', label: 'Reach/Wingspan (cm)', type: 'number' },
|
{ key: 'reach_cm', label: 'Reach/Wingspan (cm)', type: 'number' },
|
||||||
{ key: 'dominant_hand', label: 'Dominant Hand', type: 'text' },
|
{ key: 'dominant_hand', label: 'Dominant Hand', type: 'text' },
|
||||||
@@ -115,8 +124,8 @@ export const BIOMETRIC_FIELDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const positions = {
|
const positions = {
|
||||||
football: ['QB', 'RB', 'WR', 'TE', 'OL', 'DE', 'DT', 'LB', 'CB', 'S', 'K'],
|
football: ['QB', 'RB', 'WR', 'TE', 'FB', 'OC', 'OG', 'OT', 'DE', 'DT', 'LB', 'CB', 'S', 'P', 'K'],
|
||||||
hockey: ['C', 'LW', 'RW', 'D', 'G'],
|
hockey: ['C', 'LW', 'RW', 'RD', 'LD', 'G'],
|
||||||
baseball: ['SP', 'RP', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'],
|
baseball: ['SP', 'RP', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'],
|
||||||
soccer: ['GK', 'CB', 'LB', 'RB', 'CDM', 'CM', 'CAM', 'LW', 'RW', 'ST'],
|
soccer: ['GK', 'CB', 'LB', 'RB', 'CDM', 'CM', 'CAM', 'LW', 'RW', 'ST'],
|
||||||
basketball: ['PG', 'SG', 'SF', 'PF', 'C'],
|
basketball: ['PG', 'SG', 'SF', 'PF', 'C'],
|
||||||
@@ -143,10 +152,13 @@ function genBiometrics(sport) {
|
|||||||
const [hMin, hMax] = heightMap[sport];
|
const [hMin, hMax] = heightMap[sport];
|
||||||
const [wMin, wMax] = weightMap[sport];
|
const [wMin, wMax] = weightMap[sport];
|
||||||
const h = r(hMin, hMax);
|
const h = r(hMin, hMax);
|
||||||
|
const age = r(20, 38);
|
||||||
|
const birthYear = new Date().getFullYear() - age;
|
||||||
return {
|
return {
|
||||||
height_cm: h,
|
height_cm: h,
|
||||||
weight_kg: r(wMin, wMax),
|
weight_kg: r(wMin, wMax),
|
||||||
age: r(20, 38),
|
DOB: `${birthYear}-${String(r(1,12)).padStart(2,'0')}-${String(r(1,28)).padStart(2,'0')}`,
|
||||||
|
age,
|
||||||
reach_cm: h + r(5, 20),
|
reach_cm: h + r(5, 20),
|
||||||
dominant_hand: Math.random() > 0.1 ? 'Right' : 'Left',
|
dominant_hand: Math.random() > 0.1 ? 'Right' : 'Left',
|
||||||
dominant_foot: Math.random() > 0.15 ? 'Right' : 'Left',
|
dominant_foot: Math.random() > 0.15 ? 'Right' : 'Left',
|
||||||
@@ -316,7 +328,7 @@ const bios = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let idCounter = 100;
|
let idCounter = 100;
|
||||||
function genUser(firstName, lastName, primarySport) {
|
function genUser(firstName, lastName, primarySport, role = 'athlete') {
|
||||||
const pos = pick(positions[primarySport]);
|
const pos = pick(positions[primarySport]);
|
||||||
const sports = [primarySport];
|
const sports = [primarySport];
|
||||||
// 30% chance of a second sport
|
// 30% chance of a second sport
|
||||||
@@ -330,23 +342,32 @@ function genUser(firstName, lastName, primarySport) {
|
|||||||
statsMap[s] = genSportStats(s, p);
|
statsMap[s] = genSportStats(s, p);
|
||||||
});
|
});
|
||||||
const name = `${firstName} ${lastName}`;
|
const name = `${firstName} ${lastName}`;
|
||||||
|
const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`;
|
||||||
return {
|
return {
|
||||||
id: String(++idCounter),
|
id: String(++idCounter),
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
name,
|
name,
|
||||||
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`,
|
email,
|
||||||
|
password: 'temp123', // Default password for seed data
|
||||||
phone: `+1 (${r(200,999)}) ${r(200,999)}-${r(1000,9999)}`,
|
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']),
|
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']),
|
country: Math.random() > 0.2 ? 'USA' : pick(['Canada', 'Mexico', 'UK', 'Brazil', 'Australia']),
|
||||||
bio: pick(bios),
|
bio: pick(bios),
|
||||||
socials: genSocials(name),
|
socials: genSocials(name),
|
||||||
avatarColor: pick(avatarColors),
|
avatarColor: pick(avatarColors),
|
||||||
|
profileImage: '',
|
||||||
primarySport,
|
primarySport,
|
||||||
sports,
|
sports,
|
||||||
|
role,
|
||||||
biometrics: genBiometrics(primarySport),
|
biometrics: genBiometrics(primarySport),
|
||||||
sportStats: statsMap,
|
sportStats: statsMap,
|
||||||
joinDate: new Date(Date.now() - r(30, 730) * 86400000).toISOString().split('T')[0],
|
joinDate: new Date(Date.now() - r(30, 730) * 86400000).toISOString().split('T')[0],
|
||||||
|
emailValidated: false,
|
||||||
|
managerAuthorized: false,
|
||||||
|
teams: [], // Array of team IDs the athlete belongs to
|
||||||
|
paymentStatus: 'completed', // For seed data, assume payment completed
|
||||||
|
lastLogin: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SEED_USERS } from '../data/seedData.js';
|
|||||||
const StoreContext = createContext(null);
|
const StoreContext = createContext(null);
|
||||||
|
|
||||||
const STORAGE_KEY = 'statsphere_users_v1';
|
const STORAGE_KEY = 'statsphere_users_v1';
|
||||||
|
const AUTH_KEY = 'statsphere_auth_v1';
|
||||||
|
|
||||||
function loadUsers() {
|
function loadUsers() {
|
||||||
try {
|
try {
|
||||||
@@ -17,11 +18,48 @@ function saveUsers(users) {
|
|||||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(users)); } catch {}
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(users)); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadAuth() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(AUTH_KEY);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch {}
|
||||||
|
return { currentUser: null, isLoggedIn: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAuth(auth) {
|
||||||
|
try { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export function StoreProvider({ children }) {
|
export function StoreProvider({ children }) {
|
||||||
const [users, setUsers] = useState(loadUsers);
|
const [users, setUsers] = useState(loadUsers);
|
||||||
const [currentUser, setCurrentUser] = useState(null);
|
const [auth, setAuth] = useState(loadAuth);
|
||||||
|
|
||||||
useEffect(() => { saveUsers(users); }, [users]);
|
useEffect(() => { saveUsers(users); }, [users]);
|
||||||
|
useEffect(() => { saveAuth(auth); }, [auth]);
|
||||||
|
|
||||||
|
function login(email, password) {
|
||||||
|
const user = users.find(u => u.email === email && u.password === password);
|
||||||
|
if (user) {
|
||||||
|
const updatedUser = { ...user, lastLogin: new Date().toISOString() };
|
||||||
|
updateUser(user.id, { lastLogin: updatedUser.lastLogin });
|
||||||
|
setAuth({ currentUser: updatedUser, isLoggedIn: true });
|
||||||
|
return { success: true, user: updatedUser };
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Invalid email or password' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
setAuth({ currentUser: null, isLoggedIn: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function register(userData) {
|
||||||
|
const existingUser = users.find(u => u.email === userData.email);
|
||||||
|
if (existingUser) {
|
||||||
|
return { success: false, error: 'Email already registered' };
|
||||||
|
}
|
||||||
|
const newUser = addUser(userData);
|
||||||
|
return { success: true, user: newUser };
|
||||||
|
}
|
||||||
|
|
||||||
function addUser(user) {
|
function addUser(user) {
|
||||||
const newUser = { ...user, id: String(Date.now()), joinDate: new Date().toISOString().split('T')[0] };
|
const newUser = { ...user, id: String(Date.now()), joinDate: new Date().toISOString().split('T')[0] };
|
||||||
@@ -50,7 +88,19 @@ export function StoreProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StoreContext.Provider value={{ users, currentUser, setCurrentUser, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed }}>
|
<StoreContext.Provider value={{
|
||||||
|
users,
|
||||||
|
auth,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
register,
|
||||||
|
addUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
getUserById,
|
||||||
|
getUsersBySport,
|
||||||
|
resetToSeed
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</StoreContext.Provider>
|
</StoreContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
30
src/pages/AdminReports.jsx
Normal file
30
src/pages/AdminReports.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function AdminReports() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
else if (auth.currentUser?.role !== 'administrator') navigate('/dashboard');
|
||||||
|
}, [auth.isLoggedIn, auth.currentUser, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Reports</h1>
|
||||||
|
<p className="section-sub">View system reports and analytics</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📈</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
System Reports
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Registration trends, active users, stat entry volume, and platform health analytics. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/pages/AdminSettings.jsx
Normal file
30
src/pages/AdminSettings.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function AdminSettings() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
else if (auth.currentUser?.role !== 'administrator') navigate('/dashboard');
|
||||||
|
}, [auth.isLoggedIn, auth.currentUser, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">System Settings</h1>
|
||||||
|
<p className="section-sub">Configure system-wide settings</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>🔧</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
System Configuration
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Platform-wide configuration: roles, pricing tiers, feature flags, and maintenance controls. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/pages/AdminUsers.jsx
Normal file
30
src/pages/AdminUsers.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function AdminUsers() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
else if (auth.currentUser?.role !== 'administrator') navigate('/dashboard');
|
||||||
|
}, [auth.isLoggedIn, auth.currentUser, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">User Management</h1>
|
||||||
|
<p className="section-sub">Manage all user accounts and permissions</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>⚙️</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
User Administration
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
View, edit, suspend, and manage roles for all registered accounts. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/pages/BulkUpload.jsx
Normal file
29
src/pages/BulkUpload.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function BulkUpload() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Bulk Upload</h1>
|
||||||
|
<p className="section-sub">Upload athlete data and statistics via CSV</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📂</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
CSV Bulk Upload
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Upload a CSV to bulk-create athlete accounts or import stats for multiple athletes at once. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/pages/ClientContacts.jsx
Normal file
29
src/pages/ClientContacts.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function ClientContacts() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Client Contacts</h1>
|
||||||
|
<p className="section-sub">Access contact information for your athlete clients</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📇</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
Contact Directory
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Full contact details — email, phone, and social media — for athletes who have authorized your agent access. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/pages/Dashboard.jsx
Normal file
218
src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
import { USER_ROLES } from '../data/seedData.js';
|
||||||
|
import Avatar from '../components/Avatar.jsx';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { auth, logout } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { currentUser } = auth;
|
||||||
|
|
||||||
|
if (!auth.isLoggedIn || !currentUser) {
|
||||||
|
navigate('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDashboardContent() {
|
||||||
|
switch (currentUser.role) {
|
||||||
|
case 'athlete':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Athlete Dashboard</h2>
|
||||||
|
<div className="grid-2" style={{ marginBottom: '24px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>My Stats</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
View and manage your performance statistics
|
||||||
|
</p>
|
||||||
|
<Link to="/athletes/:id" className="btn btn-primary">View My Profile</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Enter Stats</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Add new match statistics and performance data
|
||||||
|
</p>
|
||||||
|
<Link to="/stats-entry" className="btn btn-secondary">Enter Stats</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'manager':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Manager Dashboard</h2>
|
||||||
|
<div className="grid-3" style={{ marginBottom: '24px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>My Athletes</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Manage athlete profiles and statistics
|
||||||
|
</p>
|
||||||
|
<Link to="/manage-athletes" className="btn btn-primary">Manage Athletes</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Bulk Upload</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Upload athlete data via CSV
|
||||||
|
</p>
|
||||||
|
<Link to="/bulk-upload" className="btn btn-secondary">CSV Upload</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Stat Entry</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Enter stats for multiple athletes
|
||||||
|
</p>
|
||||||
|
<Link to="/stats-entry" className="btn btn-secondary">Enter Stats</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'team_manager':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Team Manager Dashboard</h2>
|
||||||
|
<div className="grid-3" style={{ marginBottom: '24px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>My Teams</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Manage team rosters and information
|
||||||
|
</p>
|
||||||
|
<Link to="/manage-teams" className="btn btn-primary">Manage Teams</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Team Roster</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
View and manage team athletes
|
||||||
|
</p>
|
||||||
|
<Link to="/team-roster" className="btn btn-secondary">View Roster</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Stat Entry</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Enter team statistics
|
||||||
|
</p>
|
||||||
|
<Link to="/stats-entry" className="btn btn-secondary">Enter Stats</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'agent':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Agent Dashboard</h2>
|
||||||
|
<div className="grid-2" style={{ marginBottom: '24px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>My Clients</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
View and manage athlete client information
|
||||||
|
</p>
|
||||||
|
<Link to="/my-clients" className="btn btn-primary">View Clients</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Contact Info</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Access client contact details
|
||||||
|
</p>
|
||||||
|
<Link to="/client-contacts" className="btn btn-secondary">View Contacts</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'administrator':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Administrator Dashboard</h2>
|
||||||
|
<div className="grid-3" style={{ marginBottom: '24px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>User Management</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Manage all user accounts and permissions
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/users" className="btn btn-primary">Manage Users</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>System Settings</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
Configure system-wide settings
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/settings" className="btn btn-secondary">Settings</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ color: 'var(--accent)', marginBottom: '16px' }}>Reports</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||||
|
View system reports and analytics
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/reports" className="btn btn-secondary">View Reports</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Dashboard</h2>
|
||||||
|
<p>Welcome to your dashboard!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
{/* Header with user info */}
|
||||||
|
<div className="card" style={{ marginBottom: '32px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<Avatar user={currentUser} size={60} />
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: '0', fontSize: '24px', fontWeight: 700 }}>
|
||||||
|
{currentUser.name}
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: '4px 0', color: 'var(--text-secondary)' }}>
|
||||||
|
{currentUser.email}
|
||||||
|
</p>
|
||||||
|
<span className="badge" style={{ background: 'var(--accent)', color: 'black' }}>
|
||||||
|
{USER_ROLES[currentUser.role]?.name || currentUser.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleLogout} className="btn btn-danger">
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role-specific dashboard content */}
|
||||||
|
{getDashboardContent()}
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>Quick Stats</h3>
|
||||||
|
<div className="grid-3">
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div className="stat-val">--</div>
|
||||||
|
<div className="stat-lbl">Profile Views</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div className="stat-val">--</div>
|
||||||
|
<div className="stat-lbl">Stat Updates</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div className="stat-val">--</div>
|
||||||
|
<div className="stat-lbl">Last Login</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/pages/Login.jsx
Normal file
113
src/pages/Login.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login, auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isLoggedIn) {
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = login(email, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ maxWidth: '400px', margin: '0 auto' }}>
|
||||||
|
<div className="card" style={{ textAlign: 'center' }}>
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 800,
|
||||||
|
marginBottom: '8px',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}>
|
||||||
|
Welcome Back
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '32px' }}>
|
||||||
|
Sign in to manage your athlete stats
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ textAlign: 'left' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', marginBottom: '16px' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing In...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
borderTop: `1px solid var(--border)`,
|
||||||
|
paddingTop: '16px',
|
||||||
|
marginTop: '24px'
|
||||||
|
}}>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" style={{ color: 'var(--accent)' }}>
|
||||||
|
Register here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/pages/ManageAthletes.jsx
Normal file
29
src/pages/ManageAthletes.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function ManageAthletes() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Manage Athletes</h1>
|
||||||
|
<p className="section-sub">View and manage athlete profiles and statistics</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>👥</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
Athlete Management
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Manage profiles and statistics for athletes you represent. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/pages/ManageTeams.jsx
Normal file
29
src/pages/ManageTeams.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function ManageTeams() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Manage Teams</h1>
|
||||||
|
<p className="section-sub">View and manage team rosters and information</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>🏟️</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
Team Management
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Manage teams, rosters, league associations, and team-level stat entry. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/pages/MyClients.jsx
Normal file
29
src/pages/MyClients.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function MyClients() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">My Clients</h1>
|
||||||
|
<p className="section-sub">View and manage your athlete clients</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>🤝</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
Client Management
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
View athlete clients you represent, including full contact details and performance data. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
import { useStore } from '../hooks/useStore.jsx';
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js';
|
import { SPORTS, BIOMETRIC_FIELDS } from '../data/seedData.js';
|
||||||
|
|
||||||
const STEPS = ['Personal Info', 'Biometrics', 'Sport Stats', 'Review'];
|
const STEPS = ['Personal Info', 'Biometrics', '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'];
|
const COLORS = ['#00d4ff', '#ff6b35', '#a855f7', '#22c55e', '#f59e0b', '#ec4899'];
|
||||||
|
|
||||||
@@ -87,7 +79,6 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeSports = form.sports || [];
|
const activeSports = form.sports || [];
|
||||||
const currentSportForStats = activeSports[0] || 'football';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -219,10 +210,11 @@ export default function Register() {
|
|||||||
{f.type === 'text'
|
{f.type === 'text'
|
||||||
? <select value={form.biometrics[f.key] || ''} onChange={e => setField(`biometrics.${f.key}`, e.target.value)}>
|
? <select value={form.biometrics[f.key] || ''} onChange={e => setField(`biometrics.${f.key}`, e.target.value)}>
|
||||||
<option value="">Select...</option>
|
<option value="">Select...</option>
|
||||||
{f.key === 'dominant_hand' || f.key === 'dominant_foot'
|
{['Right', 'Left', 'Both'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
? ['Right', 'Left', 'Both'].map(o => <option key={o} value={o}>{o}</option>)
|
|
||||||
: null}
|
|
||||||
</select>
|
</select>
|
||||||
|
: f.type === 'date'
|
||||||
|
? <input type="date" value={form.biometrics[f.key] || ''}
|
||||||
|
onChange={e => setField(`biometrics.${f.key}`, e.target.value)} />
|
||||||
: <input type="number" step={f.type === 'decimal' ? '0.1' : '1'}
|
: <input type="number" step={f.type === 'decimal' ? '0.1' : '1'}
|
||||||
value={form.biometrics[f.key] || ''}
|
value={form.biometrics[f.key] || ''}
|
||||||
onChange={e => setField(`biometrics.${f.key}`, e.target.value)}
|
onChange={e => setField(`biometrics.${f.key}`, e.target.value)}
|
||||||
@@ -234,41 +226,8 @@ export default function Register() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Sport Stats */}
|
{/* Step 3: Review */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div>
|
|
||||||
{activeSports.map(sport => {
|
|
||||||
const defs = SPORT_STATS_DEFS[sport] || [];
|
|
||||||
return (
|
|
||||||
<div key={sport} className="card" style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>
|
|
||||||
{SPORTS[sport]?.emoji} {SPORTS[sport]?.name} Stats
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
|
||||||
{defs.map(def => (
|
|
||||||
<div className="form-group" key={def.key}>
|
|
||||||
<label className="label">{def.label}</label>
|
|
||||||
{def.type === 'text'
|
|
||||||
? <select value={form.sportStats?.[sport]?.[def.key] || ''} onChange={e => setField(`sportStats.${sport}.${def.key}`, e.target.value)}>
|
|
||||||
<option value="">Select position...</option>
|
|
||||||
{(positions[sport] || []).map(p => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
: <input type="number" step={def.type === 'decimal' ? '0.01' : '1'}
|
|
||||||
value={form.sportStats?.[sport]?.[def.key] || ''}
|
|
||||||
onChange={e => setField(`sportStats.${sport}.${def.key}`, e.target.value)}
|
|
||||||
placeholder="0" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 4: Review */}
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Review & Submit</div>
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Review & Submit</div>
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 20 }}>
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 20 }}>
|
||||||
@@ -292,9 +251,11 @@ export default function Register() {
|
|||||||
{form.bio && <p style={{ color: 'var(--text-secondary)', fontSize: 14, fontStyle: 'italic', marginBottom: 16 }}>"{form.bio}"</p>}
|
{form.bio && <p style={{ color: 'var(--text-secondary)', fontSize: 14, fontStyle: 'italic', marginBottom: 16 }}>"{form.bio}"</p>}
|
||||||
|
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||||
{Object.values(form.biometrics || {}).filter(Boolean).length} biometric fields filled ·{' '}
|
{Object.values(form.biometrics || {}).filter(Boolean).length} biometric fields filled
|
||||||
{activeSports.reduce((acc, s) => acc + Object.values(form.sportStats?.[s] || {}).filter(Boolean).length, 0)} sport stats filled
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: 13, marginTop: 12 }}>
|
||||||
|
Sport stats can be added from your dashboard after registration.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
29
src/pages/StatsEntry.jsx
Normal file
29
src/pages/StatsEntry.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function StatsEntry() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Enter Stats</h1>
|
||||||
|
<p className="section-sub">Add match statistics and performance data</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📊</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
Per-Match Stats Entry
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
Enter game-by-game statistics with date, opponent, and supporting URL or document. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/pages/TeamRoster.jsx
Normal file
29
src/pages/TeamRoster.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useStore } from '../hooks/useStore.jsx';
|
||||||
|
|
||||||
|
export default function TeamRoster() {
|
||||||
|
const { auth } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.isLoggedIn) navigate('/login');
|
||||||
|
}, [auth.isLoggedIn, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h1 className="section-title">Team Roster</h1>
|
||||||
|
<p className="section-sub">View and manage athletes on your team</p>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: '48px 20px' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📋</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
Team Roster
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', maxWidth: 400, margin: '0 auto 24px' }}>
|
||||||
|
View your team's athletes, manage their profiles, and enter stats on their behalf. Coming soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-secondary">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/version.js
Normal file
2
src/version.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Bump this alongside package.json and CLAUDE.md when releasing a new version.
|
||||||
|
export const APP_VERSION = '0.0.2';
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
# StatSphere – Athlete Stats Platform
|
# PlayersEdge – Athlete Stats Platform
|
||||||
## Vibe-Coding Prompt
|
## Vibe-Coding Prompt
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Overview
|
## 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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -209,9 +225,16 @@ An object keyed by sport ID. Each sport has an array of stat definition objects:
|
|||||||
// 'decimal' = float, displayed with toFixed(2)
|
// '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)
|
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.
|
||||||
|
|
||||||
**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
|
**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)
|
**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)
|
||||||
|
|
||||||
@@ -253,7 +276,7 @@ bench_press_reps (number), years_pro (number)
|
|||||||
biometrics: {
|
biometrics: {
|
||||||
height_cm: Number,
|
height_cm: Number,
|
||||||
weight_kg: Number,
|
weight_kg: Number,
|
||||||
age: Number,
|
DOB: date (format:yyyy-mm-dd),
|
||||||
reach_cm: Number,
|
reach_cm: Number,
|
||||||
dominant_hand: String,
|
dominant_hand: String,
|
||||||
dominant_foot: String,
|
dominant_foot: String,
|
||||||
@@ -275,8 +298,8 @@ bench_press_reps (number), years_pro (number)
|
|||||||
|
|
||||||
### Positions Per Sport
|
### Positions Per Sport
|
||||||
```js
|
```js
|
||||||
football: ['QB','RB','WR','TE','OL','DE','DT','LB','CB','S','K']
|
football: ['QB','RB','WR','TE','OC',"OG","OT",'DE','DT','LB','CB','S','P','K']
|
||||||
hockey: ['C','LW','RW','D','G']
|
hockey: ['C','LW','RW','RD','LD','G']
|
||||||
baseball: ['SP','RP','C','1B','2B','3B','SS','LF','CF','RF','DH']
|
baseball: ['SP','RP','C','1B','2B','3B','SS','LF','CF','RF','DH']
|
||||||
soccer: ['GK','CB','LB','RB','CDM','CM','CAM','LW','RW','ST']
|
soccer: ['GK','CB','LB','RB','CDM','CM','CAM','LW','RW','ST']
|
||||||
basketball: ['PG','SG','SF','PF','C']
|
basketball: ['PG','SG','SF','PF','C']
|
||||||
@@ -499,7 +522,7 @@ Filtering is applied in order: text search → sport → position → stat filte
|
|||||||
**Each card:**
|
**Each card:**
|
||||||
- Avatar (48px) + name + city/country
|
- Avatar (48px) + name + city/country
|
||||||
- Sport badges row
|
- 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
|
- 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)
|
- Hover: border-color → `--accent`, translateY(-2px)
|
||||||
- Links to `/athletes/:id`
|
- Links to `/athletes/:id`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user