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/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 (
+
+ )
+}
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()}
+
+
+
+ )
+}