import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { validateAnswers } from '../../../backend/src/survey/validate.js'; import { surveyApi } from '../surveyApi.js'; const ALREADY = 'This survey has already been submitted'; /** * @param {Record} question * @param {unknown} value */ function formatAnswer(question, value) { if (value === undefined || value === null || value === '') { return '—'; } if (question.type === 'short_text' || question.type === 'long_text') { return String(value); } if (question.type === 'number' || question.type === 'percentage') { return question.type === 'percentage' ? `${value}%` : String(value); } if (question.type === 'single_choice' && value && typeof value === 'object') { const selected = question.options.find((option) => option.id === value.choice); const label = selected?.label ?? '—'; return value.choice === 'other' && value.other ? `${label}: ${value.other}` : label; } if (question.type === 'multi_choice' && value && typeof value === 'object') { const labels = (value.choices ?? []).map((choice) => { const selected = question.options.find((option) => option.id === choice); return choice === 'other' && value.other ? `${selected?.label}: ${value.other}` : selected?.label; }); return labels.filter(Boolean).join(', ') || '—'; } if ( (question.type === 'grid_percentage' || question.type === 'grid_number') && value && typeof value === 'object' ) { return question.rows .map((row) => { const cells = question.columns .map((column) => { const cell = value[row.id]?.[column.id]; const shown = cell === undefined || cell === '' ? '—' : cell; return `${column.label} ${shown}${question.type === 'grid_percentage' ? '%' : question.suffix || ''}`; }) .join(', '); return `${row.label}: ${cells}`; }) .join('; '); } if (question.type === 'likert_matrix' && value && typeof value === 'object') { return question.rows .map((row) => { const point = question.scale.find((item) => item.id === value[row.id]); return `${row.label}: ${point?.label ?? '—'}`; }) .join('; '); } return '—'; } /** * @param {{ question: Record, value: unknown, invalid?: boolean, hideLabel?: boolean, onChange: (value: unknown) => void }} props */ function QuestionField({ question, value, invalid = false, hideLabel = false, onChange }) { const legendClass = hideLabel ? 'visually-hidden' : undefined; if (question.type === 'help') { return

{question.text}

; } if (question.type === 'section_header') { return

{question.label}

; } if (question.type === 'short_text' || question.type === 'long_text') { const Input = question.type === 'long_text' ? 'textarea' : 'input'; return ( ); } if (question.type === 'number' || question.type === 'percentage') { return ( ); } if (question.type === 'single_choice' || question.type === 'multi_choice') { const current = value && typeof value === 'object' ? value : {}; return (
{question.label} {question.options.map((option) => ( ))} {question.other && (current.choice === 'other' || (current.choices ?? []).includes('other')) ? ( onChange({ ...current, other: event.target.value })} /> ) : null}
); } if (question.type === 'grid_percentage' || question.type === 'grid_number') { const grid = value && typeof value === 'object' ? value : {}; return (
{question.label} {question.columns.map((column) => ( ))} {question.rows.map((row) => ( {question.columns.map((column) => ( ))} ))} {question.type === 'grid_percentage' ? ( {question.columns.map((column) => { const numbers = question.rows.map((row) => grid[row.id]?.[column.id]); const filled = numbers.filter((cell) => typeof cell === 'number'); const total = filled.reduce((sum, cell) => sum + cell, 0); const ready = filled.length > 0; const exact = ready && total === question.sumTo; return ( ); })} ) : null}
Pack type {column.label}
{row.label} {column.label}
{ const raw = event.target.value; onChange({ ...grid, [row.id]: { ...(grid[row.id] ?? {}), [column.id]: raw === '' ? '' : Number(raw), }, }); }} /> {question.suffix ? ( {question.suffix} ) : null}
Total {ready ? exact ? `Total: ${total}%` : `Total: ${total}% — must equal ${question.sumTo}%` : 'Total: —'}
); } if (question.type === 'likert_matrix') { const selected = value && typeof value === 'object' ? value : {}; return (
{question.label} {question.scale.map((point) => ( ))} {question.rows.map((row) => ( {question.scale.map((point) => ( ))} ))}
Attribute {point.label}
{row.label} {point.label}
); } return null; } export default function SurveyPage() { const { token } = useParams(); const [screen, setScreen] = useState('loading'); const [message, setMessage] = useState(''); const [survey, setSurvey] = useState(null); const [answers, setAnswers] = useState({}); const [sectionIndex, setSectionIndex] = useState(0); const [error, setError] = useState(''); const [stepErrors, setStepErrors] = useState([]); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const suppressOpen = useRef(false); const answersRef = useRef(answers); const dirtyRef = useRef(false); const persistRef = useRef(async () => {}); const stepAlertRef = useRef(null); answersRef.current = answers; useEffect(() => { let active = true; surveyApi(`/api/survey/${token}`) .then((payload) => { if (!active) { return; } setSurvey(payload); setAnswers(payload.answers ?? {}); setScreen('form'); }) .catch((loadError) => { if (!active) { return; } setMessage(loadError.message); setScreen(loadError.message === ALREADY ? 'submitted' : 'invalid'); }); return () => { active = false; }; }, [token]); persistRef.current = async () => { if (!dirtyRef.current) { return; } dirtyRef.current = false; try { await surveyApi(`/api/survey/${token}/progress`, { method: 'PUT', body: { answers: answersRef.current }, }); } catch (saveError) { dirtyRef.current = true; setError(saveError.message); } }; useEffect(() => { if (screen !== 'form') { return undefined; } const timer = setInterval(() => { persistRef.current(); }, 10000); return () => clearInterval(timer); }, [screen, token]); const shownSection = useRef(sectionIndex); useEffect(() => { if (shownSection.current === sectionIndex) { return; } shownSection.current = sectionIndex; const page = document.scrollingElement || document.documentElement; page.scrollTo?.({ top: 0, behavior: 'smooth' }); }, [sectionIndex]); useEffect(() => { if (stepErrors.length > 0) { stepAlertRef.current?.scrollIntoView?.({ block: 'nearest' }); } }, [stepErrors]); function updateAnswer(id, value) { dirtyRef.current = true; setStepErrors([]); setAnswers((current) => ({ ...current, [id]: value })); } /** * @param {{ questions: Record[] }} section */ function problemsFor(section) { return validateAnswers({ sections: [section] }, answersRef.current); } async function goNext() { const section = survey.definition.sections[sectionIndex]; const problems = problemsFor(section); if (problems.length > 0) { setStepErrors(problems); return; } setStepErrors([]); setBusy(true); await persistRef.current(); setSectionIndex((current) => current + 1); setBusy(false); } function openConfirm() { if (suppressOpen.current) { return; } const sections = survey.definition.sections; for (let index = 0; index < sections.length; index += 1) { const problems = problemsFor(sections[index]); if (problems.length > 0) { setStepErrors(problems); setSectionIndex(index); return; } } setConfirming(true); } function closeConfirm() { suppressOpen.current = true; setConfirming(false); window.setTimeout(() => { suppressOpen.current = false; }, 400); } async function submit() { setBusy(true); setError(''); try { await surveyApi(`/api/survey/${token}/submit`, { method: 'POST', body: { answers: answersRef.current }, }); setConfirming(false); setScreen('thanks'); } catch (submitError) { closeConfirm(); setError(submitError.message); if (submitError.message === ALREADY) { setScreen('submitted'); setMessage(submitError.message); } } finally { setBusy(false); } } if (screen === 'loading') { return (

Loading the survey…

); } if (screen === 'invalid') { return (

Survey link

{message}

); } if (screen === 'submitted') { return (

Already submitted

{message}

); } if (screen === 'thanks') { return (

Thank you

Your survey has been submitted.

); } const sections = survey.definition.sections; const review = sectionIndex >= sections.length; const section = sections[sectionIndex]; const progress = Math.round((sectionIndex / sections.length) * 100); return (

{survey.title}

{review ? (

Review

{sections.map((item) => (

{item.title}

{item.questions .filter( (question) => question.type !== 'help' && question.type !== 'section_header', ) .map((question) => (

{question.label}
{formatAnswer(question, answers[question.id])}

))}
))}
) : (

{section.title}

{stepErrors.length > 0 ? (
{stepErrors.map((item) => (

{item.message}

))}
) : null}
{section.questions.map((question, index) => ( item.id === question.id)} value={answers[question.id]} onChange={(value) => updateAnswer(question.id, value)} /> ))}
)} {error ? (

{error}

) : null}
{sectionIndex > 0 ? ( ) : null} {review ? ( ) : ( )}
{confirming ? (

Submit survey

You cannot change answers after this.

) : null}
); }