Files
motor-de-encuestas/components/wizard-form.tsx

267 lines
9.6 KiB
TypeScript

'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { BrandConfig, Survey } from '@/lib/types'
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
import {
answerKey,
expandInstances,
initialState,
next,
back,
resolveScreens,
setAnswer,
} from '@/lib/form-engine'
import { loadDraft, saveDraft } from '@/lib/storage'
import { ConsentScreen } from './consent-screen'
import { ProgressBar } from './progress-bar'
import { QuestionRenderer } from './question-renderer'
type Stage = 'consent' | 'form'
interface WizardFormProps {
survey: Survey
schema: FormSchema
}
function isEmpty(v: AnswerValue): boolean {
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
}
function valueFor(screen: Screen, answers: FormAnswers): AnswerValue {
if (!screen.question) return null
const v = answers[answerKey(screen.question.code, screen.instanceId ?? undefined)]
return v === undefined ? null : v
}
/** Resume en la primera pantalla sin responder (o la sección que la precede). */
function resumeScreenId(schema: FormSchema, answers: FormAnswers): string {
const screens = resolveScreens(schema, answers)
const firstUnanswered = screens.findIndex(
(s) => s.kind === 'question' && isEmpty(valueFor(s, answers))
)
if (firstUnanswered <= 0) return screens[0]?.id ?? 'end'
const prev = screens[firstUnanswered - 1]
return prev.kind === 'section' ? prev.id : screens[firstUnanswered].id
}
export function WizardForm({ survey, schema }: WizardFormProps) {
const [stage, setStage] = useState<Stage>('consent')
const [consentOperativo, setConsentOperativo] = useState(false)
const [consentMacroN1, setConsentMacroN1] = useState(false)
const [state, setState] = useState(() => initialState(schema))
const [errors, setErrors] = useState<Record<string, string>>({})
const [hydrated, setHydrated] = useState(false)
const [a11yAnnounce, setA11yAnnounce] = useState('')
const answerAreaRef = useRef<HTMLDivElement>(null)
const [hasOverflow, setHasOverflow] = useState(false)
useEffect(() => {
const draft = loadDraft(survey.id)
if (Object.keys(draft).length > 0) {
setState({ answers: draft, currentScreenId: resumeScreenId(schema, draft) })
}
setHydrated(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey.id])
useEffect(() => {
if (!hydrated) return
saveDraft(survey.id, state.answers)
}, [state.answers, hydrated, survey.id])
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const screen = screens[idx] ?? screens[0]
useEffect(() => {
if (!screen) return
if (screen.kind === 'section') setA11yAnnounce(`Sección: ${screen.question?.prompt}`)
else if (screen.kind === 'question') setA11yAnnounce(`Pregunta ${screen.question?.code}: ${screen.question?.prompt}`)
else setA11yAnnounce('Fin del formulario')
}, [screen?.id])
// El fade de wizard-answer-area solo debe aparecer si hay overflow real —
// CSS no puede expresar esa condición (no hay selector para "este elemento
// scrollea"), así que se mide el DOM. Se remide al cambiar de pantalla
// (cambia todo el contenido) y al resize (puede cambiar cuántas opciones entran).
useLayoutEffect(() => {
const el = answerAreaRef.current
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
}, [screen?.id])
useEffect(() => {
function recalc() {
const el = answerAreaRef.current
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
}
window.addEventListener('resize', recalc)
return () => window.removeEventListener('resize', recalc)
}, [])
if (stage === 'consent') {
return (
<ConsentScreen
brandConfig={survey.brand_config}
onAccept={(op, macro) => {
setConsentOperativo(op)
setConsentMacroN1(macro)
setStage('form')
}}
/>
)
}
if (!screen) return null
const currentValue = valueFor(screen, state.answers)
const questionScreens = screens.filter((s) => s.kind !== 'end')
const ordinal = Math.min(idx + 1, questionScreens.length)
const sectionScreens = screens.filter((s) => s.kind === 'section')
const sectionOrdinal = screen.kind === 'section' ? sectionScreens.findIndex((s) => s.id === screen.id) + 1 : null
// Contexto de instancia ("Familiar X de N") — derivado, no decide visibilidad ni navegación.
let instanceCtx: { label: string; ordinal: number; total: number } | null = null
if (screen.instanceId && screen.question?.instance_of) {
const generator = schema.questions.find((q) => q.code === screen.question!.instance_of)
if (generator) {
const instances = expandInstances(generator, state.answers)
const ord = instances.findIndex((i) => i.instanceId === screen.instanceId)
if (ord >= 0) {
instanceCtx = { label: instances[ord].optionValue, ordinal: ord + 1, total: instances.length }
}
}
}
function handleChange(value: AnswerValue) {
if (!screen.question) return
const code = screen.question.code
const instanceId = screen.instanceId ?? undefined
setState((prev) => {
const updated = setAnswer(prev, code, value, instanceId)
// Limpia respuestas de pantallas que dejaron de ser visibles tras este cambio.
const before = resolveScreens(schema, prev.answers)
const after = resolveScreens(schema, updated.answers)
const afterIds = new Set(after.map((s) => s.id))
const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question)
if (disappeared.length === 0) return updated
const clearedAnswers = { ...updated.answers }
for (const s of disappeared) {
delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)]
}
return { ...updated, answers: clearedAnswers }
})
setErrors((prev) => {
if (!prev[code]) return prev
const { [code]: _, ...rest } = prev
return rest
})
}
function handleNext() {
if (screen.kind === 'question' && screen.question?.required && isEmpty(currentValue)) {
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return
}
setState((s) => next(s, schema))
}
function handleBack() {
setState((s) => back(s, schema))
}
const bc: BrandConfig | null = survey.brand_config
return (
<div className="wizard-shell">
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
{a11yAnnounce}
</div>
<div className="wizard-topbar">
{bc?.org_name && (
<p className="brand-text wizard-topbar-brand">
Encuesta de <strong>{bc.org_name}</strong>
</p>
)}
<ProgressBar answered={ordinal} total={questionScreens.length} />
</div>
<main className="wizard-stage" id="wizard-stage">
{screen.kind === 'section' && screen.question && (
<div className="wizard-section-screen" role="region" aria-labelledby="wizard-section-title">
<p className="wizard-section-number">
Sección {sectionOrdinal} de {sectionScreens.length}
</p>
<h2 id="wizard-section-title" className="wizard-section-title">
{screen.question.prompt}
</h2>
{screen.question.hint && <p className="wizard-section-desc">{screen.question.hint}</p>}
</div>
)}
{screen.kind === 'question' && screen.question && (
<>
<div className="wizard-prompt-block">
{instanceCtx && (
<span className="wizard-instance-badge">
{instanceCtx.label} · familiar {instanceCtx.ordinal} de {instanceCtx.total}
</span>
)}
<span className="wizard-prompt-code">{screen.question.code}</span>
<h2 className="wizard-prompt-text">{screen.question.prompt}</h2>
{screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>}
</div>
<div
ref={answerAreaRef}
className={`wizard-answer-area${hasOverflow ? ' has-overflow' : ''}`}
>
<QuestionRenderer
question={screen.question}
value={currentValue}
onChange={handleChange}
error={errors[screen.question.code]}
/>
</div>
</>
)}
{screen.kind === 'end' && (
<div className="wizard-section-screen">
<h2 className="wizard-section-title">Llegaste al final</h2>
<p className="wizard-section-desc">
El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus
respuestas con &quot;Anterior&quot;.
</p>
</div>
)}
</main>
<div className="wizard-bottombar">
<button
type="button"
className="btn btn-secondary"
onClick={handleBack}
disabled={idx === 0}
aria-disabled={idx === 0}
>
Anterior
</button>
<span className="wizard-bottombar-hint" aria-hidden="true">
{ordinal}/{questionScreens.length}
</span>
{screen.kind !== 'end' ? (
<button type="button" className="btn btn-primary" onClick={handleNext}>
Siguiente
</button>
) : (
<button type="button" className="btn btn-primary btn-disabled" disabled aria-disabled="true">
Enviar (próximamente)
</button>
)}
</div>
</div>
)
}