Initial commit: Gravl MVP med onboarding

This commit is contained in:
2026-01-31 23:33:20 +01:00
commit 032cca851d
3461 changed files with 634124 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app.loading {
justify-content: center;
align-items: center;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Header */
.header {
background: var(--bg-secondary);
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.week-selector {
display: flex;
align-items: center;
gap: 0.75rem;
}
.week-selector button {
background: var(--bg-card);
color: var(--text-primary);
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 1.1rem;
transition: all 0.2s;
}
.week-selector button:hover:not(:disabled) {
background: var(--accent);
}
.week-selector button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.week-selector span {
font-weight: 600;
min-width: 80px;
text-align: center;
}
/* Main */
.main {
flex: 1;
padding: 1rem;
max-width: 600px;
margin: 0 auto;
width: 100%;
}
/* Program Info */
.program-info {
margin-bottom: 1.5rem;
}
.program-info h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: var(--accent);
}
.program-info p {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
/* Days List */
.days-list h3 {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.day-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.day-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
}
.day-card:active {
transform: scale(0.98);
}
.day-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.day-number {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.day-name {
font-size: 1.1rem;
font-weight: 600;
}
.day-exercises {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.exercise-tag {
background: var(--bg-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
}
.exercise-tag.more {
background: var(--accent);
color: white;
}
.day-action {
text-align: right;
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
}
/* Workout View */
.workout-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.back-btn {
background: none;
color: var(--accent);
font-size: 0.9rem;
padding: 0.25rem 0;
}
.header-title h1 {
font-size: 1.25rem;
}
.header-subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Exercise Card */
.exercise-card {
background: var(--bg-card);
border-radius: 12px;
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid transparent;
transition: all 0.2s;
}
.exercise-card.expanded {
border-color: var(--accent);
}
.exercise-header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.exercise-info h3 {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.muscle-group {
font-size: 0.8rem;
color: var(--text-secondary);
}
.exercise-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.sets-info {
font-size: 0.9rem;
color: var(--text-secondary);
}
.progress-badge {
background: var(--bg-secondary);
padding: 0.2rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
}
.progress-badge.complete {
background: var(--success);
color: var(--bg-primary);
}
/* Exercise Body */
.exercise-body {
padding: 0 1rem 1rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.progression-hint {
background: rgba(233, 69, 96, 0.1);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.progression-hint strong {
color: var(--accent);
}
/* Sets List */
.sets-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.set-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
transition: all 0.2s;
}
.set-row.completed {
background: rgba(78, 204, 163, 0.15);
border: 1px solid var(--success);
}
.set-number {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 45px;
}
.set-inputs {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.weight-input,
.reps-input {
width: 70px;
padding: 0.6rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
text-align: center;
}
.weight-input:focus,
.reps-input:focus {
border-color: var(--accent);
}
.input-separator {
color: var(--text-secondary);
}
.complete-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 1.25rem;
transition: all 0.2s;
}
.complete-btn:hover {
background: var(--accent);
color: white;
}
.complete-btn.done {
background: var(--success);
color: var(--bg-primary);
}
/* Mobile optimizations */
@media (max-width: 480px) {
.header {
padding: 0.75rem 1rem;
}
.header h1 {
font-size: 1.25rem;
}
.main {
padding: 0.75rem;
}
.weight-input,
.reps-input {
width: 60px;
padding: 0.5rem;
}
}
/* Safe area for notched phones */
@supports (padding: env(safe-area-inset-bottom)) {
.main {
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
}
+330
View File
@@ -0,0 +1,330 @@
import { useState, useEffect } from 'react'
import { useAuth } from './context/AuthContext'
import './App.css'
const API_URL = '/api'
function App() {
const { user, logout } = useAuth()
const [view, setView] = useState('program')
const [program, setProgram] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [currentWeek, setCurrentWeek] = useState(1)
const [logs, setLogs] = useState({})
const [loading, setLoading] = useState(true)
const userId = user?.id || 1
const today = new Date().toISOString().split('T')[0]
useEffect(() => {
fetchProgram()
}, [])
const fetchProgram = async () => {
try {
const res = await fetch(`${API_URL}/programs/1`)
const data = await res.json()
setProgram(data)
setLoading(false)
} catch (err) {
console.error('Failed to fetch program:', err)
setLoading(false)
}
}
const fetchLogs = async (dayId) => {
try {
const day = program.days.find(d => d.id === dayId)
if (!day) return
const newLogs = {}
for (const exercise of day.exercises) {
if (!exercise.id) continue
const res = await fetch(`${API_URL}/logs?user_id=${userId}&date=${today}&program_exercise_id=${exercise.id}`)
const data = await res.json()
newLogs[exercise.id] = data
}
setLogs(newLogs)
} catch (err) {
console.error('Failed to fetch logs:', err)
}
}
const fetchProgression = async (programExerciseId) => {
try {
const res = await fetch(`${API_URL}/progression/${programExerciseId}?user_id=${userId}`)
return await res.json()
} catch (err) {
console.error('Failed to fetch progression:', err)
return null
}
}
const logSet = async (programExerciseId, setNumber, weight, reps, completed) => {
try {
const res = await fetch(`${API_URL}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
program_exercise_id: programExerciseId,
date: today,
set_number: setNumber,
weight: parseFloat(weight) || 0,
reps: parseInt(reps) || 0,
completed
})
})
const data = await res.json()
// Update local logs
setLogs(prev => ({
...prev,
[programExerciseId]: [
...(prev[programExerciseId] || []).filter(l => l.set_number !== setNumber),
data
].sort((a, b) => a.set_number - b.set_number)
}))
} catch (err) {
console.error('Failed to log set:', err)
}
}
const startWorkout = (day) => {
setSelectedDay(day)
setView('workout')
fetchLogs(day.id)
}
if (loading) {
return (
<div className="app loading">
<div className="spinner"></div>
<p>Laddar program...</p>
</div>
)
}
if (view === 'workout' && selectedDay) {
return (
<WorkoutView
day={selectedDay}
week={currentWeek}
logs={logs}
onLogSet={logSet}
onBack={() => setView('program')}
fetchProgression={fetchProgression}
/>
)
}
return (
<div className="app">
<header className="header">
<div className="header-left">
<h1>🏋 Gravl</h1>
<button className="logout-btn" onClick={logout}>Logga ut</button>
</div>
<div className="week-selector">
<button
onClick={() => setCurrentWeek(w => Math.max(1, w - 1))}
disabled={currentWeek === 1}
>
</button>
<span>Vecka {currentWeek}</span>
<button
onClick={() => setCurrentWeek(w => Math.min(program?.weeks || 6, w + 1))}
disabled={currentWeek === (program?.weeks || 6)}
>
</button>
</div>
</header>
<main className="main">
<section className="program-info">
<h2>{program?.name || 'Laddar...'}</h2>
<p>{program?.description}</p>
</section>
<section className="days-list">
<h3>Veckans pass</h3>
{program?.days?.map((day, idx) => (
<div key={day.id} className="day-card" onClick={() => startWorkout(day)}>
<div className="day-header">
<span className="day-number">Dag {day.day_number}</span>
<span className="day-name">{day.name}</span>
</div>
<div className="day-exercises">
{day.exercises?.filter(e => e.name).slice(0, 3).map((ex, i) => (
<span key={i} className="exercise-tag">{ex.name}</span>
))}
{day.exercises?.filter(e => e.name).length > 3 && (
<span className="exercise-tag more">+{day.exercises.filter(e => e.name).length - 3}</span>
)}
</div>
<div className="day-action">
Starta
</div>
</div>
))}
</section>
</main>
</div>
)
}
function WorkoutView({ day, week, logs, onLogSet, onBack, fetchProgression }) {
const [progressions, setProgressions] = useState({})
const [expandedExercise, setExpandedExercise] = useState(null)
useEffect(() => {
loadProgressions()
}, [day])
const loadProgressions = async () => {
const progs = {}
for (const exercise of day.exercises) {
if (exercise.id) {
progs[exercise.id] = await fetchProgression(exercise.id)
}
}
setProgressions(progs)
}
const exercises = day.exercises?.filter(e => e.name) || []
return (
<div className="app workout-view">
<header className="header workout-header">
<button className="back-btn" onClick={onBack}> Tillbaka</button>
<div className="header-title">
<h1>{day.name}</h1>
<span className="header-subtitle">Vecka {week} Dag {day.day_number}</span>
</div>
</header>
<main className="main workout-main">
{exercises.map((exercise, idx) => (
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
/>
))}
</main>
</div>
)
}
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet }) {
const [setInputs, setSetInputs] = useState({})
useEffect(() => {
// Initialize with suggested weight or last logged
const initial = {}
for (let i = 1; i <= exercise.sets; i++) {
const existingLog = logs.find(l => l.set_number === i)
initial[i] = {
weight: existingLog?.weight || progression?.suggestedWeight || '',
reps: existingLog?.reps || '',
completed: existingLog?.completed || false
}
}
setSetInputs(initial)
}, [exercise, logs, progression])
const handleInputChange = (setNum, field, value) => {
setSetInputs(prev => ({
...prev,
[setNum]: { ...prev[setNum], [field]: value }
}))
}
const handleComplete = (setNum) => {
const input = setInputs[setNum]
const newCompleted = !input.completed
setSetInputs(prev => ({
...prev,
[setNum]: { ...prev[setNum], completed: newCompleted }
}))
onLogSet(exercise.id, setNum, input.weight, input.reps, newCompleted)
}
const completedSets = Object.values(setInputs).filter(s => s.completed).length
return (
<div className={`exercise-card ${expanded ? 'expanded' : ''}`}>
<div className="exercise-header" onClick={onToggle}>
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
</div>
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === exercise.sets ? 'complete' : ''}`}>
{completedSets}/{exercise.sets}
</span>
</div>
</div>
{expanded && (
<div className="exercise-body">
{progression && (
<div className="progression-hint">
💡 {progression.reason}
{progression.suggestedWeight && (
<strong> {progression.suggestedWeight} kg</strong>
)}
</div>
)}
<div className="sets-list">
{Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => {
const input = setInputs[setNum] || { weight: '', reps: '', completed: false }
return (
<div key={setNum} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {setNum}</span>
<div className="set-inputs">
<input
type="number"
placeholder="kg"
value={input.weight}
onChange={(e) => handleInputChange(setNum, 'weight', e.target.value)}
className="weight-input"
inputMode="decimal"
/>
<span className="input-separator">×</span>
<input
type="number"
placeholder="reps"
value={input.reps}
onChange={(e) => handleInputChange(setNum, 'reps', e.target.value)}
className="reps-input"
inputMode="numeric"
/>
</div>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(setNum)}
>
{input.completed ? '✓' : '○'}
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export default App
+74
View File
@@ -0,0 +1,74 @@
import { createContext, useContext, useState, useEffect } from 'react';
const API = '/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
if (token) fetchProfile();
else setLoading(false);
}, [token]);
const fetchProfile = async () => {
try {
const res = await fetch(`${API}/user/profile`, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) setUser(await res.json());
else logout();
} catch { logout(); }
setLoading(false);
};
const register = async (email, password) => {
const res = await fetch(`${API}/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
return data;
};
const login = async (email, password) => {
const res = await fetch(`${API}/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
return data;
};
const updateProfile = async (profile) => {
const res = await fetch(`${API}/user/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(profile)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setUser(data);
return data;
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, loading, register, login, logout, updateProfile }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
+109
View File
@@ -0,0 +1,109 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-card-hover: #1f3460;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--accent: #e94560;
--accent-hover: #ff6b6b;
--success: #4ecca3;
--warning: #ffd93d;
--border: #2a2a4a;
}
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
outline: none;
}
input {
font-family: inherit;
outline: none;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Auth pages */
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.auth-card { background: var(--bg-card); padding: 40px; border-radius: 16px; width: 100%; max-width: 400px; text-align: center; }
.auth-card h1 { font-size: 2.5rem; margin-bottom: 8px; }
.auth-card h2 { color: var(--text-secondary); font-weight: 400; margin-bottom: 24px; }
.auth-card form { display: flex; flex-direction: column; gap: 16px; }
.auth-card input { padding: 14px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.auth-card input:focus { border-color: var(--accent); }
.auth-card button[type="submit"] { padding: 14px; background: var(--accent); color: white; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: background 0.2s; }
.auth-card button[type="submit"]:hover:not(:disabled) { background: var(--accent-hover); }
.auth-card button:disabled { opacity: 0.6; cursor: not-allowed; }
.auth-card .error { background: rgba(233,69,96,0.15); color: var(--accent); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
.auth-link { margin-top: 20px; color: var(--text-secondary); }
.auth-link a { color: var(--accent); text-decoration: none; }
/* Onboarding */
.onboarding { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.onboarding-card { background: var(--bg-card); padding: 32px; border-radius: 16px; width: 100%; max-width: 480px; }
.steps-indicator { display: flex; justify-content: center; gap: 12px; margin-bottom: 28px; }
.steps-indicator span { width: 32px; height: 32px; border-radius: 50%; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; color: var(--text-secondary); }
.steps-indicator span.active { background: var(--accent); color: white; }
.step h2 { margin-bottom: 20px; text-align: center; }
.step .hint { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 16px; text-align: center; }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.875rem; }
.field input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 1rem; }
.field input:focus { border-color: var(--accent); }
.btn-group { display: flex; gap: 8px; }
.btn-group.vertical { flex-direction: column; }
.btn-group button { flex: 1; padding: 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); transition: all 0.2s; }
.btn-group button:hover { border-color: var(--accent); }
.btn-group button.active { background: var(--accent); color: white; border-color: var(--accent); }
.rm-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 8px; }
.rm-fields .field { margin-bottom: 0; }
.bodyfat-result { background: rgba(78,204,163,0.15); color: var(--success); padding: 16px; border-radius: 8px; text-align: center; margin: 16px 0; }
.nav-btns { display: flex; gap: 12px; margin-top: 24px; }
.nav-btns button { flex: 1; padding: 14px; border-radius: 8px; font-size: 1rem; }
.nav-btns button:first-child { background: var(--bg-secondary); color: var(--text-secondary); }
.next-btn, .finish-btn { background: var(--accent) !important; color: white !important; font-weight: 600; }
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) { background: var(--accent-hover) !important; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Header logout */
.header-left { display: flex; align-items: center; gap: 16px; }
.logout-btn { padding: 6px 12px; background: var(--bg-secondary); color: var(--text-secondary); border-radius: 6px; font-size: 0.75rem; }
.logout-btn:hover { background: var(--border); }
+40
View File
@@ -0,0 +1,40 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import App from './App.jsx'
import RegisterPage from './pages/RegisterPage'
import LoginPage from './pages/LoginPage'
import OnboardingWizard from './pages/OnboardingWizard'
import './index.css'
function ProtectedRoute({ children, requireOnboarding = true }) {
const { user, loading } = useAuth();
if (loading) return <div className="app loading"><div className="spinner"></div></div>;
if (!user) return <Navigate to="/login" />;
if (requireOnboarding && !user.onboarding_complete) return <Navigate to="/onboarding" />;
return children;
}
function AuthRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <div className="app loading"><div className="spinner"></div></div>;
if (user?.onboarding_complete) return <Navigate to="/" />;
if (user) return <Navigate to="/onboarding" />;
return children;
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/register" element={<AuthRoute><RegisterPage /></AuthRoute>} />
<Route path="/login" element={<AuthRoute><LoginPage /></AuthRoute>} />
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><OnboardingWizard /></ProtectedRoute>} />
<Route path="/*" element={<ProtectedRoute><App /></ProtectedRoute>} />
</Routes>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { user } = await login(email, password);
navigate(user.onboarding_complete ? '/' : '/onboarding');
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>🏋 Gravl</h1>
<h2>Logga in</h2>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
<button type="submit" disabled={loading}>{loading ? 'Loggar in...' : 'Logga in'}</button>
</form>
<p className="auth-link">Inget konto? <Link to="/register">Skapa konto</Link></p>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const calcBodyFat = (gender, waist, neck, hip, height) => {
if (!waist || !neck || !height) return null;
if (gender === 'female' && !hip) return null;
if (gender === 'male') return Math.max(0, 495 / (1.0324 - 0.19077 * Math.log10(waist - neck) + 0.15456 * Math.log10(height)) - 450).toFixed(1);
return Math.max(0, 495 / (1.29579 - 0.35004 * Math.log10(waist + hip - neck) + 0.22100 * Math.log10(height)) - 450).toFixed(1);
};
export default function OnboardingWizard() {
const [step, setStep] = useState(1);
const [data, setData] = useState({ gender: '', age: '', height_cm: '', weight: '', neck_cm: '', waist_cm: '', hip_cm: '', experience_level: '', bench_1rm: '', squat_1rm: '', deadlift_1rm: '', goal: '', workouts_per_week: '' });
const [saving, setSaving] = useState(false);
const { updateProfile } = useAuth();
const navigate = useNavigate();
const update = (field, value) => setData(d => ({ ...d, [field]: value }));
const bodyFat = calcBodyFat(data.gender, parseFloat(data.waist_cm), parseFloat(data.neck_cm), parseFloat(data.hip_cm), parseFloat(data.height_cm));
const finish = async () => {
setSaving(true);
await updateProfile({ ...data, onboarding_complete: true });
navigate('/');
};
return (
<div className="onboarding">
<div className="onboarding-card">
<div className="steps-indicator">
{[1,2,3,4].map(s => <span key={s} className={step >= s ? 'active' : ''}>{s}</span>)}
</div>
{step === 1 && (
<div className="step">
<h2>Grundinfo</h2>
<div className="field">
<label>Kön</label>
<div className="btn-group">
<button className={data.gender === 'male' ? 'active' : ''} onClick={() => update('gender', 'male')}>Man</button>
<button className={data.gender === 'female' ? 'active' : ''} onClick={() => update('gender', 'female')}>Kvinna</button>
</div>
</div>
<div className="field">
<label>Ålder</label>
<input type="number" value={data.age} onChange={e => update('age', e.target.value)} placeholder="25" />
</div>
<div className="field">
<label>Längd (cm)</label>
<input type="number" value={data.height_cm} onChange={e => update('height_cm', e.target.value)} placeholder="175" />
</div>
<div className="field">
<label>Vikt (kg)</label>
<input type="number" step="0.1" value={data.weight} onChange={e => update('weight', e.target.value)} placeholder="75" />
</div>
<button className="next-btn" onClick={() => setStep(2)} disabled={!data.gender || !data.age || !data.height_cm || !data.weight}>Nästa </button>
</div>
)}
{step === 2 && (
<div className="step">
<h2>Kroppsmått</h2>
<p className="hint">För att beräkna kroppsfett (US Navy-metoden)</p>
<div className="field">
<label>Hals (cm)</label>
<input type="number" step="0.1" value={data.neck_cm} onChange={e => update('neck_cm', e.target.value)} placeholder="38" />
</div>
<div className="field">
<label>Mage/midja (cm)</label>
<input type="number" step="0.1" value={data.waist_cm} onChange={e => update('waist_cm', e.target.value)} placeholder="85" />
</div>
{data.gender === 'female' && (
<div className="field">
<label>Höft (cm)</label>
<input type="number" step="0.1" value={data.hip_cm} onChange={e => update('hip_cm', e.target.value)} placeholder="95" />
</div>
)}
{bodyFat && <div className="bodyfat-result">Beräknat kroppsfett: <strong>{bodyFat}%</strong></div>}
<div className="nav-btns">
<button onClick={() => setStep(1)}> Tillbaka</button>
<button className="next-btn" onClick={() => setStep(3)}>Nästa </button>
</div>
</div>
)}
{step === 3 && (
<div className="step">
<h2>Erfarenhet & styrka</h2>
<div className="field">
<label>Träningserfarenhet</label>
<div className="btn-group vertical">
{['beginner', 'intermediate', 'advanced'].map(l => (
<button key={l} className={data.experience_level === l ? 'active' : ''} onClick={() => update('experience_level', l)}>
{l === 'beginner' ? 'Nybörjare (<1 år)' : l === 'intermediate' ? 'Medel (1-3 år)' : 'Avancerad (3+ år)'}
</button>
))}
</div>
</div>
<p className="hint">1RM (valfritt)</p>
<div className="rm-fields">
<div className="field"><label>Bänk</label><input type="number" value={data.bench_1rm} onChange={e => update('bench_1rm', e.target.value)} placeholder="kg" /></div>
<div className="field"><label>Knäböj</label><input type="number" value={data.squat_1rm} onChange={e => update('squat_1rm', e.target.value)} placeholder="kg" /></div>
<div className="field"><label>Marklyft</label><input type="number" value={data.deadlift_1rm} onChange={e => update('deadlift_1rm', e.target.value)} placeholder="kg" /></div>
</div>
<div className="nav-btns">
<button onClick={() => setStep(2)}> Tillbaka</button>
<button className="next-btn" onClick={() => setStep(4)} disabled={!data.experience_level}>Nästa </button>
</div>
</div>
)}
{step === 4 && (
<div className="step">
<h2>Mål & schema</h2>
<div className="field">
<label>Mål</label>
<div className="btn-group vertical">
{['strength', 'muscle', 'fat_loss', 'general'].map(g => (
<button key={g} className={data.goal === g ? 'active' : ''} onClick={() => update('goal', g)}>
{g === 'strength' ? '💪 Styrka' : g === 'muscle' ? '🏋️ Muskelmassa' : g === 'fat_loss' ? '🔥 Fettförbränning' : '⚖️ Allmän fitness'}
</button>
))}
</div>
</div>
<div className="field">
<label>Pass per vecka</label>
<div className="btn-group">
{[3,4,5,6].map(n => (
<button key={n} className={data.workouts_per_week == n ? 'active' : ''} onClick={() => update('workouts_per_week', n)}>{n}</button>
))}
</div>
</div>
<div className="nav-btns">
<button onClick={() => setStep(3)}> Tillbaka</button>
<button className="finish-btn" onClick={finish} disabled={!data.goal || !data.workouts_per_week || saving}>
{saving ? 'Sparar...' : 'Starta träningen! 🚀'}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await register(email, password);
navigate('/onboarding');
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>🏋 Gravl</h1>
<h2>Skapa konto</h2>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
<button type="submit" disabled={loading}>{loading ? 'Skapar...' : 'Skapa konto'}</button>
</form>
<p className="auth-link">Har redan konto? <Link to="/login">Logga in</Link></p>
</div>
</div>
);
}