Initial commit: Gravl MVP med onboarding
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user