250 lines
7.8 KiB
TypeScript
250 lines
7.8 KiB
TypeScript
'use client'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { FormAnswers, Question, Survey } from '@/lib/types'
|
|
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
|
|
import { getHiddenCodes, isQuestionVisible } from '@/lib/conditional'
|
|
import { ProgressBar } from './progress-bar'
|
|
import { QuestionRenderer } from './question-renderer'
|
|
import { SummaryScreen } from './summary-screen'
|
|
|
|
// Section metadata (code prefix → display title)
|
|
const SECTIONS: { prefix: string; title: string; sensitive?: boolean }[] = [
|
|
{ prefix: 'A', title: 'Datos Generales' },
|
|
{ prefix: 'B', title: '¿Vivís con discapacidad?', sensitive: true },
|
|
{ prefix: 'C', title: 'Familiares con Discapacidad', sensitive: true },
|
|
{ prefix: 'D', title: 'Adultos Mayores a Cargo', sensitive: true },
|
|
{ prefix: 'E', title: 'Tu Rol como Cuidador/a' },
|
|
{ prefix: 'F', title: 'Impacto Económico' },
|
|
{ prefix: 'G', title: 'Bienestar y Descanso' },
|
|
{ prefix: 'H', title: 'Necesidades' },
|
|
]
|
|
|
|
function getSectionPrefix(code: string) {
|
|
return code.replace(/\d+$/, '')
|
|
}
|
|
|
|
interface SurveyFormProps {
|
|
survey: Survey
|
|
questions: Question[]
|
|
}
|
|
|
|
export function SurveyForm({ survey, questions }: SurveyFormProps) {
|
|
const [answers, setAnswers] = useState<FormAnswers>({})
|
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
const [submitted, setSubmitted] = useState(false)
|
|
const [hydrated, setHydrated] = useState(false)
|
|
|
|
// Announce when conditional blocks appear/disappear
|
|
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
|
|
|
// Track which trigger codes had "Sí" on prev render to detect toggle
|
|
const prevVisibility = useRef<Record<string, boolean>>({})
|
|
|
|
// Hydrate from localStorage on mount
|
|
useEffect(() => {
|
|
const draft = loadDraft(survey.id)
|
|
if (Object.keys(draft).length > 0) setAnswers(draft)
|
|
setHydrated(true)
|
|
}, [survey.id])
|
|
|
|
// Persist to localStorage on every change (after hydration)
|
|
useEffect(() => {
|
|
if (!hydrated) return
|
|
saveDraft(survey.id, answers)
|
|
}, [answers, hydrated, survey.id])
|
|
|
|
const handleChange = useCallback(
|
|
(code: string, value: FormAnswers[string]) => {
|
|
setAnswers((prev) => {
|
|
const updated = { ...prev, [code]: value }
|
|
|
|
// Clear any dependent questions that become hidden
|
|
const toHide = getHiddenCodes(code, value, questions, prev)
|
|
for (const hiddenCode of toHide) {
|
|
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
|
|
}
|
|
|
|
// Announce block visibility change to screen readers
|
|
if (toHide.length > 0) {
|
|
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
|
|
} else {
|
|
// Check if a conditional block just became visible
|
|
const appeared = questions.some(
|
|
(q) =>
|
|
q.conditional_rules?.some((r) => r.if_question === code) &&
|
|
isQuestionVisible(q, updated) &&
|
|
!isQuestionVisible(q, prev)
|
|
)
|
|
if (appeared) {
|
|
setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
|
|
}
|
|
}
|
|
|
|
return updated
|
|
})
|
|
|
|
// Clear validation error for this field
|
|
setErrors((prev) => {
|
|
if (!prev[code]) return prev
|
|
const { [code]: _, ...rest } = prev
|
|
return rest
|
|
})
|
|
},
|
|
[questions]
|
|
)
|
|
|
|
function validate(): boolean {
|
|
const newErrors: Record<string, string> = {}
|
|
for (const q of questions) {
|
|
if (!q.required) continue
|
|
if (!isQuestionVisible(q, answers)) continue
|
|
const val = answers[q.code]
|
|
if (
|
|
val === null ||
|
|
val === undefined ||
|
|
val === '' ||
|
|
(Array.isArray(val) && val.length === 0)
|
|
) {
|
|
newErrors[q.code] = 'Este campo es obligatorio.'
|
|
}
|
|
}
|
|
setErrors(newErrors)
|
|
return Object.keys(newErrors).length === 0
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!validate()) {
|
|
// Move focus to first error
|
|
const firstErrorCode = Object.keys(errors)[0]
|
|
if (firstErrorCode) {
|
|
document.getElementById(firstErrorCode)?.focus()
|
|
}
|
|
return
|
|
}
|
|
// 2A: no write to DB — just show summary
|
|
clearDraft(survey.id)
|
|
setSubmitted(true)
|
|
}
|
|
|
|
function handleStartOver() {
|
|
setAnswers({})
|
|
setSubmitted(false)
|
|
setErrors({})
|
|
}
|
|
|
|
// Visible questions driven by conditional_rules
|
|
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
|
|
const answeredCount = visibleQuestions.filter((q) => {
|
|
const v = answers[q.code]
|
|
return v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)
|
|
}).length
|
|
|
|
if (submitted) {
|
|
return (
|
|
<SummaryScreen
|
|
surveyTitle={survey.title}
|
|
questions={questions}
|
|
answers={answers}
|
|
onStartOver={handleStartOver}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Group visible questions by section prefix, in order
|
|
type Section = { prefix: string; title: string; sensitive?: boolean; questions: Question[] }
|
|
const sectionMap = new Map<string, Section>()
|
|
for (const q of visibleQuestions) {
|
|
const prefix = getSectionPrefix(q.code)
|
|
if (!sectionMap.has(prefix)) {
|
|
const meta = SECTIONS.find((s) => s.prefix === prefix)
|
|
sectionMap.set(prefix, {
|
|
prefix,
|
|
title: meta?.title ?? prefix,
|
|
sensitive: meta?.sensitive,
|
|
questions: [],
|
|
})
|
|
}
|
|
sectionMap.get(prefix)!.questions.push(q)
|
|
}
|
|
|
|
return (
|
|
<div className="survey-wrapper">
|
|
{/* Skip link for keyboard users */}
|
|
<a href="#survey-form" className="skip-link">
|
|
Ir al formulario
|
|
</a>
|
|
|
|
{/* Screen-reader live region for conditional announcements */}
|
|
<div
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
className="sr-only"
|
|
role="status"
|
|
>
|
|
{a11yAnnounce}
|
|
</div>
|
|
|
|
<header className="survey-header">
|
|
<h1 className="survey-title">{survey.title}</h1>
|
|
<p className="survey-anon-note">
|
|
Esta encuesta es anónima. Tus respuestas no te identifican.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="survey-progress-sticky" aria-label="Barra de progreso">
|
|
<ProgressBar answered={answeredCount} total={visibleQuestions.length} />
|
|
</div>
|
|
|
|
<main id="survey-form">
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
noValidate
|
|
aria-label={`Encuesta: ${survey.title}`}
|
|
>
|
|
{Array.from(sectionMap.values()).map((section) => (
|
|
<section
|
|
key={section.prefix}
|
|
className="survey-section"
|
|
aria-labelledby={`section-${section.prefix}`}
|
|
>
|
|
<div className="section-header">
|
|
<h2 id={`section-${section.prefix}`} className="section-title">
|
|
{section.title}
|
|
</h2>
|
|
{section.sensitive && (
|
|
<p className="section-sensitive-note" role="note">
|
|
Esta sección contiene preguntas sensibles sobre salud o discapacidad.
|
|
Responder es completamente voluntario.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{section.questions.map((q) => (
|
|
<div
|
|
key={q.id}
|
|
className="question-wrapper"
|
|
data-code={q.code}
|
|
>
|
|
<QuestionRenderer
|
|
question={q}
|
|
answers={answers}
|
|
onChange={handleChange}
|
|
errors={errors}
|
|
/>
|
|
</div>
|
|
))}
|
|
</section>
|
|
))}
|
|
|
|
<div className="form-footer">
|
|
<button type="submit" className="btn btn-primary">
|
|
Ver resumen de respuestas
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|