geo_paraguay_full.json se cargaba dos veces sin cache compartido. Módulo lib/geo-cache.ts singleton: un fetch para toda la sesión. Feedback visual mientras carga (disabled + placeholder) y si falla (mensaje + texto libre). debounce 500ms en saveDraft para no escribir en localStorage en cada keystroke. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
'use client'
|
|
import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'
|
|
import type { BrandConfig, Survey } from '@/lib/types'
|
|
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
|
|
import {
|
|
answerKey,
|
|
applyAnswer,
|
|
expandInstances,
|
|
initialState,
|
|
isRequiredAndUnanswered,
|
|
next,
|
|
back,
|
|
resolveScreens,
|
|
} from '@/lib/form-engine'
|
|
import { loadDraft, saveDraft, clearDraft } from '@/lib/storage'
|
|
import { submitSurvey } from '@/app/actions/submit-survey'
|
|
import { ConsentScreen } from './consent-screen'
|
|
import { ProgressBar } from './progress-bar'
|
|
import { QuestionRenderer } from './question-renderer'
|
|
|
|
type Stage = 'consent' | 'form' | 'success' | 'error'
|
|
|
|
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)
|
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
|
const [isPending, startTransition] = useTransition()
|
|
|
|
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
|
|
const timer = setTimeout(() => saveDraft(survey.id, state.answers), 500)
|
|
return () => clearTimeout(timer)
|
|
}, [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 (stage === 'success') {
|
|
return (
|
|
<div className="survey-wrapper">
|
|
<div className="thank-you-container" role="main">
|
|
<h1 className="thank-you-title">¡Gracias por participar!</h1>
|
|
<p className="thank-you-body">
|
|
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
|
|
forma de vincular tus respuestas con tu identidad.
|
|
</p>
|
|
<p className="thank-you-body">
|
|
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
|
|
diseñar mejores apoyos para las personas cuidadoras.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (stage === 'error') {
|
|
return (
|
|
<div className="survey-wrapper">
|
|
<div className="error-container" role="main">
|
|
<h1 className="error-title">No se pudo enviar la encuesta</h1>
|
|
<p>Hubo un error al guardar tus respuestas. Podés intentarlo de nuevo.</p>
|
|
{submitError && <pre className="no-survey-hint">{submitError}</pre>}
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => { setSubmitError(null); setStage('form') }}
|
|
>
|
|
Volver e intentar de nuevo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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) => applyAnswer(prev, schema, code, value, instanceId))
|
|
|
|
setErrors((prev) => {
|
|
if (!prev[code]) return prev
|
|
const { [code]: _, ...rest } = prev
|
|
return rest
|
|
})
|
|
}
|
|
|
|
function handleNext() {
|
|
if (isRequiredAndUnanswered(screen, state.answers)) {
|
|
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
|
|
return
|
|
}
|
|
saveDraft(survey.id, state.answers)
|
|
setState((s) => next(s, schema))
|
|
}
|
|
|
|
function handleBack() {
|
|
saveDraft(survey.id, state.answers)
|
|
setState((s) => back(s, schema))
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (isPending) return
|
|
// state.answers usa claves "code:instanceId" — se descompone una sola vez
|
|
// acá, al borde cliente/servidor; el validador (lib/submit-validation.ts)
|
|
// recibe la forma explícita {code, instanceId, value}[].
|
|
const answers = Object.entries(state.answers).map(([key, value]) => {
|
|
const sep = key.indexOf(':')
|
|
return { code: key.slice(0, sep), instanceId: key.slice(sep + 1), value }
|
|
})
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await submitSurvey({
|
|
surveyId: survey.id,
|
|
answers,
|
|
consentOperativo,
|
|
consentMacroN1,
|
|
})
|
|
if (result.success) {
|
|
clearDraft(survey.id)
|
|
setStage('success')
|
|
} else {
|
|
setSubmitError(result.error)
|
|
setStage('error')
|
|
}
|
|
} catch {
|
|
// Falla de red al invocar el Server Action (fetch failed) — no es un
|
|
// error que submitSurvey pueda devolver como result, hay que capturarlo acá.
|
|
setSubmitError('No pudimos enviar tu respuesta. Verificá tu conexión e intentá de nuevo.')
|
|
setStage('error')
|
|
}
|
|
})
|
|
}
|
|
|
|
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]}
|
|
answers={state.answers}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{screen.kind === 'end' && (
|
|
<div className="wizard-section-screen">
|
|
<h2 className="wizard-section-title">Llegaste al final</h2>
|
|
<p className="wizard-section-desc">
|
|
Revisá tus respuestas con "Anterior" si querés corregir algo, o enviá la
|
|
encuesta cuando estés listo/a.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<div className="wizard-bottombar">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={handleBack}
|
|
disabled={idx === 0 || isPending}
|
|
aria-disabled={idx === 0 || isPending}
|
|
>
|
|
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"
|
|
onClick={handleSubmit}
|
|
disabled={isPending}
|
|
aria-disabled={isPending}
|
|
>
|
|
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|