Files
motor-de-encuestas/components/survey-form.tsx
2026-06-04 14:30:28 -03:00

254 lines
8.0 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)
// Prefixes: 'A' for A1-A7, '1' for 1.1-1.16, '2' for 2.1-2.5, etc.
const SECTIONS: { prefix: string; title: string; sensitive?: boolean }[] = [
{ prefix: 'A', title: 'Datos Generales' },
{ prefix: '1', title: 'Prevalencia: cuidado y discapacidad', sensitive: true },
{ prefix: '2', title: 'Intensidad y rol del cuidado' },
{ prefix: '3', title: 'Impacto económico del cuidado' },
{ prefix: '4', title: 'Tiempo y descanso' },
{ prefix: '5', title: 'Impacto en el trabajo' },
{ prefix: '6', title: 'Conciencia organizacional' },
{ prefix: '7', title: 'Para cerrar' },
]
// Extracts the leading alpha or numeric segment from a question code.
// 'A1' → 'A', '1.1' → '1', '1.16' → '1', '6.4' → '6', '7.1' → '7'
function getSectionPrefix(code: string): string {
const m = code.match(/^([A-Za-z]+|\d+)/)
return m ? m[1] : code
}
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>
)
}