9 Commits

Author SHA1 Message Date
clawd f6e98ae6b0 checkpoint: mark phase 3 complete (03-01, 03-02, 03-03) 2026-03-01 00:03:48 +01:00
clawd ddbd7010df 03-03: Workout Experience Polish - enhanced exercise cards, progress badges, rest timer, KLART button, warmup styling 2026-02-28 23:47:36 +01:00
clawd d03bd50f1d feat(dashboard): polish header logo, stat cards, calendar and animations
- Replace gravl icon text with Logo component in dashboard header
- Stat cards: gradient depth + per-card colour accent (orange/green/amber)
- Calendar today: pulsing glow animation; workout days get subtle brand tint
- Arrow nudge animation on today-workout-card hover
- Section stagger fade-in on page load (calendar → coach → stats)
- Larger stat-value font (3xl) with tighter letter-spacing
- Consistent gap spacing in dashboard-main (space-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:22:34 +01:00
clawd 65ea12a47b feat(auth): polish login/register with logo, gradients and animations 2026-02-28 22:59:08 +01:00
clawd aa1786a550 checkpoint: mark 03-01-login-onboarding-polish as completed 2026-02-28 22:58:24 +01:00
clawd db31bea203 feat(onboarding): add conversational ChatOnboarding component 2026-02-28 22:06:15 +01:00
clawd 7a965319a2 design: WorkoutPage Hevy-style redesign + AlternativeModal + backend API
- Add GET /api/exercises/:id/alternatives endpoint
- Add GET /api/exercises/:id/last-workout endpoint
- New AlternativeModal component for swapping exercises
- WorkoutPage: single-tap logging, +/- buttons, rest timer
- Updated Icons with new workout icons
- Polish: card shadows, borders, micro-interactions
- Tasks directory for project management
2026-02-28 21:25:23 +01:00
clawd 0e5cec927a docs: add TDD coding conventions
Red/Green/Refactor cycle is now mandatory for all development
2026-02-28 14:43:25 +01:00
clawd 7b4625c78f docs: add phase 3 design polish planning, update progress 2026-02-26 23:53:22 +01:00
18 changed files with 3503 additions and 810 deletions
+8
View File
@@ -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."
}
+55
View File
@@ -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 {
+103
View File
@@ -0,0 +1,103 @@
# Gravl Coding Conventions
## Utvecklingsmetodik
### Red/Green TDD (OBLIGATORISKT)
All ny kod måste följa TDD-cykeln:
```
🔴 RED → 🟢 GREEN → 🔄 REFACTOR
```
#### 1. 🔴 RED - Skriv test först
```javascript
// test/feature.test.js
describe('Feature', () => {
it('should do expected behavior', async () => {
const result = await feature.doSomething();
expect(result).toBe(expected);
});
});
```
**Kör testet - det MÅSTE faila!**
```bash
npm test -- --grep "Feature"
# ❌ FAIL (detta är rätt!)
```
#### 2. 🟢 GREEN - Minimal implementation
Skriv bara tillräckligt med kod för att testet passerar:
```javascript
// src/feature.js
export function doSomething() {
return expected; // Minimal lösning
}
```
**Kör testet igen:**
```bash
npm test -- --grep "Feature"
# ✅ PASS
```
#### 3. 🔄 REFACTOR - Förbättra
Nu kan du:
- Refaktorera för clean code
- Extrahera funktioner
- Förbättra namngivning
- Ta bort duplicering
**Kör testerna kontinuerligt:**
```bash
npm test
# ✅ Alla test måste fortfarande passa
```
---
## Teststruktur
```
/workspace/gravl/
├── src/
│ └── components/
├── server/
│ └── routes/
└── test/
├── unit/ # Enhetstester
├── integration/ # API-tester
└── e2e/ # End-to-end
```
## Namnkonventioner
### Tester
- `[feature].test.js` - Unit tests
- `[feature].integration.test.js` - Integration tests
- Describe-block: Noun (vad testas)
- It-block: "should [verb] [expected outcome]"
### Commits
```
test: add failing test for [feature]
feat: implement [feature] to pass tests
refactor: clean up [feature] implementation
```
---
## Workflow för kodningsagenter
1. **Få uppgift** från Gravl PM
2. **Läs spec** i docs/current-task.md
3. **Skriv failing test** - visa PM
4. **Implementera** tills test passerar
5. **Refaktorera** om nödvändigt
6. **Commit** med rätt prefix
7. **Rapportera** till PM
---
*Uppdaterad: 2026-02-28*
+1615 -696
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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
+18
View File
@@ -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>
)
}
+8
View File
@@ -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"/>
+9
View File
@@ -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>
);
}
+19
View File
@@ -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>
)
}
+9
View File
@@ -0,0 +1,9 @@
export default function UserMessage({ text }) {
return (
<div className="chat-message user">
<div className="chat-bubble">
{text}
</div>
</div>
)
}
+784 -60
View File
@@ -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;
}
+2 -2
View File
@@ -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>
+557
View File
@@ -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>
)
}
+5 -1
View File
@@ -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>
+5 -3
View File
@@ -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 />
+5 -3
View File
@@ -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} />
+243 -43
View File
@@ -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>
))} ))}