Initial commit: full fitness-rewards app
Backend: FastAPI + PostgreSQL + SQLAlchemy async - Auth (JWT), onboarding (PAR-Q+, TTM, BREQ-2), activities, food log - Points engine with daily gate + weekly targets - Strava + Polar OAuth integration - Open Food Facts barcode lookup - Resource recommendation engine - 10 seeded Darebee/StrongLifts/YouTube programs Frontend: React + Vite, mobile-first dark theme - Login/Register, 8-step onboarding flow - Dashboard with daily gate status - Activity logger, food logger (manual + barcode scan + search) - Reward shop with redemption - Weekly summary view - Settings (point rules, targets, integrations) Deployment: Docker Compose for Coolify (Traefik)
This commit is contained in:
commit
4db2b0846b
61 changed files with 4280 additions and 0 deletions
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<title>Fitness Rewards</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
frontend/nginx.conf
Normal file
18
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "fitness-rewards-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
56
frontend/src/App.jsx
Normal file
56
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Routes, Route, Navigate, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { hasToken, clearToken, api } from './api'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import LogActivity from './pages/LogActivity'
|
||||
import LogFood from './pages/LogFood'
|
||||
import Rewards from './pages/Rewards'
|
||||
import Settings from './pages/Settings'
|
||||
import Onboarding from './pages/Onboarding'
|
||||
import WeekView from './pages/WeekView'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
if (!hasToken()) return <Navigate to="/login" />
|
||||
return children
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
return (
|
||||
<nav className="nav-bar">
|
||||
<NavLink to="/" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">🏠</span> Home
|
||||
</NavLink>
|
||||
<NavLink to="/log" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">💪</span> Log
|
||||
</NavLink>
|
||||
<NavLink to="/food" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">🍽️</span> Food
|
||||
</NavLink>
|
||||
<NavLink to="/rewards" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">🎮</span> Rewards
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">⚙️</span> Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/log" element={<ProtectedRoute><LogActivity /></ProtectedRoute>} />
|
||||
<Route path="/food" element={<ProtectedRoute><LogFood /></ProtectedRoute>} />
|
||||
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
|
||||
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
{hasToken() && <NavBar />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
94
frontend/src/api.js
Normal file
94
frontend/src/api.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
const API_BASE = '/api';
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('fitness_token');
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const resp = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
if (resp.status === 401) {
|
||||
localStorage.removeItem('fitness_token');
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (username, password) =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
register: (username, password, display_name) =>
|
||||
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name }) }),
|
||||
me: () => request('/auth/me'),
|
||||
|
||||
// Onboarding
|
||||
onboardingStatus: () => request('/onboarding/status'),
|
||||
savePhase1: (data) => request('/onboarding/phase1', { method: 'POST', body: JSON.stringify(data) }),
|
||||
savePhase2: (data) => request('/onboarding/phase2', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getRecommendations: () => request('/onboarding/recommendations'),
|
||||
|
||||
// Points
|
||||
today: () => request('/points/today'),
|
||||
week: (start) => request(`/points/week${start ? `?start=${start}` : ''}`),
|
||||
getRules: () => request('/points/rules'),
|
||||
updateRule: (id, data) => request(`/points/rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
getTargets: () => request('/points/targets'),
|
||||
updateTargets: (data) => request('/points/targets', { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
|
||||
// Activities
|
||||
listActivities: (date) => request(`/activities${date ? `?date=${date}` : ''}`),
|
||||
logActivity: (data) => request('/activities', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteActivity: (id) => request(`/activities/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Food
|
||||
listFood: (date) => request(`/food${date ? `?date=${date}` : ''}`),
|
||||
logFood: (data) => request('/food', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteFood: (id) => request(`/food/${id}`, { method: 'DELETE' }),
|
||||
scanBarcode: (barcode) => request(`/food/scan/${barcode}`),
|
||||
searchFood: (q) => request(`/food/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// Rewards
|
||||
listRewards: () => request('/rewards'),
|
||||
createReward: (data) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
||||
redeemReward: (id, date) => request(`/rewards/${id}/redeem`, { method: 'POST', body: JSON.stringify({ date }) }),
|
||||
|
||||
// Programs
|
||||
listPrograms: () => request('/programs'),
|
||||
createProgram: (data) => request('/programs', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getProgram: (id) => request(`/programs/${id}`),
|
||||
|
||||
// Integrations
|
||||
integrationStatus: () => request('/integrations'),
|
||||
stravaAuth: () => request('/integrations/strava/auth'),
|
||||
stravaSync: () => request('/integrations/strava/sync', { method: 'POST' }),
|
||||
polarAuth: () => request('/integrations/polar/auth'),
|
||||
polarSync: () => request('/integrations/polar/sync', { method: 'POST' }),
|
||||
disconnect: (provider) => request(`/integrations/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// Resources
|
||||
getRecommendations: () => request('/onboarding/recommendations'),
|
||||
};
|
||||
|
||||
export function setToken(token) {
|
||||
localStorage.setItem('fitness_token', token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('fitness_token');
|
||||
}
|
||||
|
||||
export function hasToken() {
|
||||
return !!localStorage.getItem('fitness_token');
|
||||
}
|
||||
296
frontend/src/index.css
Normal file
296
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--bg-card: #1a1a2e;
|
||||
--bg-card-hover: #222240;
|
||||
--text: #e8e8f0;
|
||||
--text-muted: #8888a0;
|
||||
--accent: #6c63ff;
|
||||
--accent-hover: #7f78ff;
|
||||
--success: #4ade80;
|
||||
--danger: #f87171;
|
||||
--warning: #fbbf24;
|
||||
--border: #2a2a45;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100dvh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 20px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm { padding: 6px 14px; font-size: 0.85rem; }
|
||||
.btn-full { width: 100%; }
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: var(--border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.status-earned { color: var(--success); }
|
||||
.status-locked { color: var(--danger); }
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.checklist-item:last-child { border-bottom: none; }
|
||||
.checklist-check { font-size: 1.2rem; min-width: 28px; }
|
||||
.checklist-points {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 12px;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.nav-item.active, .nav-item:hover { color: var(--accent); }
|
||||
.nav-icon { font-size: 1.3rem; }
|
||||
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.option-btn {
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.option-btn:hover { border-color: var(--accent); }
|
||||
.option-btn.selected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
}
|
||||
|
||||
.chip-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.chip.selected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(108, 99, 255, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.reward-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.reward-card:last-child { border-bottom: none; }
|
||||
|
||||
.likert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
.likert-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.likert-btn.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.likert-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gate-banner {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.gate-earned {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
.gate-locked {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
.gate-pts {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.meal-section { margin-bottom: 20px; }
|
||||
.meal-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--danger);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
145
frontend/src/pages/Dashboard.jsx
Normal file
145
frontend/src/pages/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => { loadStatus() }, [])
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const data = await api.today()
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync(provider) {
|
||||
try {
|
||||
const fn = provider === 'strava' ? api.stravaSync : api.polarSync
|
||||
await fn()
|
||||
await loadStatus()
|
||||
} catch (err) {
|
||||
alert(`Sync failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||
if (!status) return <div className="page"><div className="error-msg">Failed to load</div></div>
|
||||
|
||||
const pct = status.daily_minimum > 0 ? Math.min(100, Math.round((status.points_earned / status.daily_minimum) * 100)) : 0
|
||||
const weekPct = status.weekly_target > 0 ? Math.min(100, Math.round((status.week_points / status.weekly_target) * 100)) : 0
|
||||
|
||||
// Build checklist from activities
|
||||
const categories = {
|
||||
workout: { label: 'Workout', icon: '💪', pts: 50 },
|
||||
food_log: { label: 'Food logged', icon: '🍽️', pts: 30 },
|
||||
steps_target: { label: 'Steps target', icon: '👟', pts: 20 },
|
||||
screen_free: { label: 'Screen-free time', icon: '📚', pts: '20/hr' },
|
||||
daily_checkin: { label: 'Daily check-in', icon: '✅', pts: 10 },
|
||||
}
|
||||
const doneCats = new Set(status.activities_today.map(a => a.category))
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Program progress */}
|
||||
{status.program_name && (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '14px 20px' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{status.program_name}
|
||||
</div>
|
||||
<div style={{ fontWeight: 700, margin: '4px 0' }}>
|
||||
Day {status.program_day} of {status.program_total_days}
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-bar-fill" style={{
|
||||
width: `${Math.round((status.program_day / status.program_total_days) * 100)}%`,
|
||||
background: 'var(--accent)'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gate banner */}
|
||||
<div className={`gate-banner ${status.gate_passed ? 'gate-earned' : 'gate-locked'}`}>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>TODAY</div>
|
||||
<div className="gate-pts">
|
||||
<span className={status.gate_passed ? 'status-earned' : 'status-locked'}>
|
||||
{status.points_earned}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '1.2rem' }}> / {status.daily_minimum} pts</span>
|
||||
</div>
|
||||
<div className="progress-bar" style={{ marginTop: '8px' }}>
|
||||
<div className="progress-bar-fill" style={{
|
||||
width: `${pct}%`,
|
||||
background: status.gate_passed ? 'var(--success)' : 'var(--danger)'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '0.9rem', fontWeight: 600 }}>
|
||||
{status.gate_passed ? '✅ REWARDS UNLOCKED' : `🔒 Earn ${status.points_remaining} more pts to unlock`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Week progress */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>This Week</span>
|
||||
<Link to="/week" style={{ fontSize: '0.85rem' }}>Details →</Link>
|
||||
</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: '6px' }}>
|
||||
{status.week_points} / {status.weekly_target} pts
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-bar-fill" style={{ width: `${weekPct}%`, background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity checklist */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '10px', fontSize: '0.95rem' }}>Today's Activities</div>
|
||||
{Object.entries(categories).map(([cat, info]) => (
|
||||
<div className="checklist-item" key={cat}>
|
||||
<span className="checklist-check">{doneCats.has(cat) ? '✅' : '⬜'}</span>
|
||||
<span>{info.icon} {info.label}</span>
|
||||
<span className="checklist-points">+{info.pts}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rewards (if gate passed) */}
|
||||
{status.gate_passed && status.rewards_available.length > 0 && (
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '10px', fontSize: '0.95rem' }}>🎮 Rewards Available</div>
|
||||
{status.rewards_available.map(r => (
|
||||
<div className="reward-card" key={r.id}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.point_cost} pts</div>
|
||||
</div>
|
||||
<button className="btn-success btn-sm" onClick={async () => {
|
||||
try {
|
||||
await api.redeemReward(r.id)
|
||||
await loadStatus()
|
||||
} catch (err) { alert(err.message) }
|
||||
}}>Redeem</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginTop: '8px' }}>
|
||||
<button className="btn-primary btn-full" onClick={() => navigate('/log')}>+ Log Activity</button>
|
||||
<button className="btn-outline btn-full" onClick={() => navigate('/food')}>📷 Log Food</button>
|
||||
<button className="btn-outline btn-full btn-sm" onClick={() => handleSync('strava')}>🔄 Sync Strava</button>
|
||||
<button className="btn-outline btn-full btn-sm" onClick={() => handleSync('polar')}>🔄 Sync Polar</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
frontend/src/pages/LogActivity.jsx
Normal file
94
frontend/src/pages/LogActivity.jsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session' },
|
||||
{ value: 'steps_target', label: 'Steps Target', icon: '👟', desc: 'Hit your daily step goal' },
|
||||
{ value: 'screen_free', label: 'Screen-Free', icon: '📚', desc: 'Reading, outdoor time, etc.' },
|
||||
{ value: 'daily_checkin', label: 'Daily Check-in', icon: '✅', desc: 'Just showing up counts' },
|
||||
]
|
||||
|
||||
export default function LogActivity() {
|
||||
const [category, setCategory] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [duration, setDuration] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!category) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const result = await api.logActivity({
|
||||
category,
|
||||
title: title || CATEGORIES.find(c => c.value === category)?.label,
|
||||
description: description || null,
|
||||
duration_minutes: duration ? parseInt(duration) : null,
|
||||
activity_date: today,
|
||||
})
|
||||
setSuccess(`+${result.points_earned} points earned!`)
|
||||
setCategory('')
|
||||
setTitle('')
|
||||
setDuration('')
|
||||
setDescription('')
|
||||
setTimeout(() => navigate('/'), 1500)
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-title">Log Activity</h1>
|
||||
|
||||
{success && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', background: 'rgba(74,222,128,0.1)', borderRadius: 'var(--radius)', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: '4px' }}>🎉</div>
|
||||
<div style={{ color: 'var(--success)', fontWeight: 700 }}>{success}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="form-label">What did you do?</div>
|
||||
<div className="option-grid">
|
||||
{CATEGORIES.map(c => (
|
||||
<div key={c.value} className={`option-btn ${category === c.value ? 'selected' : ''}`} onClick={() => setCategory(c.value)}>
|
||||
<div style={{ fontSize: '1.3rem', marginBottom: '4px' }}>{c.icon}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.85rem' }}>{c.label}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '2px' }}>{c.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{category && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Title (optional)</label>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Morning run, StrongLifts Day 12" />
|
||||
</div>
|
||||
{(category === 'workout' || category === 'screen_free') && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Duration (minutes)</label>
|
||||
<input type="number" value={duration} onChange={e => setDuration(e.target.value)} placeholder="30" />
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Notes (optional)</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} placeholder="How did it go?" />
|
||||
</div>
|
||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Log Activity'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
frontend/src/pages/LogFood.jsx
Normal file
211
frontend/src/pages/LogFood.jsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const MEALS = ['breakfast', 'lunch', 'dinner', 'snack']
|
||||
|
||||
export default function LogFood() {
|
||||
const [tab, setTab] = useState('log') // log, scan, search
|
||||
const [mealType, setMealType] = useState('lunch')
|
||||
const [foodName, setFoodName] = useState('')
|
||||
const [brand, setBrand] = useState('')
|
||||
const [calories, setCalories] = useState('')
|
||||
const [protein, setProtein] = useState('')
|
||||
const [carbs, setCarbs] = useState('')
|
||||
const [fat, setFat] = useState('')
|
||||
const [servings, setServings] = useState('1')
|
||||
const [barcode, setBarcode] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [todayFood, setTodayFood] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
useEffect(() => { loadToday() }, [])
|
||||
|
||||
async function loadToday() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const data = await api.listFood(today)
|
||||
setTodayFood(data)
|
||||
} catch (err) { console.error(err) }
|
||||
}
|
||||
|
||||
function fillFromProduct(product) {
|
||||
setFoodName(product.product_name || '')
|
||||
setBrand(product.brand || '')
|
||||
setCalories(product.calories_100g ? String(Math.round(product.calories_100g)) : '')
|
||||
setProtein(product.protein_100g ? String(Math.round(product.protein_100g)) : '')
|
||||
setCarbs(product.carbs_100g ? String(Math.round(product.carbs_100g)) : '')
|
||||
setFat(product.fat_100g ? String(Math.round(product.fat_100g)) : '')
|
||||
setTab('log')
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
if (!barcode.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const product = await api.scanBarcode(barcode.trim())
|
||||
fillFromProduct(product)
|
||||
} catch (err) { alert('Product not found. Try manual entry.') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.searchFood(searchQuery)
|
||||
setSearchResults(data.results || [])
|
||||
} catch (err) { alert(err.message) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleLog(e) {
|
||||
e.preventDefault()
|
||||
if (!foodName.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
await api.logFood({
|
||||
meal_type: mealType,
|
||||
food_name: foodName,
|
||||
brand: brand || null,
|
||||
calories: calories ? parseFloat(calories) * parseFloat(servings || 1) : null,
|
||||
protein_g: protein ? parseFloat(protein) * parseFloat(servings || 1) : null,
|
||||
carbs_g: carbs ? parseFloat(carbs) * parseFloat(servings || 1) : null,
|
||||
fat_g: fat ? parseFloat(fat) * parseFloat(servings || 1) : null,
|
||||
servings: parseFloat(servings || 1),
|
||||
food_date: today,
|
||||
})
|
||||
setSuccess('Food logged!')
|
||||
setFoodName(''); setBrand(''); setCalories(''); setProtein(''); setCarbs(''); setFat(''); setServings('1')
|
||||
await loadToday()
|
||||
setTimeout(() => setSuccess(''), 2000)
|
||||
} catch (err) { alert(err.message) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-title">Food Log</h1>
|
||||
|
||||
{success && <div style={{ padding: '10px', background: 'rgba(74,222,128,0.1)', borderRadius: 'var(--radius-sm)', color: 'var(--success)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
<button className={tab === 'log' ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setTab('log')}>Manual</button>
|
||||
<button className={tab === 'scan' ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setTab('scan')}>📷 Scan</button>
|
||||
<button className={tab === 'search' ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setTab('search')}>🔍 Search</button>
|
||||
</div>
|
||||
|
||||
{/* Barcode scan */}
|
||||
{tab === 'scan' && (
|
||||
<div className="card">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Barcode number</label>
|
||||
<input value={barcode} onChange={e => setBarcode(e.target.value)} placeholder="Enter or scan barcode" inputMode="numeric" />
|
||||
</div>
|
||||
<button className="btn-primary btn-full" onClick={handleScan} disabled={loading}>
|
||||
{loading ? 'Looking up...' : 'Lookup'}
|
||||
</button>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '8px', textAlign: 'center' }}>
|
||||
Powered by Open Food Facts (4M+ products)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Food search */}
|
||||
{tab === 'search' && (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search foods..." onKeyDown={e => e.key === 'Enter' && handleSearch()} />
|
||||
<button className="btn-primary btn-sm" onClick={handleSearch} disabled={loading}>Go</button>
|
||||
</div>
|
||||
{searchResults.map((r, i) => (
|
||||
<div key={i} style={{ padding: '10px 0', borderBottom: '1px solid var(--border)', cursor: 'pointer' }} onClick={() => fillFromProduct(r)}>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{r.product_name || 'Unknown'}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{r.brand && `${r.brand} · `}{r.calories_100g ? `${Math.round(r.calories_100g)} cal/100g` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual log form */}
|
||||
{tab === 'log' && (
|
||||
<form onSubmit={handleLog}>
|
||||
<div className="card">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Meal</label>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{MEALS.map(m => (
|
||||
<button key={m} type="button" className={mealType === m ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick={() => setMealType(m)}>
|
||||
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Food name</label>
|
||||
<input value={foodName} onChange={e => setFoodName(e.target.value)} placeholder="e.g. Grilled chicken breast" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Brand (optional)</label>
|
||||
<input value={brand} onChange={e => setBrand(e.target.value)} placeholder="" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Calories</label>
|
||||
<input type="number" value={calories} onChange={e => setCalories(e.target.value)} placeholder="per serving" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Servings</label>
|
||||
<input type="number" step="0.5" value={servings} onChange={e => setServings(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Protein (g)</label>
|
||||
<input type="number" value={protein} onChange={e => setProtein(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Carbs (g)</label>
|
||||
<input type="number" value={carbs} onChange={e => setCarbs(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fat (g)</label>
|
||||
<input type="number" value={fat} onChange={e => setFat(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Log Food'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Today's summary */}
|
||||
{todayFood && (
|
||||
<div className="card" style={{ marginTop: '8px' }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: '10px' }}>
|
||||
Today: {Math.round(todayFood.total_calories)} cal
|
||||
</div>
|
||||
{MEALS.map(m => {
|
||||
const items = todayFood.meals[m] || []
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div className="meal-section" key={m}>
|
||||
<div className="meal-title">{m}</div>
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: '0.9rem' }}>
|
||||
<span>{item.food_name}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{item.calories ? `${Math.round(item.calories)} cal` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/pages/Login.jsx
Normal file
87
frontend/src/pages/Login.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api, setToken } from '../api'
|
||||
|
||||
export default function Login() {
|
||||
const [mode, setMode] = useState('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
let data
|
||||
if (mode === 'login') {
|
||||
data = await api.login(username, password)
|
||||
} else {
|
||||
data = await api.register(username, password, displayName || username)
|
||||
}
|
||||
setToken(data.access_token)
|
||||
|
||||
// Check onboarding status
|
||||
const status = await api.onboardingStatus()
|
||||
if (!status.phase1_completed) {
|
||||
navigate('/onboarding')
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page" style={{ paddingTop: '80px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '40px' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: '8px' }}>🏆</div>
|
||||
<h1 style={{ fontSize: '1.8rem', fontWeight: 800 }}>Fitness Rewards</h1>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '8px' }}>Earn your rewards. Every day.</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||
<button
|
||||
className={mode === 'login' ? 'btn-primary btn-full' : 'btn-outline btn-full'}
|
||||
onClick={() => setMode('login')}
|
||||
type="button"
|
||||
>Sign In</button>
|
||||
<button
|
||||
className={mode === 'register' ? 'btn-primary btn-full' : 'btn-outline btn-full'}
|
||||
onClick={() => setMode('register')}
|
||||
type="button"
|
||||
>Sign Up</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Username</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} required autoComplete="username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
{mode === 'register' && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Display Name</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="What should we call you?" />
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||
{loading ? '...' : mode === 'login' ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
frontend/src/pages/Onboarding.jsx
Normal file
335
frontend/src/pages/Onboarding.jsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
const GOALS = [
|
||||
{ value: 'lose_weight', label: 'Lose Weight', icon: '⚖️' },
|
||||
{ value: 'build_strength', label: 'Build Strength', icon: '💪' },
|
||||
{ value: 'get_active', label: 'Get More Active', icon: '🏃' },
|
||||
{ value: 'feel_better', label: 'Feel Better Overall', icon: '😊' },
|
||||
]
|
||||
|
||||
const TTM_STAGES = [
|
||||
{ value: 'precontemplation', label: "I'm not exercising and haven't thought about starting" },
|
||||
{ value: 'contemplation', label: "I've been thinking about getting more active but haven't started" },
|
||||
{ value: 'preparation', label: 'I do some exercise but not consistently' },
|
||||
{ value: 'action', label: "I've been exercising regularly for a few months" },
|
||||
{ value: 'maintenance', label: "I've been exercising regularly for 6+ months" },
|
||||
]
|
||||
|
||||
const ACTIVITIES = [
|
||||
'Walking', 'Hiking', 'Running', 'Cycling', 'Swimming',
|
||||
'Bodyweight', 'Weights', 'Yoga', 'Martial Arts', 'Dance', 'Team Sports',
|
||||
]
|
||||
|
||||
const EQUIPMENT = [
|
||||
{ value: 'none', label: 'Nothing — bodyweight only' },
|
||||
{ value: 'basic_home', label: 'Basic home equipment' },
|
||||
{ value: 'full_gym', label: 'Full gym access' },
|
||||
]
|
||||
|
||||
export default function Onboarding() {
|
||||
const [step, setStep] = useState(0)
|
||||
const [goal, setGoal] = useState('')
|
||||
const [ttm, setTtm] = useState('')
|
||||
const [age, setAge] = useState('')
|
||||
const [heightCm, setHeightCm] = useState('')
|
||||
const [weightKg, setWeightKg] = useState('')
|
||||
const [gender, setGender] = useState('')
|
||||
const [parq, setParq] = useState({ heart: false, joints: false, meds: false })
|
||||
const [motivation, setMotivation] = useState({ ext: 0, intro: 0, ident: 0, intr: 0, amot: 0 })
|
||||
const [activities, setActivities] = useState([])
|
||||
const [equipment, setEquipment] = useState('none')
|
||||
const [daysPerWeek, setDaysPerWeek] = useState(3)
|
||||
const [minsPerSession, setMinsPerSession] = useState(30)
|
||||
const [rewards, setRewards] = useState([{ name: '', cost: 100 }])
|
||||
const [recommendations, setRecommendations] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const totalSteps = 8
|
||||
|
||||
function Likert({ label, value, onChange }) {
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '0.9rem', marginBottom: '6px' }}>{label}</div>
|
||||
<div className="likert-row">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button key={n} type="button" className={`likert-btn ${value === n ? 'selected' : ''}`} onClick={() => onChange(n)}>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="likert-labels"><span>Not at all</span><span>Very true</span></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handlePhase1() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.savePhase1({ primary_goal: goal, ttm_stage: ttm })
|
||||
setStep(2)
|
||||
} catch (err) { alert(err.message) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handlePhase2() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.savePhase2({
|
||||
age: age ? parseInt(age) : null,
|
||||
height_cm: heightCm ? parseFloat(heightCm) : null,
|
||||
weight_kg: weightKg ? parseFloat(weightKg) : null,
|
||||
gender: gender || null,
|
||||
parq_heart_condition: parq.heart,
|
||||
parq_joint_issues: parq.joints,
|
||||
parq_medications: parq.meds,
|
||||
motivation_external: motivation.ext || null,
|
||||
motivation_introjected: motivation.intro || null,
|
||||
motivation_identified: motivation.ident || null,
|
||||
motivation_intrinsic: motivation.intr || null,
|
||||
motivation_amotivation: motivation.amot || null,
|
||||
activity_preferences: activities.map(a => a.toLowerCase().replace(/ /g, '_')),
|
||||
equipment_access: equipment,
|
||||
days_per_week: daysPerWeek,
|
||||
minutes_per_session: minsPerSession,
|
||||
})
|
||||
|
||||
// Create rewards
|
||||
for (const r of rewards) {
|
||||
if (r.name.trim()) {
|
||||
await api.createReward({ name: r.name, point_cost: r.cost || 100 })
|
||||
}
|
||||
}
|
||||
|
||||
// Get recommendations
|
||||
const recs = await api.getRecommendations()
|
||||
setRecommendations(recs.recommendations || [])
|
||||
setStep(7)
|
||||
} catch (err) { alert(err.message) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleCommit(rec) {
|
||||
try {
|
||||
const today = new Date()
|
||||
const monday = new Date(today)
|
||||
monday.setDate(today.getDate() + ((8 - today.getDay()) % 7 || 7))
|
||||
const startDate = monday.toISOString().split('T')[0]
|
||||
|
||||
await api.createProgram({
|
||||
name: rec.name,
|
||||
source: rec.source,
|
||||
source_url: rec.url,
|
||||
start_date: startDate,
|
||||
duration_days: rec.duration_days || 90,
|
||||
})
|
||||
navigate('/')
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
function toggleActivity(a) {
|
||||
setActivities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
const progress = Math.round(((step + 1) / totalSteps) * 100)
|
||||
|
||||
return (
|
||||
<div className="page" style={{ paddingTop: '40px' }}>
|
||||
<div className="progress-bar" style={{ marginBottom: '24px' }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
{/* Step 0: Goal */}
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>What matters most to you?</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Pick one — you can change this anytime.</p>
|
||||
<div className="option-grid">
|
||||
{GOALS.map(g => (
|
||||
<div key={g.value} className={`option-btn ${goal === g.value ? 'selected' : ''}`} onClick={() => setGoal(g.value)}>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: '6px' }}>{g.icon}</div>
|
||||
{g.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-primary btn-full" style={{ marginTop: '20px' }} disabled={!goal} onClick={() => setStep(1)}>Continue</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: TTM Stage */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>Where are you now?</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Be honest — there's no wrong answer.</p>
|
||||
{TTM_STAGES.map(s => (
|
||||
<div key={s.value}
|
||||
className={`option-btn ${ttm === s.value ? 'selected' : ''}`}
|
||||
style={{ textAlign: 'left', marginBottom: '10px' }}
|
||||
onClick={() => setTtm(s.value)}
|
||||
>{s.label}</div>
|
||||
))}
|
||||
<button className="btn-primary btn-full" style={{ marginTop: '16px' }} disabled={!ttm || loading} onClick={handlePhase1}>
|
||||
{loading ? '...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Safety (PAR-Q+) */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>Quick health check</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>For your safety — this takes 10 seconds.</p>
|
||||
{[
|
||||
{ key: 'heart', label: 'I have a heart condition or high blood pressure' },
|
||||
{ key: 'joints', label: 'I have bone or joint problems that could worsen with exercise' },
|
||||
{ key: 'meds', label: 'I take prescription medications for a chronic condition' },
|
||||
].map(q => (
|
||||
<label key={q.key} style={{ display: 'flex', gap: '12px', padding: '12px 0', cursor: 'pointer', borderBottom: '1px solid var(--border)' }}>
|
||||
<input type="checkbox" checked={parq[q.key]} onChange={e => setParq({ ...parq, [q.key]: e.target.checked })} style={{ width: 'auto' }} />
|
||||
<span style={{ fontSize: '0.9rem' }}>{q.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{(parq.heart || parq.joints || parq.meds) && (
|
||||
<div style={{ marginTop: '12px', padding: '12px', background: 'rgba(251, 191, 36, 0.1)', borderRadius: 'var(--radius-sm)', fontSize: '0.85rem', color: 'var(--warning)' }}>
|
||||
⚠️ We recommend checking with your doctor before starting. This won't stop you — just be mindful.
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-primary btn-full" style={{ marginTop: '20px' }} onClick={() => setStep(3)}>Continue</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Body Metrics */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>About you</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Optional — helps estimate nutrition needs.</p>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Age</label>
|
||||
<input type="number" value={age} onChange={e => setAge(e.target.value)} placeholder="e.g. 35" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Height (cm)</label>
|
||||
<input type="number" value={heightCm} onChange={e => setHeightCm(e.target.value)} placeholder="175" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Weight (kg)</label>
|
||||
<input type="number" value={weightKg} onChange={e => setWeightKg(e.target.value)} placeholder="80" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Gender</label>
|
||||
<select value={gender} onChange={e => setGender(e.target.value)}>
|
||||
<option value="">Prefer not to say</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button className="btn-outline btn-full" onClick={() => setStep(4)}>Skip</button>
|
||||
<button className="btn-primary btn-full" onClick={() => setStep(4)}>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Motivation (BREQ-2) */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>What drives you?</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Rate each honestly — helps us calibrate your experience.</p>
|
||||
<Likert label='"People important to me say I should exercise"' value={motivation.ext} onChange={v => setMotivation({ ...motivation, ext: v })} />
|
||||
<Likert label='"I feel bad about myself when I skip exercise"' value={motivation.intro} onChange={v => setMotivation({ ...motivation, intro: v })} />
|
||||
<Likert label='"I value what exercise does for my health"' value={motivation.ident} onChange={v => setMotivation({ ...motivation, ident: v })} />
|
||||
<Likert label='"I find exercise enjoyable and satisfying"' value={motivation.intr} onChange={v => setMotivation({ ...motivation, intr: v })} />
|
||||
<Likert label={'"I don\'t really see why I should bother"'} value={motivation.amot} onChange={v => setMotivation({ ...motivation, amot: v })} />
|
||||
<button className="btn-primary btn-full" onClick={() => setStep(5)}>Continue</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Activities + Equipment */}
|
||||
{step === 5 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>What sounds fun?</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '16px', fontSize: '0.9rem' }}>Pick all that interest you.</p>
|
||||
<div className="chip-grid" style={{ marginBottom: '24px' }}>
|
||||
{ACTIVITIES.map(a => (
|
||||
<div key={a} className={`chip ${activities.includes(a) ? 'selected' : ''}`} onClick={() => toggleActivity(a)}>{a}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Equipment access</label>
|
||||
{EQUIPMENT.map(e => (
|
||||
<div key={e.value} className={`option-btn ${equipment === e.value ? 'selected' : ''}`} style={{ textAlign: 'left', marginBottom: '8px' }} onClick={() => setEquipment(e.value)}>
|
||||
{e.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Days/week</label>
|
||||
<select value={daysPerWeek} onChange={e => setDaysPerWeek(parseInt(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5, 6, 7].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Minutes/session</label>
|
||||
<select value={minsPerSession} onChange={e => setMinsPerSession(parseInt(e.target.value))}>
|
||||
{[15, 20, 30, 45, 60, 90].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary btn-full" onClick={() => setStep(6)}>Continue</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 6: Define Rewards */}
|
||||
{step === 6 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>Define your rewards</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>What guilty pleasures do you want to earn?</p>
|
||||
{rewards.map((r, i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '10px', marginBottom: '12px' }}>
|
||||
<input placeholder="e.g. 1hr gaming" value={r.name} onChange={e => {
|
||||
const next = [...rewards]; next[i] = { ...r, name: e.target.value }; setRewards(next)
|
||||
}} />
|
||||
<input type="number" placeholder="100" value={r.cost} onChange={e => {
|
||||
const next = [...rewards]; next[i] = { ...r, cost: parseInt(e.target.value) || 0 }; setRewards(next)
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-outline btn-sm" style={{ marginBottom: '20px' }} onClick={() => setRewards([...rewards, { name: '', cost: 100 }])}>
|
||||
+ Add another reward
|
||||
</button>
|
||||
<button className="btn-primary btn-full" disabled={loading} onClick={handlePhase2}>
|
||||
{loading ? 'Saving...' : 'See Recommendations'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 7: Recommendations + Commit */}
|
||||
{step === 7 && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '8px' }}>Recommended for you</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '20px', fontSize: '0.9rem' }}>Pick a program and commit to 90 days.</p>
|
||||
{recommendations.map(rec => (
|
||||
<div className="card" key={rec.id}>
|
||||
<div style={{ fontWeight: 700, marginBottom: '4px' }}>{rec.name}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '8px' }}>
|
||||
{rec.difficulty} • {rec.duration_days ? `${rec.duration_days} days` : 'Ongoing'} • {rec.source}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.9rem', marginBottom: '12px' }}>{rec.description}</p>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<a href={rec.url} target="_blank" rel="noopener noreferrer" className="btn-outline btn-sm" style={{ display: 'inline-block' }}>View ↗</a>
|
||||
<button className="btn-primary btn-sm" onClick={() => handleCommit(rec)}>Select & Commit</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-outline btn-full" style={{ marginTop: '8px' }} onClick={() => navigate('/')}>Skip for now</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
frontend/src/pages/Rewards.jsx
Normal file
90
frontend/src/pages/Rewards.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function Rewards() {
|
||||
const [rewards, setRewards] = useState([])
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newCost, setNewCost] = useState('100')
|
||||
const [status, setStatus] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [r, s] = await Promise.all([api.listRewards(), api.today()])
|
||||
setRewards(r)
|
||||
setStatus(s)
|
||||
} catch (err) { console.error(err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
if (!newName.trim()) return
|
||||
try {
|
||||
await api.createReward({ name: newName, point_cost: parseInt(newCost) || 100 })
|
||||
setNewName(''); setNewCost('100')
|
||||
await load()
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
async function handleRedeem(id) {
|
||||
try {
|
||||
await api.redeemReward(id)
|
||||
await load()
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-title">🎮 Rewards</h1>
|
||||
|
||||
{/* Status */}
|
||||
{status && (
|
||||
<div className={`gate-banner ${status.gate_passed ? 'gate-earned' : 'gate-locked'}`} style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
{status.gate_passed ? '✅ Rewards Unlocked' : `🔒 Earn ${status.points_remaining} more pts`}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{status.points_earned} / {status.daily_minimum} pts today
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reward list */}
|
||||
<div className="card">
|
||||
{rewards.length === 0 && <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '16px' }}>No rewards yet. Add one below!</div>}
|
||||
{rewards.map(r => (
|
||||
<div className="reward-card" key={r.id}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.point_cost} pts</div>
|
||||
</div>
|
||||
<button
|
||||
className={status?.gate_passed ? 'btn-success btn-sm' : 'btn-outline btn-sm'}
|
||||
disabled={!status?.gate_passed}
|
||||
onClick={() => handleRedeem(r.id)}
|
||||
>
|
||||
{status?.gate_passed ? 'Redeem' : 'Locked'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add new reward */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '12px', fontSize: '0.9rem' }}>Add New Reward</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '10px', marginBottom: '10px' }}>
|
||||
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. 1hr gaming" required />
|
||||
<input type="number" value={newCost} onChange={e => setNewCost(e.target.value)} placeholder="pts" />
|
||||
</div>
|
||||
<button type="submit" className="btn-primary btn-full btn-sm">Add Reward</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
frontend/src/pages/Settings.jsx
Normal file
137
frontend/src/pages/Settings.jsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api, clearToken } from '../api'
|
||||
|
||||
export default function Settings() {
|
||||
const [user, setUser] = useState(null)
|
||||
const [rules, setRules] = useState([])
|
||||
const [targets, setTargets] = useState(null)
|
||||
const [integrations, setIntegrations] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [u, r, t, intg] = await Promise.all([
|
||||
api.me(), api.getRules(), api.getTargets(), api.integrationStatus(),
|
||||
])
|
||||
setUser(u); setRules(r); setTargets(t); setIntegrations(intg)
|
||||
} catch (err) { console.error(err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function updateRule(id, points) {
|
||||
try {
|
||||
await api.updateRule(id, { points: parseInt(points) })
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
async function updateTargets(field, value) {
|
||||
const update = { [field]: parseInt(value) }
|
||||
try {
|
||||
await api.updateTargets(update)
|
||||
setTargets({ ...targets, ...update })
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
async function connectProvider(provider) {
|
||||
try {
|
||||
const fn = provider === 'strava' ? api.stravaAuth : api.polarAuth
|
||||
const data = await fn()
|
||||
window.location.href = data.auth_url
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
async function disconnectProvider(provider) {
|
||||
if (!confirm(`Disconnect ${provider}?`)) return
|
||||
try {
|
||||
await api.disconnect(provider)
|
||||
await load()
|
||||
} catch (err) { alert(err.message) }
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clearToken()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-title">⚙️ Settings</h1>
|
||||
|
||||
{/* User */}
|
||||
{user && (
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '4px' }}>{user.display_name}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>@{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Point Rules */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Point Rules</div>
|
||||
{rules.map(r => (
|
||||
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1, fontSize: '0.9rem' }}>{r.description}</div>
|
||||
<input
|
||||
type="number"
|
||||
style={{ width: '70px', textAlign: 'center' }}
|
||||
defaultValue={r.points}
|
||||
onBlur={e => updateRule(r.id, e.target.value)}
|
||||
/>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>pts</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Daily Targets */}
|
||||
{targets && (
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Daily Targets</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Daily minimum (pts to unlock rewards)</label>
|
||||
<input type="number" defaultValue={targets.daily_minimum_pts} onBlur={e => updateTargets('daily_minimum_pts', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Weekly target</label>
|
||||
<input type="number" defaultValue={targets.weekly_target_pts} onBlur={e => updateTargets('weekly_target_pts', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Weekly bonus (for hitting target)</label>
|
||||
<input type="number" defaultValue={targets.weekly_bonus_pts} onBlur={e => updateTargets('weekly_bonus_pts', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integrations */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Integrations</div>
|
||||
{['strava', 'polar'].map(provider => {
|
||||
const info = integrations?.[provider] || { connected: false }
|
||||
return (
|
||||
<div key={provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, textTransform: 'capitalize' }}>{provider}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: info.connected ? 'var(--success)' : 'var(--text-muted)' }}>
|
||||
{info.connected ? 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
{info.connected ? (
|
||||
<button className="btn-outline btn-sm" onClick={() => disconnectProvider(provider)}>Disconnect</button>
|
||||
) : (
|
||||
<button className="btn-primary btn-sm" onClick={() => connectProvider(provider)}>Connect</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-danger btn-full" style={{ marginTop: '16px' }} onClick={handleLogout}>Sign Out</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/pages/WeekView.jsx
Normal file
77
frontend/src/pages/WeekView.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function WeekView() {
|
||||
const [week, setWeek] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.week()
|
||||
setWeek(data)
|
||||
} catch (err) { console.error(err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||
if (!week) return <div className="page"><div className="error-msg">Failed to load</div></div>
|
||||
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const pct = week.weekly_target > 0 ? Math.min(100, Math.round((week.total_points_earned / week.weekly_target) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-title">📊 This Week</h1>
|
||||
|
||||
{/* Week summary */}
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 800 }}>{week.total_points_earned}</div>
|
||||
<div style={{ color: 'var(--text-muted)', marginBottom: '8px' }}>/ {week.weekly_target} pts</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-bar-fill" style={{ width: `${pct}%`, background: week.hit_weekly_target ? 'var(--success)' : 'var(--accent)' }} />
|
||||
</div>
|
||||
{week.hit_weekly_target && (
|
||||
<div style={{ marginTop: '8px', color: 'var(--success)', fontWeight: 600 }}>
|
||||
🏆 Weekly target hit! +{week.weekly_bonus_earned} bonus pts
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginTop: '12px', fontSize: '0.85rem' }}>
|
||||
<div><span style={{ fontWeight: 700 }}>{week.active_days}</span> <span style={{ color: 'var(--text-muted)' }}>active days</span></div>
|
||||
<div><span style={{ fontWeight: 700 }}>{week.gate_passed_days}</span> <span style={{ color: 'var(--text-muted)' }}>rewards earned</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily breakdown */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 700, marginBottom: '12px' }}>Daily Breakdown</div>
|
||||
{week.daily_breakdown.map((day, i) => {
|
||||
const dayPct = day.daily_minimum > 0 ? Math.min(100, Math.round((day.points_earned / day.daily_minimum) * 100)) : 0
|
||||
const isToday = day.date === new Date().toISOString().split('T')[0]
|
||||
return (
|
||||
<div key={day.date} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 0', borderBottom: i < 6 ? '1px solid var(--border)' : 'none' }}>
|
||||
<div style={{ width: '40px', fontWeight: isToday ? 700 : 400, color: isToday ? 'var(--accent)' : 'var(--text)' }}>
|
||||
{dayNames[i]}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="progress-bar" style={{ height: '6px' }}>
|
||||
<div className="progress-bar-fill" style={{
|
||||
width: `${dayPct}%`,
|
||||
background: day.gate_passed ? 'var(--success)' : day.points_earned > 0 ? 'var(--warning)' : 'var(--border)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '50px', textAlign: 'right', fontSize: '0.85rem', fontWeight: 600 }}>
|
||||
{day.points_earned}
|
||||
</div>
|
||||
<div style={{ width: '24px', textAlign: 'center' }}>
|
||||
{day.gate_passed ? '✅' : day.points_earned > 0 ? '🟡' : '⬜'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue