Compare commits
7 Commits
f6e98ae6b0
...
362f4eed49
| Author | SHA1 | Date | |
|---|---|---|---|
| 362f4eed49 | |||
| 6d1da03fec | |||
| 5d0e0e3952 | |||
| be4a149a47 | |||
| 0cd6cd0269 | |||
| e40b486ae5 | |||
| 04bab32e26 |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"lastRun": "2026-02-28T23:45:00+01:00",
|
||||||
|
"status": "completed",
|
||||||
|
"tasksCompleted": ["emoji-replacement", "alternative-modal", "workout-page-ux-redish", "chat-onboarding", "03-01-login-onboarding-polish", "03-02-dashboard-polish", "03-03-workout-experience-polish"],
|
||||||
|
"activeTask": null,
|
||||||
|
"nextTask": null,
|
||||||
|
"notes": "03-03 Workout Experience Polish complete (commit f6b1379). Phase 3 complete: login/onboarding, dashboard, and workout experience all polished."
|
||||||
|
}
|
||||||
@@ -248,6 +248,61 @@ app.get('/api/days/:dayId/exercises', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get alternative exercises for a given exercise (same muscle group)
|
||||||
|
app.get('/api/exercises/:id/alternatives', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const exerciseResult = await pool.query(
|
||||||
|
'SELECT muscle_group FROM exercises WHERE id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exerciseResult.rows.length) {
|
||||||
|
return res.status(404).json({ error: 'Exercise not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const muscleGroup = exerciseResult.rows[0].muscle_group;
|
||||||
|
const alternatives = await pool.query(
|
||||||
|
`SELECT id, name, muscle_group, description
|
||||||
|
FROM exercises
|
||||||
|
WHERE muscle_group = $1 AND id <> $2
|
||||||
|
ORDER BY name`,
|
||||||
|
[muscleGroup, req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(alternatives.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching alternatives:', err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get last workout for a specific exercise id
|
||||||
|
app.get('/api/exercises/:id/last-workout', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const result = await pool.query(`
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT wl.date
|
||||||
|
FROM workout_logs wl
|
||||||
|
JOIN program_exercises pe ON wl.program_exercise_id = pe.id
|
||||||
|
WHERE pe.exercise_id = $1 AND wl.user_id = $2
|
||||||
|
ORDER BY wl.date DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
SELECT wl.*
|
||||||
|
FROM workout_logs wl
|
||||||
|
JOIN program_exercises pe ON wl.program_exercise_id = pe.id
|
||||||
|
JOIN latest l ON wl.date = l.date
|
||||||
|
WHERE pe.exercise_id = $1 AND wl.user_id = $2
|
||||||
|
ORDER BY wl.set_number ASC
|
||||||
|
`, [req.params.id, user_id || 1]);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching last workout for exercise:', err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get workout logs for a user and date
|
// Get workout logs for a user and date
|
||||||
app.get('/api/logs', async (req, res) => {
|
app.get('/api/logs', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+67
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sv">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0a0a0f" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>Gravl - Träning</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-hhKetRGz.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-my_lGtI5.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1615
-696
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import ProfilePage from './pages/ProfilePage'
|
|||||||
import ProgressPage from './pages/ProgressPage'
|
import ProgressPage from './pages/ProgressPage'
|
||||||
import WorkoutPage from './pages/WorkoutPage'
|
import WorkoutPage from './pages/WorkoutPage'
|
||||||
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
||||||
|
import ChatOnboarding from './pages/ChatOnboarding'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
@@ -21,6 +22,10 @@ function App() {
|
|||||||
const userId = user?.id || 1
|
const userId = user?.id || 1
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
if (user && !user.onboarding_complete) {
|
||||||
|
return <ChatOnboarding />
|
||||||
|
}
|
||||||
|
|
||||||
const fetchProgram = async () => {
|
const fetchProgram = async () => {
|
||||||
if (program) return // Already loaded
|
if (program) return // Already loaded
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Icon } from './Icons'
|
||||||
|
|
||||||
|
function AlternativeModal({ exercise, alternatives, loading, error, onSelect, onClose }) {
|
||||||
|
if (!exercise) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alternative-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="alternative-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="alternative-modal-header">
|
||||||
|
<div>
|
||||||
|
<h3>Alternativa övningar</h3>
|
||||||
|
<p>För {exercise.name}</p>
|
||||||
|
</div>
|
||||||
|
<button className="alternative-modal-close" onClick={onClose} aria-label="Stäng">
|
||||||
|
<Icon name="chevronDown" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="alternative-modal-state">Laddar alternativ...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="alternative-modal-state error">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && alternatives.length === 0 && (
|
||||||
|
<div className="alternative-modal-state">Inga alternativ hittades.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && alternatives.length > 0 && (
|
||||||
|
<div className="alternative-list">
|
||||||
|
{alternatives.map((alt) => (
|
||||||
|
<div key={alt.id} className="alternative-item">
|
||||||
|
<div className="alternative-info">
|
||||||
|
<strong>{alt.name}</strong>
|
||||||
|
<span>{alt.description || 'Ingen beskrivning tillgänglig.'}</span>
|
||||||
|
</div>
|
||||||
|
<button className="alternative-select-btn" onClick={() => onSelect(alt)}>
|
||||||
|
Välj
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlternativeModal
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export default function CoachMessage({ text, typing = false }) {
|
||||||
|
return (
|
||||||
|
<div className={`chat-message coach ${typing ? 'typing' : ''}`}>
|
||||||
|
<div className="chat-avatar">C</div>
|
||||||
|
<div className="chat-bubble">
|
||||||
|
{typing ? (
|
||||||
|
<div className="typing-indicator" aria-label="Coach skriver">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
text
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,6 +62,14 @@ export const Icons = {
|
|||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
swap: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="7 7 3 11 7 15"/>
|
||||||
|
<polyline points="17 9 21 13 17 17"/>
|
||||||
|
<line x1="3" y1="11" x2="21" y2="11"/>
|
||||||
|
<line x1="3" y1="13" x2="21" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
check: (
|
check: (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default function Logo() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" className="logo-mark" aria-hidden="true">
|
||||||
|
<path d="M12 16h4v16h-4zM20 12h8v24h-8zM32 16h4v16h-4z" fill="currentColor"/>
|
||||||
|
<rect x="8" y="20" width="4" height="8" fill="currentColor"/>
|
||||||
|
<rect x="36" y="20" width="4" height="8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export default function QuickReplies({ options = [], onSelect, disabled = false }) {
|
||||||
|
if (!options.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quick-replies">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={`${option.label}-${option.value}`}
|
||||||
|
type="button"
|
||||||
|
className={`quick-reply ${option.variant || ''}`.trim()}
|
||||||
|
onClick={() => onSelect(option)}
|
||||||
|
disabled={disabled || option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default function UserMessage({ text }) {
|
||||||
|
return (
|
||||||
|
<div className="chat-message user">
|
||||||
|
<div className="chat-bubble">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+784
-60
@@ -5,39 +5,91 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dark fitness palette */
|
/* Dark fitness palette - refined */
|
||||||
--bg-primary: #0a0a0f;
|
--bg-primary: #0a0a0f;
|
||||||
--bg-secondary: #0d0d12;
|
--bg-secondary: #0d0d14;
|
||||||
--bg-card: #15151b;
|
--bg-tertiary: #12121a;
|
||||||
--bg-card-hover: #1a1a22;
|
--bg-card: #16161f;
|
||||||
|
--bg-card-hover: #1c1c28;
|
||||||
|
--bg-elevated: #1a1a24;
|
||||||
--bg: #0a0a0f;
|
--bg: #0a0a0f;
|
||||||
|
|
||||||
/* Text colors */
|
/* Text colors - better hierarchy */
|
||||||
--text-primary: #ffffff;
|
--text-primary: #ffffff;
|
||||||
--text-secondary: #a1a1aa;
|
--text-secondary: #a1a1aa;
|
||||||
--text-muted: #71717a;
|
--text-muted: #71717a;
|
||||||
|
--text-tertiary: #52525b;
|
||||||
--text: #ffffff;
|
--text: #ffffff;
|
||||||
|
|
||||||
/* Accent - energetic orange */
|
/* Accent - refined energetic coral */
|
||||||
--accent: #ff6b35;
|
--accent: #ff6b4a;
|
||||||
--accent-hover: #ff8555;
|
--accent-hover: #ff8066;
|
||||||
|
--accent-subtle: rgba(255, 107, 74, 0.15);
|
||||||
|
--accent-glow: rgba(255, 107, 74, 0.25);
|
||||||
|
|
||||||
/* Status colors */
|
/* Status colors - refined */
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
|
--success-subtle: rgba(34, 197, 94, 0.15);
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
|
--warning-subtle: rgba(245, 158, 11, 0.15);
|
||||||
--error: #ef4444;
|
--error: #ef4444;
|
||||||
|
--error-subtle: rgba(239, 68, 68, 0.15);
|
||||||
|
|
||||||
/* Border */
|
/* Borders - refined */
|
||||||
--border: #1f1f28;
|
--border: #1f1f2a;
|
||||||
|
--border-hover: #2a2a38;
|
||||||
|
--border-accent: var(--accent-subtle);
|
||||||
|
|
||||||
/* Workout type colors - muted, professional */
|
/* Shadows - key for enterprise feel */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-glow: 0 0 20px var(--accent-glow);
|
||||||
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
/* Workout type colors - refined */
|
||||||
--workout-push: #ef4444;
|
--workout-push: #ef4444;
|
||||||
--workout-pull: #3b82f6;
|
--workout-pull: #3b82f6;
|
||||||
--workout-legs: #22c55e;
|
--workout-legs: #22c55e;
|
||||||
--workout-shoulders: #f59e0b;
|
--workout-shoulders: #f59e0b;
|
||||||
--workout-upper: #8b5cf6;
|
--workout-upper: #8b5cf6;
|
||||||
--workout-lower: #06b6d4;
|
--workout-lower: #06b6d4;
|
||||||
--workout-default: #ff6b35;
|
--workout-default: #ff6b4a;
|
||||||
|
|
||||||
|
/* Typography scale */
|
||||||
|
--font-xs: 0.75rem;
|
||||||
|
--font-sm: 0.875rem;
|
||||||
|
--font-base: 1rem;
|
||||||
|
--font-lg: 1.125rem;
|
||||||
|
--font-xl: 1.25rem;
|
||||||
|
--font-2xl: 1.5rem;
|
||||||
|
--font-3xl: 2rem;
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.25rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
--space-10: 2.5rem;
|
||||||
|
--space-12: 3rem;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 18px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -47,10 +99,12 @@ html, body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -62,74 +116,744 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
font-size: var(--font-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
font-size: var(--font-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling - refined */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-secondary);
|
background: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auth pages */
|
/* ============================================
|
||||||
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
AUTH PAGES - Premium First Impression
|
||||||
.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 */
|
.auth-page {
|
||||||
.onboarding { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
min-height: 100vh;
|
||||||
.onboarding-card { background: var(--bg-card); padding: 32px; border-radius: 16px; width: 100%; max-width: 480px; }
|
display: flex;
|
||||||
.steps-indicator { display: flex; justify-content: center; gap: 12px; margin-bottom: 28px; }
|
align-items: center;
|
||||||
.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); }
|
justify-content: center;
|
||||||
.steps-indicator span.active { background: var(--accent); color: white; }
|
padding: var(--space-5);
|
||||||
.step h2 { margin-bottom: 20px; text-align: center; }
|
background: var(--bg-primary);
|
||||||
.step .hint { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 16px; text-align: center; }
|
position: relative;
|
||||||
.field { margin-bottom: 16px; }
|
overflow: hidden;
|
||||||
.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); }
|
/* Subtle background gradient */
|
||||||
.btn-group { display: flex; gap: 8px; }
|
.auth-page::before {
|
||||||
.btn-group.vertical { flex-direction: column; }
|
content: '';
|
||||||
.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; }
|
position: absolute;
|
||||||
.btn-group button:hover { border-color: var(--accent); }
|
top: -50%;
|
||||||
.btn-group button.active { background: var(--accent); color: white; border-color: var(--accent); }
|
left: -50%;
|
||||||
.rm-fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 8px; }
|
width: 200%;
|
||||||
.rm-fields .field { margin-bottom: 0; }
|
height: 200%;
|
||||||
.bodyfat-result { background: rgba(78,204,163,0.15); color: var(--success); padding: 16px; border-radius: 8px; text-align: center; margin: 16px 0; }
|
background: radial-gradient(
|
||||||
.nav-btns { display: flex; gap: 12px; margin-top: 24px; }
|
ellipse at 30% 20%,
|
||||||
.nav-btns button { flex: 1; padding: 14px; border-radius: 8px; font-size: 1rem; }
|
rgba(255, 107, 74, 0.03) 0%,
|
||||||
.nav-btns button:first-child { background: var(--bg-secondary); color: var(--text-secondary); }
|
transparent 50%
|
||||||
.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; }
|
radial-gradient(
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
ellipse at 70% 80%,
|
||||||
|
rgba(99, 102, 241, 0.03) 0%,
|
||||||
|
transparent 50%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-10) var(--space-8);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1 {
|
||||||
|
font-size: var(--font-3xl);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h2 {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 auto var(--space-4);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: var(--font-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tagline {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-error-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
animation: auth-error-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button[type="submit"] {
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button[type="submit"]::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button[type="submit"]:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button[type="submit"]:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .error {
|
||||||
|
background: var(--error-subtle);
|
||||||
|
color: var(--error);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ONBOARDING - Premium Step Wizard
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.onboarding {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at 30% 20%,
|
||||||
|
rgba(255, 107, 74, 0.04) 0%,
|
||||||
|
transparent 50%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 70% 80%,
|
||||||
|
rgba(99, 102, 241, 0.04) 0%,
|
||||||
|
transparent 50%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-8);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-indicator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-indicator span {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-indicator span.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step h2 {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step .hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CHAT ONBOARDING
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.chat-onboarding {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: radial-gradient(circle at top, rgba(255, 107, 74, 0.08), transparent 55%), var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
min-height: calc(100vh - var(--space-10));
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: var(--space-5) var(--space-5) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 107, 74, 0.1), rgba(18, 18, 26, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h1 {
|
||||||
|
font-size: var(--font-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-subtitle {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-status {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-status.saving {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
color: var(--warning);
|
||||||
|
border-color: rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
animation: slideUp 0.3s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user .chat-bubble {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.coach .chat-bubble {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-actions {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-replies {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-reply {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-reply:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-reply:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-reply.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
animation: typingPulse 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typingPulse {
|
||||||
|
0%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||||
|
50% { transform: translateY(-4px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.chat-onboarding {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell {
|
||||||
|
min-height: calc(100vh - var(--space-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group button {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-fields .field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyfat-result {
|
||||||
|
background: var(--success-subtle);
|
||||||
|
color: var(--success);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-align: center;
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyfat-result strong {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btns button {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btns button:first-child {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btns button:first-child:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-btn, .finish-btn {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header logout */
|
/* Header logout */
|
||||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
.header-left {
|
||||||
.logout-btn { padding: 6px 12px; background: var(--bg-secondary); color: var(--text-secondary); border-radius: 6px; font-size: 0.75rem; }
|
display: flex;
|
||||||
.logout-btn:hover { background: var(--border); }
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLOBAL INPUT ACCESSIBILITY
|
||||||
|
Ensure all inputs have font-size >= 16px to prevent iOS auto-zoom
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="tel"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'
|
|||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import RegisterPage from './pages/RegisterPage'
|
import RegisterPage from './pages/RegisterPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import OnboardingWizard from './pages/OnboardingWizard'
|
import ChatOnboarding from './pages/ChatOnboarding'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
function ProtectedRoute({ children, requireOnboarding = true }) {
|
function ProtectedRoute({ children, requireOnboarding = true }) {
|
||||||
@@ -31,7 +31,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/register" element={<AuthRoute><RegisterPage /></AuthRoute>} />
|
<Route path="/register" element={<AuthRoute><RegisterPage /></AuthRoute>} />
|
||||||
<Route path="/login" element={<AuthRoute><LoginPage /></AuthRoute>} />
|
<Route path="/login" element={<AuthRoute><LoginPage /></AuthRoute>} />
|
||||||
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><OnboardingWizard /></ProtectedRoute>} />
|
<Route path="/onboarding" element={<ProtectedRoute requireOnboarding={false}><ChatOnboarding /></ProtectedRoute>} />
|
||||||
<Route path="/*" element={<ProtectedRoute><App /></ProtectedRoute>} />
|
<Route path="/*" element={<ProtectedRoute><App /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import CoachMessage from '../components/CoachMessage'
|
||||||
|
import UserMessage from '../components/UserMessage'
|
||||||
|
import QuickReplies from '../components/QuickReplies'
|
||||||
|
|
||||||
|
const API = '/api'
|
||||||
|
|
||||||
|
const initialData = {
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
age: '',
|
||||||
|
height_cm: '',
|
||||||
|
weight: '',
|
||||||
|
neck_cm: '',
|
||||||
|
waist_cm: '',
|
||||||
|
hip_cm: '',
|
||||||
|
experience_level: '',
|
||||||
|
bench_1rm: '',
|
||||||
|
squat_1rm: '',
|
||||||
|
deadlift_1rm: '',
|
||||||
|
goal: '',
|
||||||
|
workouts_per_week: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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.221 * Math.log10(height)) - 450
|
||||||
|
).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNumberOrNull = (value) => {
|
||||||
|
if (value === '' || value === null || value === undefined) return null
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isNaN(numberValue) ? null : numberValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatOnboarding() {
|
||||||
|
const { token, updateProfile, refreshProfile } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [data, setData] = useState(initialData)
|
||||||
|
const [messages, setMessages] = useState([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [answers, setAnswers] = useState([])
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const endRef = useRef(null)
|
||||||
|
const messageIdRef = useRef(0)
|
||||||
|
const typingTimeoutRef = useRef(null)
|
||||||
|
|
||||||
|
const questions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
field: 'name',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Hej! Jag är din coach. Vad heter du?',
|
||||||
|
placeholder: 'Ditt namn',
|
||||||
|
inputType: 'text'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'goal',
|
||||||
|
field: 'goal',
|
||||||
|
type: 'options',
|
||||||
|
prompt: values => `Kul att träffas${values.name ? ` ${values.name}` : ''}! Vad är ditt största mål?`,
|
||||||
|
options: [
|
||||||
|
{ label: 'Bygga muskler', value: 'muscle' },
|
||||||
|
{ label: 'Styrka', value: 'strength' },
|
||||||
|
{ label: 'Gå ner i vikt', value: 'fat_loss' },
|
||||||
|
{ label: 'Hälsa', value: 'general' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'experience_level',
|
||||||
|
field: 'experience_level',
|
||||||
|
type: 'options',
|
||||||
|
prompt: 'Hur länge har du tränat?',
|
||||||
|
options: [
|
||||||
|
{ label: 'Ny', value: 'beginner' },
|
||||||
|
{ label: '< 1 år', value: 'beginner' },
|
||||||
|
{ label: '1-3 år', value: 'intermediate' },
|
||||||
|
{ label: '3+ år', value: 'advanced' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workouts_per_week',
|
||||||
|
field: 'workouts_per_week',
|
||||||
|
type: 'options',
|
||||||
|
prompt: 'Hur många pass kan du köra per vecka?',
|
||||||
|
options: [2, 3, 4, 5, 6].map(n => ({ label: `${n}`, value: n }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gender',
|
||||||
|
field: 'gender',
|
||||||
|
type: 'options',
|
||||||
|
prompt: 'Super! Vi tar några snabba basfrågor. Vilket kön identifierar du dig som?',
|
||||||
|
options: [
|
||||||
|
{ label: 'Man', value: 'male' },
|
||||||
|
{ label: 'Kvinna', value: 'female' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'age',
|
||||||
|
field: 'age',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Hur gammal är du?',
|
||||||
|
placeholder: 'Ålder',
|
||||||
|
inputType: 'number',
|
||||||
|
validate: value => (value > 0 && value < 120 ? '' : 'Skriv in en giltig ålder.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'height_cm',
|
||||||
|
field: 'height_cm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Hur lång är du? (cm)',
|
||||||
|
placeholder: '175',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'cm',
|
||||||
|
validate: value => (value > 50 && value < 260 ? '' : 'Skriv in din längd i cm.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weight',
|
||||||
|
field: 'weight',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Vad väger du just nu? (kg)',
|
||||||
|
placeholder: '75',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'kg',
|
||||||
|
validate: value => (value > 20 && value < 300 ? '' : 'Skriv in din vikt i kg.')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'neck_cm',
|
||||||
|
field: 'neck_cm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Om du vet: halsmått i cm?',
|
||||||
|
placeholder: '38',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'cm',
|
||||||
|
optional: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waist_cm',
|
||||||
|
field: 'waist_cm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Midjemått i cm?',
|
||||||
|
placeholder: '85',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'cm',
|
||||||
|
optional: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hip_cm',
|
||||||
|
field: 'hip_cm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Höftmått i cm?',
|
||||||
|
placeholder: '95',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'cm',
|
||||||
|
optional: true,
|
||||||
|
shouldAsk: values => values.gender === 'female'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bench_1rm',
|
||||||
|
field: 'bench_1rm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: 'Har du en uppskattad 1RM i bänkpress? (valfritt, kg)',
|
||||||
|
placeholder: '100',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'kg',
|
||||||
|
optional: true,
|
||||||
|
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'squat_1rm',
|
||||||
|
field: 'squat_1rm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: '1RM i knäböj? (valfritt, kg)',
|
||||||
|
placeholder: '140',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'kg',
|
||||||
|
optional: true,
|
||||||
|
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deadlift_1rm',
|
||||||
|
field: 'deadlift_1rm',
|
||||||
|
type: 'text',
|
||||||
|
prompt: '1RM i marklyft? (valfritt, kg)',
|
||||||
|
placeholder: '160',
|
||||||
|
inputType: 'number',
|
||||||
|
unit: 'kg',
|
||||||
|
optional: true,
|
||||||
|
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const currentQuestion = questions[currentIndex]
|
||||||
|
|
||||||
|
const addMessage = (message) => {
|
||||||
|
messageIdRef.current += 1
|
||||||
|
setMessages(prev => [...prev, { id: messageIdRef.current, ...message }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (endRef.current) {
|
||||||
|
endRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages, isTyping])
|
||||||
|
|
||||||
|
const getPrompt = (question, values) => {
|
||||||
|
if (!question) return ''
|
||||||
|
return typeof question.prompt === 'function' ? question.prompt(values) : question.prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length === 0 && currentQuestion) {
|
||||||
|
addMessage({ sender: 'coach', text: getPrompt(currentQuestion, data), questionIndex: currentIndex })
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [messages.length, currentQuestion, currentIndex, data])
|
||||||
|
|
||||||
|
const applyAnswer = (values, question, value) => {
|
||||||
|
if (!question.field) return values
|
||||||
|
return { ...values, [question.field]: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebuildDataFromAnswers = (updatedAnswers) => {
|
||||||
|
return updatedAnswers.reduce((acc, answer) => {
|
||||||
|
const question = questions[answer.questionIndex]
|
||||||
|
if (!question) return acc
|
||||||
|
return applyAnswer(acc, question, answer.value)
|
||||||
|
}, { ...initialData })
|
||||||
|
}
|
||||||
|
|
||||||
|
const findNextIndex = (startIndex, nextData) => {
|
||||||
|
for (let i = startIndex + 1; i < questions.length; i += 1) {
|
||||||
|
const question = questions[i]
|
||||||
|
if (!question?.shouldAsk || question.shouldAsk(nextData)) return i
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMeasurementQuestion = (questionId) => ['weight', 'neck_cm', 'waist_cm', 'hip_cm'].includes(questionId)
|
||||||
|
const isStrengthQuestion = (questionId) => ['bench_1rm', 'squat_1rm', 'deadlift_1rm'].includes(questionId)
|
||||||
|
const isProfileQuestion = (questionId) => ['gender', 'age', 'height_cm', 'experience_level', 'goal', 'workouts_per_week'].includes(questionId)
|
||||||
|
|
||||||
|
const saveProfile = async (values, complete = false) => {
|
||||||
|
const payload = {
|
||||||
|
gender: values.gender || null,
|
||||||
|
age: toNumberOrNull(values.age),
|
||||||
|
height_cm: toNumberOrNull(values.height_cm),
|
||||||
|
experience_level: values.experience_level || null,
|
||||||
|
goal: values.goal || null,
|
||||||
|
workouts_per_week: toNumberOrNull(values.workouts_per_week),
|
||||||
|
onboarding_complete: complete
|
||||||
|
}
|
||||||
|
await updateProfile(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMeasurements = async (values) => {
|
||||||
|
const bodyFat = calcBodyFat(
|
||||||
|
values.gender,
|
||||||
|
toNumberOrNull(values.waist_cm),
|
||||||
|
toNumberOrNull(values.neck_cm),
|
||||||
|
toNumberOrNull(values.hip_cm),
|
||||||
|
toNumberOrNull(values.height_cm)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!values.weight && !values.neck_cm && !values.waist_cm && !values.hip_cm) return
|
||||||
|
|
||||||
|
await fetch(`${API}/user/measurements`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
weight: toNumberOrNull(values.weight),
|
||||||
|
neck_cm: toNumberOrNull(values.neck_cm),
|
||||||
|
waist_cm: toNumberOrNull(values.waist_cm),
|
||||||
|
hip_cm: toNumberOrNull(values.hip_cm),
|
||||||
|
body_fat_pct: bodyFat
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStrength = async (values) => {
|
||||||
|
if (!values.bench_1rm && !values.squat_1rm && !values.deadlift_1rm) return
|
||||||
|
|
||||||
|
await fetch(`${API}/user/strength`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
bench_1rm: toNumberOrNull(values.bench_1rm),
|
||||||
|
squat_1rm: toNumberOrNull(values.squat_1rm),
|
||||||
|
deadlift_1rm: toNumberOrNull(values.deadlift_1rm)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeAutoSave = async (questionId, nextData, nextIndex) => {
|
||||||
|
if (isProfileQuestion(questionId)) {
|
||||||
|
await saveProfile(nextData, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMeasurementQuestion(questionId)) {
|
||||||
|
const nextQuestion = nextIndex !== null ? questions[nextIndex] : null
|
||||||
|
if (!nextQuestion || !isMeasurementQuestion(nextQuestion.id)) {
|
||||||
|
await saveMeasurements(nextData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrengthQuestion(questionId)) {
|
||||||
|
const nextQuestion = nextIndex !== null ? questions[nextIndex] : null
|
||||||
|
if (!nextQuestion || !isStrengthQuestion(nextQuestion.id)) {
|
||||||
|
await saveStrength(nextData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAnswer = async (answerValue, answerLabel = null) => {
|
||||||
|
if (!currentQuestion) return
|
||||||
|
const label = answerLabel ?? `${answerValue}`
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const nextData = applyAnswer(data, currentQuestion, answerValue)
|
||||||
|
const nextIndex = findNextIndex(currentIndex, nextData)
|
||||||
|
|
||||||
|
addMessage({ sender: 'user', text: label, questionIndex: currentIndex })
|
||||||
|
setAnswers(prev => [...prev, { questionIndex: currentIndex, value: answerValue, label }])
|
||||||
|
setData(nextData)
|
||||||
|
setInputValue('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await maybeAutoSave(currentQuestion.id, nextData, nextIndex)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Autosave error:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex === null) {
|
||||||
|
setIsTyping(true)
|
||||||
|
typingTimeoutRef.current = setTimeout(async () => {
|
||||||
|
addMessage({
|
||||||
|
sender: 'coach',
|
||||||
|
text: 'Perfekt! Jag har allt jag behöver. Låt mig bygga ditt program...',
|
||||||
|
questionIndex: currentIndex + 1
|
||||||
|
})
|
||||||
|
setIsTyping(false)
|
||||||
|
await finishOnboarding(nextData)
|
||||||
|
}, 700)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTyping(true)
|
||||||
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
|
addMessage({ sender: 'coach', text: getPrompt(questions[nextIndex], nextData), questionIndex: nextIndex })
|
||||||
|
setCurrentIndex(nextIndex)
|
||||||
|
setIsTyping(false)
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishOnboarding = async (values) => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await saveProfile(values, true)
|
||||||
|
await saveMeasurements(values)
|
||||||
|
await saveStrength(values)
|
||||||
|
if (refreshProfile) await refreshProfile()
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Onboarding error:', err)
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTextSubmit = () => {
|
||||||
|
if (!currentQuestion) return
|
||||||
|
const raw = inputValue.trim()
|
||||||
|
if (!raw && !currentQuestion.optional) {
|
||||||
|
setError('Skriv ett svar för att gå vidare.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let numericValue = raw
|
||||||
|
if (currentQuestion.inputType === 'number' && raw) {
|
||||||
|
const parsed = Number(raw)
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
setError('Skriv ett giltigt nummer.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const validationMessage = currentQuestion.validate ? currentQuestion.validate(parsed) : ''
|
||||||
|
if (validationMessage) {
|
||||||
|
setError(validationMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
numericValue = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw && currentQuestion.optional) {
|
||||||
|
handleAnswer('', 'Hoppar')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = currentQuestion.unit ? `${raw} ${currentQuestion.unit}` : raw
|
||||||
|
handleAnswer(numericValue, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuickReply = (option) => {
|
||||||
|
if (option.action === 'back') {
|
||||||
|
handleBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (option.action === 'skip') {
|
||||||
|
handleAnswer('', 'Hoppar')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleAnswer(option.value, option.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (!answers.length) return
|
||||||
|
const lastAnswer = answers[answers.length - 1]
|
||||||
|
const targetIndex = lastAnswer.questionIndex
|
||||||
|
const trimmedMessages = [...messages]
|
||||||
|
const lastCoachIndex = trimmedMessages
|
||||||
|
.map((msg, idx) => (msg.sender === 'coach' && msg.questionIndex === targetIndex ? idx : -1))
|
||||||
|
.filter(idx => idx !== -1)
|
||||||
|
.pop()
|
||||||
|
|
||||||
|
if (lastCoachIndex !== undefined) {
|
||||||
|
trimmedMessages.splice(lastCoachIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAnswers = answers.slice(0, -1)
|
||||||
|
setAnswers(updatedAnswers)
|
||||||
|
setMessages(trimmedMessages)
|
||||||
|
setCurrentIndex(targetIndex)
|
||||||
|
setData(rebuildDataFromAnswers(updatedAnswers))
|
||||||
|
setIsTyping(false)
|
||||||
|
setInputValue('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderInputArea = () => {
|
||||||
|
if (!currentQuestion) return null
|
||||||
|
|
||||||
|
if (currentQuestion.type === 'options') {
|
||||||
|
const options = [...currentQuestion.options]
|
||||||
|
const actionOptions = []
|
||||||
|
if (currentQuestion.optional) {
|
||||||
|
actionOptions.push({ label: 'Hoppa över', action: 'skip', variant: 'ghost' })
|
||||||
|
}
|
||||||
|
if (answers.length) {
|
||||||
|
actionOptions.push({ label: 'Ändra senaste', action: 'back', variant: 'ghost' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<QuickReplies
|
||||||
|
options={[...options, ...actionOptions]}
|
||||||
|
onSelect={handleQuickReply}
|
||||||
|
disabled={isTyping || isSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionOptions = []
|
||||||
|
if (currentQuestion.optional) {
|
||||||
|
actionOptions.push({ label: 'Hoppa över', action: 'skip', variant: 'ghost' })
|
||||||
|
}
|
||||||
|
if (answers.length) {
|
||||||
|
actionOptions.push({ label: 'Ändra senaste', action: 'back', variant: 'ghost' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-input-area">
|
||||||
|
<div className="chat-input-row">
|
||||||
|
<input
|
||||||
|
type={currentQuestion.inputType || 'text'}
|
||||||
|
inputMode={currentQuestion.inputType === 'number' ? 'numeric' : 'text'}
|
||||||
|
placeholder={currentQuestion.placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={event => setInputValue(event.target.value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') handleTextSubmit()
|
||||||
|
}}
|
||||||
|
disabled={isTyping || isSaving}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="send-btn"
|
||||||
|
onClick={handleTextSubmit}
|
||||||
|
disabled={isTyping || isSaving}
|
||||||
|
>
|
||||||
|
Skicka
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="chat-error">{error}</div>}
|
||||||
|
<QuickReplies
|
||||||
|
options={actionOptions}
|
||||||
|
onSelect={handleQuickReply}
|
||||||
|
disabled={isTyping || isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-onboarding">
|
||||||
|
<div className="chat-shell">
|
||||||
|
<header className="chat-header">
|
||||||
|
<div>
|
||||||
|
<p className="chat-subtitle">Coach</p>
|
||||||
|
<h1>Personlig onboarding</h1>
|
||||||
|
</div>
|
||||||
|
<span className={`chat-status ${isSaving ? 'saving' : ''}`}>
|
||||||
|
{isSaving ? 'Sparar...' : 'Redo'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="chat-messages">
|
||||||
|
{messages.map(message => (
|
||||||
|
message.sender === 'coach' ? (
|
||||||
|
<CoachMessage key={message.id} text={message.text} />
|
||||||
|
) : (
|
||||||
|
<UserMessage key={message.id} text={message.text} />
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{isTyping && <CoachMessage typing />}
|
||||||
|
<div ref={endRef}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-actions">
|
||||||
|
{renderInputArea()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { Icon, getActivityIconName } from '../components/Icons'
|
import { Icon, getActivityIconName } from '../components/Icons'
|
||||||
|
import Logo from '../components/Logo'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
@@ -90,7 +91,10 @@ function Dashboard({ onStartWorkout, onNavigate }) {
|
|||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
<header className="dashboard-header">
|
<header className="dashboard-header">
|
||||||
<div className="header-top">
|
<div className="header-top">
|
||||||
<h1 className="brand-title"><Icon name="gravl" size={22} /> Gravl</h1>
|
<h1 className="brand-title">
|
||||||
|
<Logo />
|
||||||
|
<span className="brand-name">Gravl</span>
|
||||||
|
</h1>
|
||||||
<nav className="nav-menu">
|
<nav className="nav-menu">
|
||||||
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
||||||
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import Logo from '../components/Logo';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -26,9 +27,10 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1>🏋️ Gravl</h1>
|
<Logo />
|
||||||
<h2>Logga in</h2>
|
<h1 className="auth-title">Logga in</h1>
|
||||||
{error && <div className="error">{error}</div>}
|
<p className="auth-tagline">Din personliga träningspartner</p>
|
||||||
|
{error && <div className="error auth-error">{error}</div>}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
|
<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 />
|
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import Logo from '../components/Logo';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -26,9 +27,10 @@ export default function RegisterPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1>🏋️ Gravl</h1>
|
<Logo />
|
||||||
<h2>Skapa konto</h2>
|
<h1 className="auth-title">Skapa konto</h1>
|
||||||
{error && <div className="error">{error}</div>}
|
<p className="auth-tagline">Börja din träningsresa</p>
|
||||||
|
{error && <div className="error auth-error">{error}</div>}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
|
<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} />
|
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Icon } from '../components/Icons'
|
import { Icon } from '../components/Icons'
|
||||||
import WeightInput from '../components/WeightInput'
|
import AlternativeModal from '../components/AlternativeModal'
|
||||||
import RepsInput from '../components/RepsInput'
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
// Uppvärmningsövningar baserat på muskelgrupp
|
// Uppvärmningsövningar baserat på muskelgrupp
|
||||||
const warmupExercises = {
|
const warmupExercises = {
|
||||||
@@ -53,11 +54,33 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
|||||||
const [warmupDone, setWarmupDone] = useState(false)
|
const [warmupDone, setWarmupDone] = useState(false)
|
||||||
const [warmupExpanded, setWarmupExpanded] = useState(true)
|
const [warmupExpanded, setWarmupExpanded] = useState(true)
|
||||||
const [completedWarmups, setCompletedWarmups] = useState(new Set())
|
const [completedWarmups, setCompletedWarmups] = useState(new Set())
|
||||||
|
const [swapExercise, setSwapExercise] = useState(null)
|
||||||
|
const [alternatives, setAlternatives] = useState([])
|
||||||
|
const [alternativesLoading, setAlternativesLoading] = useState(false)
|
||||||
|
const [alternativesError, setAlternativesError] = useState('')
|
||||||
|
const [swappedExercises, setSwappedExercises] = useState({})
|
||||||
|
const defaultRestSeconds = 90
|
||||||
|
const [restSeconds, setRestSeconds] = useState(defaultRestSeconds)
|
||||||
|
const [restRunning, setRestRunning] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProgressions()
|
loadProgressions()
|
||||||
}, [day])
|
}, [day])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restRunning) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setRestSeconds(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
setRestRunning(false)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return prev - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [restRunning])
|
||||||
|
|
||||||
const loadProgressions = async () => {
|
const loadProgressions = async () => {
|
||||||
const progs = {}
|
const progs = {}
|
||||||
for (const exercise of day.exercises) {
|
for (const exercise of day.exercises) {
|
||||||
@@ -68,6 +91,40 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
|||||||
setProgressions(progs)
|
setProgressions(progs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAlternatives = async (exercise) => {
|
||||||
|
if (!exercise?.exercise_id) {
|
||||||
|
setAlternativesError('Saknar övningsdata för alternativa val.')
|
||||||
|
setSwapExercise(exercise)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwapExercise(exercise)
|
||||||
|
setAlternatives([])
|
||||||
|
setAlternativesError('')
|
||||||
|
setAlternativesLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/exercises/${exercise.exercise_id}/alternatives`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch alternatives')
|
||||||
|
const data = await res.json()
|
||||||
|
setAlternatives(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch alternatives:', err)
|
||||||
|
setAlternativesError('Kunde inte hämta alternativ.')
|
||||||
|
} finally {
|
||||||
|
setAlternativesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAlternative = (alternative) => {
|
||||||
|
if (!swapExercise) return
|
||||||
|
setSwappedExercises(prev => ({
|
||||||
|
...prev,
|
||||||
|
[swapExercise.id]: alternative
|
||||||
|
}))
|
||||||
|
setSwapExercise(null)
|
||||||
|
}
|
||||||
|
|
||||||
const exercises = day.exercises?.filter(e => e.name) || []
|
const exercises = day.exercises?.filter(e => e.name) || []
|
||||||
const muscleGroups = getMuscleGroups(exercises)
|
const muscleGroups = getMuscleGroups(exercises)
|
||||||
|
|
||||||
@@ -97,6 +154,26 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
|||||||
const totalWarmups = generalWarmups.length + specificWarmups.length
|
const totalWarmups = generalWarmups.length + specificWarmups.length
|
||||||
const warmupProgress = completedWarmups.size
|
const warmupProgress = completedWarmups.size
|
||||||
|
|
||||||
|
const formatRestTime = (totalSeconds) => {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRest = (seconds = defaultRestSeconds) => {
|
||||||
|
setRestSeconds(seconds)
|
||||||
|
setRestRunning(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRest = () => {
|
||||||
|
setRestRunning(prev => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetRest = () => {
|
||||||
|
setRestRunning(false)
|
||||||
|
setRestSeconds(defaultRestSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workout-page">
|
<div className="workout-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
@@ -113,6 +190,29 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="page-main workout-main">
|
<main className="page-main workout-main">
|
||||||
|
{/* Vila */}
|
||||||
|
<section className="rest-timer-card">
|
||||||
|
<div className="rest-timer-header">
|
||||||
|
<div className="rest-timer-label">Vilotimer</div>
|
||||||
|
<div className={`rest-timer-time ${restRunning ? 'running' : ''}`}>
|
||||||
|
{formatRestTime(restSeconds)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rest-timer-actions">
|
||||||
|
<button className="rest-timer-btn primary" onClick={toggleRest}>
|
||||||
|
{restRunning ? 'Pausa' : 'Starta vila'}
|
||||||
|
</button>
|
||||||
|
<button className="rest-timer-btn secondary" onClick={resetRest}>
|
||||||
|
Återställ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rest-timer-presets">
|
||||||
|
<button className="rest-timer-chip" onClick={() => startRest(60)}>1:00</button>
|
||||||
|
<button className="rest-timer-chip" onClick={() => startRest(90)}>1:30</button>
|
||||||
|
<button className="rest-timer-chip" onClick={() => startRest(120)}>2:00</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="workout-progress-bar">
|
<div className="workout-progress-bar">
|
||||||
<div
|
<div
|
||||||
@@ -228,20 +328,30 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
|||||||
{/* Övningslista */}
|
{/* Övningslista */}
|
||||||
<section className="exercises-section">
|
<section className="exercises-section">
|
||||||
<h2>Övningar</h2>
|
<h2>Övningar</h2>
|
||||||
{exercises.map((exercise, idx) => (
|
{exercises.map((exercise, idx) => {
|
||||||
<ExerciseCard
|
const swapped = swappedExercises[exercise.id]
|
||||||
key={exercise.id || idx}
|
const displayExercise = swapped
|
||||||
exercise={exercise}
|
? { ...exercise, name: swapped.name, muscle_group: swapped.muscle_group, description: swapped.description }
|
||||||
logs={logs[exercise.id] || []}
|
: exercise
|
||||||
progression={progressions[exercise.id]}
|
|
||||||
expanded={expandedExercise === exercise.id}
|
return (
|
||||||
onToggle={() => setExpandedExercise(
|
<ExerciseCard
|
||||||
expandedExercise === exercise.id ? null : exercise.id
|
key={exercise.id || idx}
|
||||||
)}
|
exercise={displayExercise}
|
||||||
onLogSet={onLogSet}
|
isSwapped={Boolean(swapped)}
|
||||||
onDeleteSet={onDeleteSet}
|
logs={logs[exercise.id] || []}
|
||||||
/>
|
progression={progressions[exercise.id]}
|
||||||
))}
|
expanded={expandedExercise === exercise.id}
|
||||||
|
onToggle={() => setExpandedExercise(
|
||||||
|
expandedExercise === exercise.id ? null : exercise.id
|
||||||
|
)}
|
||||||
|
onLogSet={onLogSet}
|
||||||
|
onDeleteSet={onDeleteSet}
|
||||||
|
onStartRest={startRest}
|
||||||
|
onSwap={() => openAlternatives(exercise)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Avsluta pass */}
|
{/* Avsluta pass */}
|
||||||
@@ -254,13 +364,24 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
|||||||
: `Avsluta pass (${completedExercises}/${exercises.length} klara)`}
|
: `Avsluta pass (${completedExercises}/${exercises.length} klara)`}
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AlternativeModal
|
||||||
|
exercise={swapExercise}
|
||||||
|
alternatives={alternatives}
|
||||||
|
loading={alternativesLoading}
|
||||||
|
error={alternativesError}
|
||||||
|
onSelect={handleSelectAlternative}
|
||||||
|
onClose={() => setSwapExercise(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet }) {
|
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest }) {
|
||||||
const [setList, setSetList] = useState([])
|
const [setList, setSetList] = useState([])
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const weightStep = 2.5
|
||||||
|
const repsStep = 1
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = []
|
const initial = []
|
||||||
@@ -279,11 +400,34 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
|||||||
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
|
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseNumber = (value) => {
|
||||||
|
const parsed = parseFloat(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatWeight = (value) => {
|
||||||
|
const fixed = Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||||
|
return fixed.replace(/\.0$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdjust = (idx, field, delta, min = 0) => {
|
||||||
|
const current = parseNumber(setList[idx]?.[field])
|
||||||
|
const next = Math.max(min, current + delta)
|
||||||
|
if (field === 'weight') {
|
||||||
|
handleInputChange(idx, field, formatWeight(next))
|
||||||
|
} else {
|
||||||
|
handleInputChange(idx, field, String(Math.round(next)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleComplete = (idx) => {
|
const handleComplete = (idx) => {
|
||||||
const input = setList[idx]
|
const input = setList[idx]
|
||||||
const newCompleted = !input.completed
|
const newCompleted = !input.completed
|
||||||
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, completed: newCompleted } : s))
|
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, completed: newCompleted } : s))
|
||||||
onLogSet(exercise.id, idx + 1, input.weight, input.reps, newCompleted)
|
onLogSet(exercise.id, idx + 1, input.weight, input.reps, newCompleted)
|
||||||
|
if (newCompleted) {
|
||||||
|
onStartRest?.()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNormal = () => {
|
const handleAddNormal = () => {
|
||||||
@@ -320,12 +464,25 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
|||||||
<div className="exercise-info">
|
<div className="exercise-info">
|
||||||
<h3>{exercise.name}</h3>
|
<h3>{exercise.name}</h3>
|
||||||
<span className="muscle-group">{exercise.muscle_group}</span>
|
<span className="muscle-group">{exercise.muscle_group}</span>
|
||||||
|
{isSwapped && <span className="swap-badge">Alternativ</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-meta">
|
<div className="exercise-actions">
|
||||||
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
<div className="exercise-meta">
|
||||||
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
|
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
||||||
{completedSets}/{setList.length}
|
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
|
||||||
</span>
|
{completedSets}/{setList.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="swap-btn"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onSwap?.()
|
||||||
|
}}
|
||||||
|
aria-label="Byt övning"
|
||||||
|
>
|
||||||
|
<Icon name="swap" size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -343,31 +500,74 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
|||||||
<div className="sets-list">
|
<div className="sets-list">
|
||||||
{setList.map((input, idx) => (
|
{setList.map((input, idx) => (
|
||||||
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||||||
<span className="set-number">Set {idx + 1}</span>
|
<div className="set-row-top">
|
||||||
<div className="set-inputs">
|
<span className="set-number">Set {idx + 1}</span>
|
||||||
<WeightInput
|
<button
|
||||||
value={input.weight}
|
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
|
||||||
onChange={(val) => handleInputChange(idx, 'weight', val)}
|
onClick={() => handleDeleteSet(idx)}
|
||||||
/>
|
disabled={setList.length <= 1}
|
||||||
<span className="input-separator">×</span>
|
aria-label={`Ta bort set ${idx + 1}`}
|
||||||
<RepsInput
|
>
|
||||||
value={input.reps}
|
<Icon name="trash" size={16} />
|
||||||
onChange={(val) => handleInputChange(idx, 'reps', val)}
|
</button>
|
||||||
/>
|
</div>
|
||||||
|
<div className="set-controls">
|
||||||
|
<div className="set-metric">
|
||||||
|
<span className="metric-label">Vikt</span>
|
||||||
|
<div className="metric-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metric-btn"
|
||||||
|
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
|
||||||
|
aria-label="Minska vikt"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div className="metric-value">
|
||||||
|
<span className="metric-number">{input.weight === '' ? '0' : input.weight}</span>
|
||||||
|
<span className="metric-suffix">kg</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metric-btn"
|
||||||
|
onClick={() => handleAdjust(idx, 'weight', weightStep)}
|
||||||
|
aria-label="Öka vikt"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="set-metric">
|
||||||
|
<span className="metric-label">Reps</span>
|
||||||
|
<div className="metric-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metric-btn"
|
||||||
|
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
|
||||||
|
aria-label="Minska reps"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div className="metric-value">
|
||||||
|
<span className="metric-number">{input.reps === '' ? '0' : input.reps}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metric-btn"
|
||||||
|
onClick={() => handleAdjust(idx, 'reps', repsStep)}
|
||||||
|
aria-label="Öka reps"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
|
className={`klart-btn ${input.completed ? 'done' : ''}`}
|
||||||
onClick={() => handleDeleteSet(idx)}
|
|
||||||
disabled={setList.length <= 1}
|
|
||||||
aria-label={`Ta bort set ${idx + 1}`}
|
|
||||||
>
|
|
||||||
<Icon name="trash" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`complete-btn ${input.completed ? 'done' : ''}`}
|
|
||||||
onClick={() => handleComplete(idx)}
|
onClick={() => handleComplete(idx)}
|
||||||
>
|
>
|
||||||
{input.completed ? <Icon name="check" size={18} /> : ''}
|
{input.completed ? <Icon name="check" size={18} /> : null}
|
||||||
|
KLART
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user