Con las mejoras visuales
This commit is contained in:
@@ -1,93 +1,69 @@
|
||||
'use client'
|
||||
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/types'
|
||||
import type { AnswerValue, Question, QuestionOption } from '@/lib/form-engine.types'
|
||||
import { RadioGroup } from './widgets/radio-group'
|
||||
import { CheckboxGroup } from './widgets/checkbox-group'
|
||||
import { RatingScale } from './widgets/rating-scale'
|
||||
import { TextAreaField } from './widgets/text-area-field'
|
||||
import { TextShortField } from './widgets/text-short-field'
|
||||
import { GeoSelectWidget } from './widgets/geo-select-widget'
|
||||
|
||||
interface QuestionRendererProps {
|
||||
question: Question
|
||||
answers: FormAnswers
|
||||
onChange: (code: string, value: FormAnswers[string]) => void
|
||||
errors: Record<string, string>
|
||||
value: AnswerValue
|
||||
onChange: (value: AnswerValue) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
function isArrayOptions(opts: unknown): opts is QuestionOption[] {
|
||||
return Array.isArray(opts)
|
||||
}
|
||||
const YES_NO_OPTIONS: QuestionOption[] = [
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' },
|
||||
]
|
||||
|
||||
function isRatingOptions(opts: unknown): opts is RatingOptions {
|
||||
return typeof opts === 'object' && opts !== null && 'min' in opts && 'max' in opts
|
||||
}
|
||||
|
||||
export function QuestionRenderer({
|
||||
question,
|
||||
answers,
|
||||
onChange,
|
||||
errors,
|
||||
}: QuestionRendererProps) {
|
||||
export function QuestionRenderer({ question, value, onChange, error }: QuestionRendererProps) {
|
||||
const { code, type, prompt, required, max_select, options } = question
|
||||
const error = errors[code]
|
||||
const legend = (
|
||||
<>
|
||||
<span className="q-code">{code} ·</span> {prompt}
|
||||
</>
|
||||
)
|
||||
|
||||
// A1 (Departamento) renders a combined geo widget that also handles A2 (Ciudad)
|
||||
// Code badges for A1/A2 are rendered inside GeoSelectWidget.
|
||||
if (code === 'A1') {
|
||||
return (
|
||||
<GeoSelectWidget
|
||||
departmentValue={(answers['A1'] as string) ?? ''}
|
||||
cityValue={(answers['A2'] as string) ?? ''}
|
||||
onDepartmentChange={(v) => onChange('A1', v)}
|
||||
onCityChange={(v) => onChange('A2', v)}
|
||||
departmentError={errors['A1']}
|
||||
cityError={errors['A2']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// A2 is rendered inside the GeoSelectWidget above — skip here to avoid duplication
|
||||
if (code === 'A2') return null
|
||||
|
||||
const legend = <><span className="q-code">{code} ·</span> {prompt}</>
|
||||
|
||||
if (type === 'single_choice' && isArrayOptions(options)) {
|
||||
if (type === 'single_choice' && options) {
|
||||
return (
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
options={options}
|
||||
value={answers[code] as string | undefined}
|
||||
value={(value as string) ?? undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'multiple_choice' && isArrayOptions(options)) {
|
||||
if (type === 'multiple_choice' && options) {
|
||||
return (
|
||||
<CheckboxGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
options={options}
|
||||
value={(answers[code] as string[]) ?? []}
|
||||
value={(value as string[]) ?? []}
|
||||
maxSelect={max_select}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'rating' && isRatingOptions(options)) {
|
||||
// yes_no resuelto con el RadioGroup existente — dos opciones fijas, sin widget nuevo.
|
||||
if (type === 'yes_no') {
|
||||
return (
|
||||
<RatingScale
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
ratingOptions={options}
|
||||
value={answers[code] as number | undefined}
|
||||
options={YES_NO_OPTIONS}
|
||||
value={(value as string) ?? undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
@@ -98,9 +74,9 @@ export function QuestionRenderer({
|
||||
<TextShortField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
@@ -111,9 +87,9 @@ export function QuestionRenderer({
|
||||
<TextAreaField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
|
||||
243
components/wizard-form.tsx
Normal file
243
components/wizard-form.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
import { useEffect, 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('')
|
||||
|
||||
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])
|
||||
|
||||
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 className="wizard-answer-area">
|
||||
<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 "Anterior".
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user