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