feat(onboarding): add conversational ChatOnboarding component
This commit is contained in:
@@ -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 <ChatOnboarding />
|
||||
}
|
||||
|
||||
const fetchProgram = async () => {
|
||||
if (program) return // Already loaded
|
||||
try {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export default function QuickReplies({ options = [], onSelect, disabled = false }) {
|
||||
if (!options.length) return null
|
||||
|
||||
return (
|
||||
<div className="quick-replies">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={`${option.label}-${option.value}`}
|
||||
type="button"
|
||||
className={`quick-reply ${option.variant || ''}`.trim()}
|
||||
onClick={() => onSelect(option)}
|
||||
disabled={disabled || option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function UserMessage({ text }) {
|
||||
return (
|
||||
<div className="chat-message user">
|
||||
<div className="chat-bubble">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+251
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<Routes>
|
||||
<Route path="/register" element={<AuthRoute><RegisterPage /></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>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import CoachMessage from '../components/CoachMessage'
|
||||
import UserMessage from '../components/UserMessage'
|
||||
import QuickReplies from '../components/QuickReplies'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
const initialData = {
|
||||
name: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
height_cm: '',
|
||||
weight: '',
|
||||
neck_cm: '',
|
||||
waist_cm: '',
|
||||
hip_cm: '',
|
||||
experience_level: '',
|
||||
bench_1rm: '',
|
||||
squat_1rm: '',
|
||||
deadlift_1rm: '',
|
||||
goal: '',
|
||||
workouts_per_week: ''
|
||||
}
|
||||
|
||||
const calcBodyFat = (gender, waist, neck, hip, height) => {
|
||||
if (!waist || !neck || !height) return null
|
||||
if (gender === 'female' && !hip) return null
|
||||
if (gender === 'male') {
|
||||
return Math.max(
|
||||
0,
|
||||
495 / (1.0324 - 0.19077 * Math.log10(waist - neck) + 0.15456 * Math.log10(height)) - 450
|
||||
).toFixed(1)
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
495 / (1.29579 - 0.35004 * Math.log10(waist + hip - neck) + 0.221 * Math.log10(height)) - 450
|
||||
).toFixed(1)
|
||||
}
|
||||
|
||||
const toNumberOrNull = (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null
|
||||
const numberValue = Number(value)
|
||||
return Number.isNaN(numberValue) ? null : numberValue
|
||||
}
|
||||
|
||||
export default function ChatOnboarding() {
|
||||
const { token, updateProfile, refreshProfile } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState(initialData)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const endRef = useRef(null)
|
||||
const messageIdRef = useRef(0)
|
||||
const typingTimeoutRef = useRef(null)
|
||||
|
||||
const questions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
field: 'name',
|
||||
type: 'text',
|
||||
prompt: 'Hej! Jag är din coach. Vad heter du?',
|
||||
placeholder: 'Ditt namn',
|
||||
inputType: 'text'
|
||||
},
|
||||
{
|
||||
id: 'goal',
|
||||
field: 'goal',
|
||||
type: 'options',
|
||||
prompt: values => `Kul att träffas${values.name ? ` ${values.name}` : ''}! Vad är ditt största mål?`,
|
||||
options: [
|
||||
{ label: 'Bygga muskler', value: 'muscle' },
|
||||
{ label: 'Styrka', value: 'strength' },
|
||||
{ label: 'Gå ner i vikt', value: 'fat_loss' },
|
||||
{ label: 'Hälsa', value: 'general' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'experience_level',
|
||||
field: 'experience_level',
|
||||
type: 'options',
|
||||
prompt: 'Hur länge har du tränat?',
|
||||
options: [
|
||||
{ label: 'Ny', value: 'beginner' },
|
||||
{ label: '< 1 år', value: 'beginner' },
|
||||
{ label: '1-3 år', value: 'intermediate' },
|
||||
{ label: '3+ år', value: 'advanced' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'workouts_per_week',
|
||||
field: 'workouts_per_week',
|
||||
type: 'options',
|
||||
prompt: 'Hur många pass kan du köra per vecka?',
|
||||
options: [2, 3, 4, 5, 6].map(n => ({ label: `${n}`, value: n }))
|
||||
},
|
||||
{
|
||||
id: 'gender',
|
||||
field: 'gender',
|
||||
type: 'options',
|
||||
prompt: 'Super! Vi tar några snabba basfrågor. Vilket kön identifierar du dig som?',
|
||||
options: [
|
||||
{ label: 'Man', value: 'male' },
|
||||
{ label: 'Kvinna', value: 'female' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
field: 'age',
|
||||
type: 'text',
|
||||
prompt: 'Hur gammal är du?',
|
||||
placeholder: 'Ålder',
|
||||
inputType: 'number',
|
||||
validate: value => (value > 0 && value < 120 ? '' : 'Skriv in en giltig ålder.')
|
||||
},
|
||||
{
|
||||
id: 'height_cm',
|
||||
field: 'height_cm',
|
||||
type: 'text',
|
||||
prompt: 'Hur lång är du? (cm)',
|
||||
placeholder: '175',
|
||||
inputType: 'number',
|
||||
unit: 'cm',
|
||||
validate: value => (value > 50 && value < 260 ? '' : 'Skriv in din längd i cm.')
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
field: 'weight',
|
||||
type: 'text',
|
||||
prompt: 'Vad väger du just nu? (kg)',
|
||||
placeholder: '75',
|
||||
inputType: 'number',
|
||||
unit: 'kg',
|
||||
validate: value => (value > 20 && value < 300 ? '' : 'Skriv in din vikt i kg.')
|
||||
},
|
||||
{
|
||||
id: 'neck_cm',
|
||||
field: 'neck_cm',
|
||||
type: 'text',
|
||||
prompt: 'Om du vet: halsmått i cm?',
|
||||
placeholder: '38',
|
||||
inputType: 'number',
|
||||
unit: 'cm',
|
||||
optional: true
|
||||
},
|
||||
{
|
||||
id: 'waist_cm',
|
||||
field: 'waist_cm',
|
||||
type: 'text',
|
||||
prompt: 'Midjemått i cm?',
|
||||
placeholder: '85',
|
||||
inputType: 'number',
|
||||
unit: 'cm',
|
||||
optional: true
|
||||
},
|
||||
{
|
||||
id: 'hip_cm',
|
||||
field: 'hip_cm',
|
||||
type: 'text',
|
||||
prompt: 'Höftmått i cm?',
|
||||
placeholder: '95',
|
||||
inputType: 'number',
|
||||
unit: 'cm',
|
||||
optional: true,
|
||||
shouldAsk: values => values.gender === 'female'
|
||||
},
|
||||
{
|
||||
id: 'bench_1rm',
|
||||
field: 'bench_1rm',
|
||||
type: 'text',
|
||||
prompt: 'Har du en uppskattad 1RM i bänkpress? (valfritt, kg)',
|
||||
placeholder: '100',
|
||||
inputType: 'number',
|
||||
unit: 'kg',
|
||||
optional: true,
|
||||
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
|
||||
},
|
||||
{
|
||||
id: 'squat_1rm',
|
||||
field: 'squat_1rm',
|
||||
type: 'text',
|
||||
prompt: '1RM i knäböj? (valfritt, kg)',
|
||||
placeholder: '140',
|
||||
inputType: 'number',
|
||||
unit: 'kg',
|
||||
optional: true,
|
||||
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
|
||||
},
|
||||
{
|
||||
id: 'deadlift_1rm',
|
||||
field: 'deadlift_1rm',
|
||||
type: 'text',
|
||||
prompt: '1RM i marklyft? (valfritt, kg)',
|
||||
placeholder: '160',
|
||||
inputType: 'number',
|
||||
unit: 'kg',
|
||||
optional: true,
|
||||
shouldAsk: values => values.experience_level && values.experience_level !== 'beginner'
|
||||
}
|
||||
]
|
||||
}, [])
|
||||
|
||||
const currentQuestion = questions[currentIndex]
|
||||
|
||||
const addMessage = (message) => {
|
||||
messageIdRef.current += 1
|
||||
setMessages(prev => [...prev, { id: messageIdRef.current, ...message }])
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (endRef.current) {
|
||||
endRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages, isTyping])
|
||||
|
||||
const getPrompt = (question, values) => {
|
||||
if (!question) return ''
|
||||
return typeof question.prompt === 'function' ? question.prompt(values) : question.prompt
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 && currentQuestion) {
|
||||
addMessage({ sender: 'coach', text: getPrompt(currentQuestion, data), questionIndex: currentIndex })
|
||||
}
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
|
||||
}
|
||||
}, [messages.length, currentQuestion, currentIndex, data])
|
||||
|
||||
const applyAnswer = (values, question, value) => {
|
||||
if (!question.field) return values
|
||||
return { ...values, [question.field]: value }
|
||||
}
|
||||
|
||||
const rebuildDataFromAnswers = (updatedAnswers) => {
|
||||
return updatedAnswers.reduce((acc, answer) => {
|
||||
const question = questions[answer.questionIndex]
|
||||
if (!question) return acc
|
||||
return applyAnswer(acc, question, answer.value)
|
||||
}, { ...initialData })
|
||||
}
|
||||
|
||||
const findNextIndex = (startIndex, nextData) => {
|
||||
for (let i = startIndex + 1; i < questions.length; i += 1) {
|
||||
const question = questions[i]
|
||||
if (!question?.shouldAsk || question.shouldAsk(nextData)) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isMeasurementQuestion = (questionId) => ['weight', 'neck_cm', 'waist_cm', 'hip_cm'].includes(questionId)
|
||||
const isStrengthQuestion = (questionId) => ['bench_1rm', 'squat_1rm', 'deadlift_1rm'].includes(questionId)
|
||||
const isProfileQuestion = (questionId) => ['gender', 'age', 'height_cm', 'experience_level', 'goal', 'workouts_per_week'].includes(questionId)
|
||||
|
||||
const saveProfile = async (values, complete = false) => {
|
||||
const payload = {
|
||||
gender: values.gender || null,
|
||||
age: toNumberOrNull(values.age),
|
||||
height_cm: toNumberOrNull(values.height_cm),
|
||||
experience_level: values.experience_level || null,
|
||||
goal: values.goal || null,
|
||||
workouts_per_week: toNumberOrNull(values.workouts_per_week),
|
||||
onboarding_complete: complete
|
||||
}
|
||||
await updateProfile(payload)
|
||||
}
|
||||
|
||||
const saveMeasurements = async (values) => {
|
||||
const bodyFat = calcBodyFat(
|
||||
values.gender,
|
||||
toNumberOrNull(values.waist_cm),
|
||||
toNumberOrNull(values.neck_cm),
|
||||
toNumberOrNull(values.hip_cm),
|
||||
toNumberOrNull(values.height_cm)
|
||||
)
|
||||
|
||||
if (!values.weight && !values.neck_cm && !values.waist_cm && !values.hip_cm) return
|
||||
|
||||
await fetch(`${API}/user/measurements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
weight: toNumberOrNull(values.weight),
|
||||
neck_cm: toNumberOrNull(values.neck_cm),
|
||||
waist_cm: toNumberOrNull(values.waist_cm),
|
||||
hip_cm: toNumberOrNull(values.hip_cm),
|
||||
body_fat_pct: bodyFat
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const saveStrength = async (values) => {
|
||||
if (!values.bench_1rm && !values.squat_1rm && !values.deadlift_1rm) return
|
||||
|
||||
await fetch(`${API}/user/strength`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
bench_1rm: toNumberOrNull(values.bench_1rm),
|
||||
squat_1rm: toNumberOrNull(values.squat_1rm),
|
||||
deadlift_1rm: toNumberOrNull(values.deadlift_1rm)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const maybeAutoSave = async (questionId, nextData, nextIndex) => {
|
||||
if (isProfileQuestion(questionId)) {
|
||||
await saveProfile(nextData, false)
|
||||
}
|
||||
|
||||
if (isMeasurementQuestion(questionId)) {
|
||||
const nextQuestion = nextIndex !== null ? questions[nextIndex] : null
|
||||
if (!nextQuestion || !isMeasurementQuestion(nextQuestion.id)) {
|
||||
await saveMeasurements(nextData)
|
||||
}
|
||||
}
|
||||
|
||||
if (isStrengthQuestion(questionId)) {
|
||||
const nextQuestion = nextIndex !== null ? questions[nextIndex] : null
|
||||
if (!nextQuestion || !isStrengthQuestion(nextQuestion.id)) {
|
||||
await saveStrength(nextData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnswer = async (answerValue, answerLabel = null) => {
|
||||
if (!currentQuestion) return
|
||||
const label = answerLabel ?? `${answerValue}`
|
||||
|
||||
setError('')
|
||||
|
||||
const nextData = applyAnswer(data, currentQuestion, answerValue)
|
||||
const nextIndex = findNextIndex(currentIndex, nextData)
|
||||
|
||||
addMessage({ sender: 'user', text: label, questionIndex: currentIndex })
|
||||
setAnswers(prev => [...prev, { questionIndex: currentIndex, value: answerValue, label }])
|
||||
setData(nextData)
|
||||
setInputValue('')
|
||||
|
||||
try {
|
||||
await maybeAutoSave(currentQuestion.id, nextData, nextIndex)
|
||||
} catch (err) {
|
||||
console.error('Autosave error:', err)
|
||||
}
|
||||
|
||||
if (nextIndex === null) {
|
||||
setIsTyping(true)
|
||||
typingTimeoutRef.current = setTimeout(async () => {
|
||||
addMessage({
|
||||
sender: 'coach',
|
||||
text: 'Perfekt! Jag har allt jag behöver. Låt mig bygga ditt program...',
|
||||
questionIndex: currentIndex + 1
|
||||
})
|
||||
setIsTyping(false)
|
||||
await finishOnboarding(nextData)
|
||||
}, 700)
|
||||
return
|
||||
}
|
||||
|
||||
setIsTyping(true)
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
addMessage({ sender: 'coach', text: getPrompt(questions[nextIndex], nextData), questionIndex: nextIndex })
|
||||
setCurrentIndex(nextIndex)
|
||||
setIsTyping(false)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const finishOnboarding = async (values) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await saveProfile(values, true)
|
||||
await saveMeasurements(values)
|
||||
await saveStrength(values)
|
||||
if (refreshProfile) await refreshProfile()
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
console.error('Onboarding error:', err)
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextSubmit = () => {
|
||||
if (!currentQuestion) return
|
||||
const raw = inputValue.trim()
|
||||
if (!raw && !currentQuestion.optional) {
|
||||
setError('Skriv ett svar för att gå vidare.')
|
||||
return
|
||||
}
|
||||
|
||||
let numericValue = raw
|
||||
if (currentQuestion.inputType === 'number' && raw) {
|
||||
const parsed = Number(raw)
|
||||
if (Number.isNaN(parsed)) {
|
||||
setError('Skriv ett giltigt nummer.')
|
||||
return
|
||||
}
|
||||
const validationMessage = currentQuestion.validate ? currentQuestion.validate(parsed) : ''
|
||||
if (validationMessage) {
|
||||
setError(validationMessage)
|
||||
return
|
||||
}
|
||||
numericValue = parsed
|
||||
}
|
||||
|
||||
if (!raw && currentQuestion.optional) {
|
||||
handleAnswer('', 'Hoppar')
|
||||
return
|
||||
}
|
||||
|
||||
const label = currentQuestion.unit ? `${raw} ${currentQuestion.unit}` : raw
|
||||
handleAnswer(numericValue, label)
|
||||
}
|
||||
|
||||
const handleQuickReply = (option) => {
|
||||
if (option.action === 'back') {
|
||||
handleBack()
|
||||
return
|
||||
}
|
||||
if (option.action === 'skip') {
|
||||
handleAnswer('', 'Hoppar')
|
||||
return
|
||||
}
|
||||
handleAnswer(option.value, option.label)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (!answers.length) return
|
||||
const lastAnswer = answers[answers.length - 1]
|
||||
const targetIndex = lastAnswer.questionIndex
|
||||
const trimmedMessages = [...messages]
|
||||
const lastCoachIndex = trimmedMessages
|
||||
.map((msg, idx) => (msg.sender === 'coach' && msg.questionIndex === targetIndex ? idx : -1))
|
||||
.filter(idx => idx !== -1)
|
||||
.pop()
|
||||
|
||||
if (lastCoachIndex !== undefined) {
|
||||
trimmedMessages.splice(lastCoachIndex + 1)
|
||||
}
|
||||
|
||||
const updatedAnswers = answers.slice(0, -1)
|
||||
setAnswers(updatedAnswers)
|
||||
setMessages(trimmedMessages)
|
||||
setCurrentIndex(targetIndex)
|
||||
setData(rebuildDataFromAnswers(updatedAnswers))
|
||||
setIsTyping(false)
|
||||
setInputValue('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const renderInputArea = () => {
|
||||
if (!currentQuestion) return null
|
||||
|
||||
if (currentQuestion.type === 'options') {
|
||||
const options = [...currentQuestion.options]
|
||||
const actionOptions = []
|
||||
if (currentQuestion.optional) {
|
||||
actionOptions.push({ label: 'Hoppa över', action: 'skip', variant: 'ghost' })
|
||||
}
|
||||
if (answers.length) {
|
||||
actionOptions.push({ label: 'Ändra senaste', action: 'back', variant: 'ghost' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuickReplies
|
||||
options={[...options, ...actionOptions]}
|
||||
onSelect={handleQuickReply}
|
||||
disabled={isTyping || isSaving}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const actionOptions = []
|
||||
if (currentQuestion.optional) {
|
||||
actionOptions.push({ label: 'Hoppa över', action: 'skip', variant: 'ghost' })
|
||||
}
|
||||
if (answers.length) {
|
||||
actionOptions.push({ label: 'Ändra senaste', action: 'back', variant: 'ghost' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-area">
|
||||
<div className="chat-input-row">
|
||||
<input
|
||||
type={currentQuestion.inputType || 'text'}
|
||||
inputMode={currentQuestion.inputType === 'number' ? 'numeric' : 'text'}
|
||||
placeholder={currentQuestion.placeholder}
|
||||
value={inputValue}
|
||||
onChange={event => setInputValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') handleTextSubmit()
|
||||
}}
|
||||
disabled={isTyping || isSaving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={handleTextSubmit}
|
||||
disabled={isTyping || isSaving}
|
||||
>
|
||||
Skicka
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="chat-error">{error}</div>}
|
||||
<QuickReplies
|
||||
options={actionOptions}
|
||||
onSelect={handleQuickReply}
|
||||
disabled={isTyping || isSaving}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-onboarding">
|
||||
<div className="chat-shell">
|
||||
<header className="chat-header">
|
||||
<div>
|
||||
<p className="chat-subtitle">Coach</p>
|
||||
<h1>Personlig onboarding</h1>
|
||||
</div>
|
||||
<span className={`chat-status ${isSaving ? 'saving' : ''}`}>
|
||||
{isSaving ? 'Sparar...' : 'Redo'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map(message => (
|
||||
message.sender === 'coach' ? (
|
||||
<CoachMessage key={message.id} text={message.text} />
|
||||
) : (
|
||||
<UserMessage key={message.id} text={message.text} />
|
||||
)
|
||||
))}
|
||||
{isTyping && <CoachMessage typing />}
|
||||
<div ref={endRef}></div>
|
||||
</div>
|
||||
|
||||
<div className="chat-actions">
|
||||
{renderInputArea()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user