diff --git a/.pm-checkpoint.json b/.pm-checkpoint.json new file mode 100644 index 0000000..777e153 --- /dev/null +++ b/.pm-checkpoint.json @@ -0,0 +1,9 @@ +{ + "lastRun": "2026-02-28T22:05:51+01:00", + "status": "running", + "tasksCompleted": ["emoji-replacement", "alternative-modal", "workout-page-ux-redesign", "chat-onboarding"], + "activeAgent": "gravl-agent-03-01", + "activeAgentTask": "03-01-login-onboarding-polish", + "nextPlannedTask": "03-02-dashboard-polish", + "notes": "Spawnat agent för 03-01: Login/Onboarding Polish. Chat-onboarding committed." +} diff --git a/TODO.md b/TODO.md index 5651a01..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,120 +0,0 @@ -# Gravl - Feature Roadmap - -## 🎹 Design Overhaul - Fitness App Feel - -**MĂ„l:** En professionell, atletisk kĂ€nsla - inte en hobby-app med emojis. - -### FĂ€rgpalett -- [ ] PrimĂ€r: Mörk bakgrund (#0a0a0f eller liknande) -- [ ] Accent: Energisk orange/röd (#ff6b35) eller electric blue (#00d4ff) -- [ ] Text: Ljus pĂ„ mörk (#ffffff, #a1a1aa för sekundĂ€r) -- [ ] Gradienter: Subtila, inte rainbow - -### Typografi -- [ ] Rubrik: Bold, kondenserad sans-serif (Inter, Oswald, eller liknande) -- [ ] Body: Clean sans-serif -- [ ] Siffror/stats: Monospace eller tabular för alignment - -### Ikoner & Grafik -- [x] **Bort med ALLA emojis** - ersĂ€tt med: - - SVG-ikoner (Lucide, Heroicons, eller custom) - - Stiliserade fitness-silhuetter för workout-typer - - Abstrakta former/linjer istĂ€llet för cartoonish grafik -- [x] Coach-avatar: Stiliserad silhuett eller initialer, inte emoji -- [x] Workout-ikoner: Dumbbell, barbell, kettlebell som rena linjeikoner - -### UI-komponenter -- [ ] Kort: Subtila skuggor, mjuka kanter, inte "bubbliga" -- [ ] Knappar: Solid eller outlined, inte gradient-rainbow -- [ ] Progress bars: Tunna, eleganta -- [ ] Kalender: Minimalistisk, fĂ€rgkodade dots/bars - -### Bilder -- [ ] Hero-bilder: Högkvalitativa trĂ€ningsbilder (Unsplash fitness) -- [ ] Bakgrunder: Mörka texturer eller subtila patterns -- [ ] Inga clip-art eller cartoon-style - -### Animation -- [ ] Subtila micro-interactions -- [ ] Smooth transitions (300ms ease) -- [ ] Loading states: Skeleton screens, inte spinners med emojis - -### Inspirations-appar -- Nike Training Club -- FITBOD -- Strong -- Hevy - ---- - -## 🔐 Onboarding & Signup -- [ ] Registrering/inloggning (email + lösenord) -- [ ] Onboarding-wizard med steg-för-steg guide -- [ ] **Konversations-onboarding med Coach** - istĂ€llet för formulĂ€r, en dialog som grĂ€ver fram riktiga mĂ„l (rekomp, specifika muskler, livsstil, etc.) - -## 🏠 Dashboard / Landningssida (efter inlogg) -- [ ] **Veckokalender** - visar trĂ€ningsdagar markerade -- [ ] **Dagens pass** - huvudinnehĂ„ll, tydligt call-to-action -- [ ] **Coach-hĂ€lsning** - personlig motivation/tips frĂ„n din coach -- [ ] Enkel meny/navigation -- [ ] Inspiration: MadMuscles-stil - -## đŸ‘€ AnvĂ€ndarprofil -- [ ] Kön -- [ ] Ålder -- [ ] Vikt -- [ ] KroppsmĂ„tt för kroppsfettberĂ€kning: - - [ ] Hals - - [ ] Mage - - [ ] Höft (för kvinnor) -- [ ] Automatisk kroppsfett-kalkylering (US Navy-metoden) - -## 🎯 MĂ„l & Erfarenhet -- [ ] Ange trĂ€ningserfarenhet (nybörjare/medel/avancerad) -- [ ] Ange 1RM pĂ„ basövningar (bĂ€nk, knĂ€böj, marklyft) -- [ ] Estimera startvik baserat pĂ„ erfarenhet/1RM -- [ ] Nybörjare startar lĂ€tt automatiskt -- [ ] Ange trĂ€ningsmĂ„l: - - [ ] Styrka - - [ ] Hypertrofi - - [ ] FettförbrĂ€nning - - [ ] AllmĂ€n fitness - -## 📅 TrĂ€ningsupplĂ€gg -- [ ] AnvĂ€ndaren anger antal pass/vecka -- [ ] Generera anpassat program utifrĂ„n frekvens -- [ ] Adaptiva pass som matchar mĂ„l -- [ ] Progressiv överbelastning som pushar anvĂ€ndaren - -## đŸ‹ïž TrĂ€ningspass -- [ ] **Dedikerad pass-sida** - "Starta pass" → egen vy för passet -- [ ] **Alternativa övningar** - byt ut övning mot variant för samma muskelgrupp -- [ ] **UppvĂ€rmningsövningar** - inkludera före huvudpasset -- [ ] **AI-anpassning efter dagsform** - coach föreslĂ„r annat upplĂ€gg vid lĂ„g energi, skada, etc. - -## đŸ‘€ Profilsida -- [ ] Visa/redigera anvĂ€ndarinfo (Ă„lder, vikt, lĂ€ngd, mĂ„l) -- [ ] Visa aktuella mĂ€tningar och kroppsfett -- [ ] Ändra trĂ€ningsfrekvens och mĂ„l -- [ ] InstĂ€llningar - -## 📊 Progressionssida -- [ ] **Progressgrafer** (vikt, styrka, kroppsfett över tid) -- [ ] Regelbundna benchmark-tester (var 4-6 vecka) -- [ ] JĂ€mförelse mot tidigare resultat -- [ ] Visualisering av 1RM-utveckling per övning -- [ ] Notifikationer/pĂ„minnelser för benchmarks - -## 📖 Övningsinformation -- [ ] Dedikerad infosida per övning -- [ ] Beskrivning av utförande -- [ ] Muskelgrupper som trĂ€nas -- [ ] Demo-video/animation -- [ ] LĂ€nk till alternativa övningar -- [ ] Tips & vanliga misstag - -## 🔼 Framtida features -- [ ] Social/dela resultat -- [ ] Vila-timer med notis -- [ ] Export av trĂ€ningsdata -- [ ] Apple Health / Google Fit integration diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index dc089a6..3bf5f5f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import ProfilePage from './pages/ProfilePage' import ProgressPage from './pages/ProgressPage' import WorkoutPage from './pages/WorkoutPage' import WorkoutSelectPage from './pages/WorkoutSelectPage' +import ChatOnboarding from './pages/ChatOnboarding' import './App.css' const API_URL = '/api' @@ -21,6 +22,10 @@ function App() { const userId = user?.id || 1 const today = new Date().toISOString().split('T')[0] + if (user && !user.onboarding_complete) { + return + } + const fetchProgram = async () => { if (program) return // Already loaded try { diff --git a/frontend/src/components/CoachMessage.jsx b/frontend/src/components/CoachMessage.jsx new file mode 100644 index 0000000..5b55331 --- /dev/null +++ b/frontend/src/components/CoachMessage.jsx @@ -0,0 +1,18 @@ +export default function CoachMessage({ text, typing = false }) { + return ( +
+
C
+
+ {typing ? ( +
+ + + +
+ ) : ( + text + )} +
+
+ ) +} diff --git a/frontend/src/components/QuickReplies.jsx b/frontend/src/components/QuickReplies.jsx new file mode 100644 index 0000000..465c708 --- /dev/null +++ b/frontend/src/components/QuickReplies.jsx @@ -0,0 +1,19 @@ +export default function QuickReplies({ options = [], onSelect, disabled = false }) { + if (!options.length) return null + + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/UserMessage.jsx b/frontend/src/components/UserMessage.jsx new file mode 100644 index 0000000..8ee9912 --- /dev/null +++ b/frontend/src/components/UserMessage.jsx @@ -0,0 +1,9 @@ +export default function UserMessage({ text }) { + return ( +
+
+ {text} +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 9ad03c6..4f193a0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -420,6 +420,256 @@ input { 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); @@ -570,4 +820,4 @@ input[type="tel"], select, textarea { font-size: 16px; -} \ No newline at end of file +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 8b648fe..e9aa68a 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,7 +5,7 @@ import { AuthProvider, useAuth } from './context/AuthContext' import App from './App.jsx' import RegisterPage from './pages/RegisterPage' import LoginPage from './pages/LoginPage' -import OnboardingWizard from './pages/OnboardingWizard' +import ChatOnboarding from './pages/ChatOnboarding' import './index.css' function ProtectedRoute({ children, requireOnboarding = true }) { @@ -31,7 +31,7 @@ ReactDOM.createRoot(document.getElementById('root')).render( } /> } /> - } /> + } /> } /> diff --git a/frontend/src/pages/ChatOnboarding.jsx b/frontend/src/pages/ChatOnboarding.jsx new file mode 100644 index 0000000..4cf5e00 --- /dev/null +++ b/frontend/src/pages/ChatOnboarding.jsx @@ -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 ( + <> + + + ) + } + + 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 ( +
+
+ setInputValue(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') handleTextSubmit() + }} + disabled={isTyping || isSaving} + /> + +
+ {error &&
{error}
} + +
+ ) + } + + return ( +
+
+
+
+

Coach

+

Personlig onboarding

+
+ + {isSaving ? 'Sparar...' : 'Redo'} + +
+ +
+ {messages.map(message => ( + message.sender === 'coach' ? ( + + ) : ( + + ) + ))} + {isTyping && } +
+
+ +
+ {renderInputArea()} +
+
+
+ ) +}