Compare commits
2 Commits
424071b97c
...
c30688810a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c30688810a | ||
|
|
f2666c7d3c |
148
app/globals.css
148
app/globals.css
@@ -482,6 +482,154 @@ body {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
WIZARD (4D) — layout de altura estable (UX 1+2+3)
|
||||
1) viewport estable: enunciado anclado en el tercio superior, no centrado
|
||||
2) header (progreso) y footer (Anterior/Siguiente) fijos, fuera del área
|
||||
que cambia de tamaño — nunca "saltan" entre pantallas
|
||||
3) área de respuesta con altura máxima + scroll interno + fade inferior
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
.wizard-shell {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100dvh - 160px); /* deja lugar al header/footer de marca (layout.tsx) */
|
||||
}
|
||||
|
||||
.wizard-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 12px 0;
|
||||
}
|
||||
.wizard-topbar-brand {
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Stage: altura fija, no auto-crece con el contenido de la pregunta actual. */
|
||||
.wizard-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-top: 48px; /* ancla el enunciado en el tercio superior, no centrado vertical */
|
||||
}
|
||||
|
||||
.wizard-section-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.wizard-section-number {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.wizard-section-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-section-desc {
|
||||
font-size: 1.0625rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Enunciado: bloque con altura reservada constante, mismo lugar pantalla tras pantalla. */
|
||||
.wizard-prompt-block {
|
||||
min-height: 116px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.wizard-instance-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-hover);
|
||||
background: var(--color-primary-light);
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wizard-prompt-code {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: #7A8FA0;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wizard-prompt-text {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.wizard-prompt-hint {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo. */
|
||||
.wizard-answer-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.wizard-answer-area::after {
|
||||
content: '';
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
height: 28px;
|
||||
margin-top: -28px;
|
||||
background: linear-gradient(to bottom, transparent, var(--color-surface) 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wizard-bottombar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 16px 0;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.wizard-bottombar-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wizard-prompt-text { font-size: 1.1875rem; }
|
||||
.wizard-section-title { font-size: 1.5rem; }
|
||||
.wizard-answer-area { max-height: 320px; }
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ── */
|
||||
.loading-wrapper {
|
||||
max-width: 720px;
|
||||
|
||||
32
app/page.tsx
32
app/page.tsx
@@ -1,6 +1,13 @@
|
||||
import { createClient } from '@/lib/supabase'
|
||||
import type { BrandConfig, Question, Survey } from '@/lib/types'
|
||||
import { SurveyForm } from '@/components/survey-form'
|
||||
import type { BrandConfig, Survey } from '@/lib/types'
|
||||
import type { FormSchema, Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||
import { buildFormSchema } from '@/lib/form-engine'
|
||||
import { WizardForm } from '@/components/wizard-form'
|
||||
|
||||
// Preguntas de consentimiento (S1, 1.1, 1.2): las resuelve ConsentScreen, no el
|
||||
// motor de flujo — su texto legal (ADR-003) no vive en el hint de estas filas
|
||||
// todavía. Se excluyen del FormSchema para que el wizard arranque en S2.
|
||||
const CONSENT_CODES = new Set(['S1', '1.1', '1.2'])
|
||||
|
||||
// Force dynamic rendering — page fetches from Supabase at request time.
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -48,11 +55,12 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Fetch questions ordered by position
|
||||
// 2. Fetch questions ordered by position — incluye hint/instance_generator/instance_of (4D)
|
||||
const { data: questionRows, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select(
|
||||
'id, survey_id, code, type, prompt, position, is_sensitive, required, max_select, options, conditional_rules'
|
||||
'id, survey_id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||
)
|
||||
.eq('survey_id', surveyRow.id)
|
||||
.order('position', { ascending: true })
|
||||
@@ -66,9 +74,12 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const questions = questionRows as Question[]
|
||||
// Cast en dos pasos: el cliente Supabase no tiene un Database genérico configurado
|
||||
// (lib/supabase.ts), así que el tipo de fila que infiere para .select() no solapa
|
||||
// directamente con EngineQuestion. Las columnas pedidas arriba sí coinciden 1:1.
|
||||
const engineQuestions = questionRows as unknown as EngineQuestion[]
|
||||
|
||||
console.log(`[2B] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`)
|
||||
console.log(`[4D] Survey loaded: id=${surveyRow.id} | questions=${engineQuestions.length}`)
|
||||
|
||||
const survey: Survey = {
|
||||
id: surveyRow.id,
|
||||
@@ -79,8 +90,13 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
k_threshold: surveyRow.k_threshold,
|
||||
consent_version_id: surveyRow.consent_version_id,
|
||||
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
|
||||
questions,
|
||||
questions: [],
|
||||
}
|
||||
|
||||
return <SurveyForm survey={survey} questions={questions} />
|
||||
// El consentimiento (S1, 1.1, 1.2) lo resuelve ConsentScreen, no el motor — ver nota arriba.
|
||||
const schema: FormSchema = buildFormSchema(
|
||||
engineQuestions.filter((q) => !CONSENT_CODES.has(q.code))
|
||||
)
|
||||
|
||||
return <WizardForm survey={survey} schema={schema} />
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
// 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']}
|
||||
/>
|
||||
const legend = (
|
||||
<>
|
||||
<span className="q-code">{code} ·</span> {prompt}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from 'react'
|
||||
import type { FormAnswers, Question, Survey } from '@/lib/types'
|
||||
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
|
||||
import { getHiddenCodes, isQuestionVisible, ruleQuestionCodes } from '@/lib/conditional'
|
||||
import { submitSurvey } from '@/app/actions/submit-survey'
|
||||
import { ConsentScreen } from './consent-screen'
|
||||
import { ProgressBar } from './progress-bar'
|
||||
import { QuestionRenderer } from './question-renderer'
|
||||
|
||||
// Section metadata (code prefix → display title)
|
||||
const SECTIONS: { prefix: string; title: string }[] = [
|
||||
{ prefix: 'A', title: 'Datos Generales' },
|
||||
{ prefix: '1', title: 'Prevalencia: cuidado y discapacidad' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
function getSectionPrefix(code: string): string {
|
||||
const m = code.match(/^([A-Za-z]+|\d+)/)
|
||||
return m ? m[1] : code
|
||||
}
|
||||
|
||||
type FormStage = 'consent' | 'form' | 'success' | 'error'
|
||||
|
||||
interface SurveyFormProps {
|
||||
survey: Survey
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
export function SurveyForm({ survey, questions }: SurveyFormProps) {
|
||||
const [stage, setStage] = useState<FormStage>('consent')
|
||||
const [consentOperativo, setConsentOperativo] = useState(false)
|
||||
const [consentMacroN1, setConsentMacroN1] = useState(false)
|
||||
const [answers, setAnswers] = useState<FormAnswers>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const prevAnswers = useRef<FormAnswers>({})
|
||||
|
||||
// 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 after hydration
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
saveDraft(survey.id, answers)
|
||||
prevAnswers.current = answers
|
||||
}, [answers, hydrated, survey.id])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(code: string, value: FormAnswers[string]) => {
|
||||
setAnswers((prev) => {
|
||||
const updated = { ...prev, [code]: value }
|
||||
const toHide = getHiddenCodes(code, value, questions, prev)
|
||||
for (const hiddenCode of toHide) {
|
||||
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
|
||||
}
|
||||
if (toHide.length > 0) {
|
||||
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
|
||||
} else {
|
||||
const appeared = questions.some(
|
||||
(q) =>
|
||||
q.conditional_rules?.some((r) => ruleQuestionCodes(r).includes(code)) &&
|
||||
isQuestionVisible(q, updated) &&
|
||||
!isQuestionVisible(q, prev)
|
||||
)
|
||||
if (appeared) setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
|
||||
}
|
||||
return updated
|
||||
})
|
||||
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 (isPending) return
|
||||
if (!validate()) {
|
||||
const firstErrorCode = Object.keys(errors)[0]
|
||||
if (firstErrorCode) document.getElementById(firstErrorCode)?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const questionMap = Object.fromEntries(questions.map((q) => [q.code, q.id]))
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitSurvey({
|
||||
surveyId: survey.id,
|
||||
answers: answers as Record<string, unknown>,
|
||||
questionMap,
|
||||
consentOperativo,
|
||||
consentMacroN1,
|
||||
})
|
||||
if (result.success) {
|
||||
clearDraft(survey.id)
|
||||
setStage('success')
|
||||
} else {
|
||||
setSubmitError(result.error)
|
||||
setStage('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Stage: consent ──────────────────────────────────────────
|
||||
if (stage === 'consent') {
|
||||
return (
|
||||
<ConsentScreen
|
||||
brandConfig={survey.brand_config}
|
||||
onAccept={(op, macro) => {
|
||||
setConsentOperativo(op)
|
||||
setConsentMacroN1(macro)
|
||||
setStage('form')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stage: success ──────────────────────────────────────────
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stage: error ────────────────────────────────────────────
|
||||
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 ({submitError}). Podés intentarlo de nuevo.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { setSubmitError(null); setStage('form') }}
|
||||
>
|
||||
Volver e intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stage: form ─────────────────────────────────────────────
|
||||
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
|
||||
|
||||
type Section = { prefix: string; title: string; 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, questions: [] })
|
||||
}
|
||||
sectionMap.get(prefix)!.questions.push(q)
|
||||
}
|
||||
|
||||
const bc = survey.brand_config
|
||||
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
<a href="#survey-form" className="skip-link">Ir al formulario</a>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
|
||||
{a11yAnnounce}
|
||||
</div>
|
||||
|
||||
{/* Header contextual con org/branch si está configurado */}
|
||||
{bc?.org_name && (
|
||||
<div className="brand-header" role="note" aria-label="Información de la empresa">
|
||||
<p className="brand-text">
|
||||
Estás respondiendo esto porque pertenecés a <strong>{bc.org_name}</strong>
|
||||
{bc.branch_name && (
|
||||
<> y participás en las oficinas de <strong>{bc.branch_name}</strong>
|
||||
{bc.branch_city && <> en <strong>{bc.branch_city}</strong></>}.</>
|
||||
)}
|
||||
</p>
|
||||
</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">Sección {section.prefix} · {section.title}</h2>
|
||||
</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"
|
||||
disabled={isPending}
|
||||
aria-disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# ETAPA 4D — Frontend renderer: wizard pregunta-por-pantalla
|
||||
|
||||
## 1. Objetivo
|
||||
Reemplazar el formulario de una sola página (v2) por un wizard que consume el motor
|
||||
de flujo (4C) y muestra una pregunta por pantalla, con layout de altura estable.
|
||||
El componente es un RENDERER: dibuja la pantalla que el motor resuelve; no contiene
|
||||
lógica de flujo (ADR-005).
|
||||
|
||||
## 2. Contexto previo
|
||||
- Motor de flujo listo y testeado: `lib/form-engine.ts` (resolveScreens, next, back,
|
||||
resolveVisibility) + `lib/form-engine.types.ts`. 16/16 tests.
|
||||
- Widgets de input AISLADOS y reutilizables en `components/widgets/`:
|
||||
`radio-group`, `checkbox-group`, `text-short-field`, `text-area-field`
|
||||
(interfaz genérica value/onChange/error — se reutilizan tal cual).
|
||||
- `question-renderer.tsx` despacha por `type` — reutilizable con 2 cambios (ver tareas).
|
||||
- `survey-form.tsx` (una sola página, secciones por regex) se REESCRIBE entero.
|
||||
- `app/page.tsx` query NO trae `hint`/`instance_generator`/`instance_of` (tarea 4.5 pospuesta de 4C).
|
||||
- Gaps detectados: falta widget `yes_no`; hay hardcode A1/A2 en question-renderer a quitar.
|
||||
- Comportamiento de referencia (NO de arquitectura): mockup `wizard_formulario_mapeo_v3.html`.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- El componente consume el motor; NO reimplementa visibilidad/navegación/instancias.
|
||||
- Una pregunta por pantalla. Secciones = pantallas propias (intro con número + descripción).
|
||||
- Reutilizar los widgets de `components/widgets/` existentes.
|
||||
- Identificación de pantalla por ID estable (el motor ya lo provee).
|
||||
- **Layout de altura estable (UX 1+2+3), parte de esta etapa:**
|
||||
- (1) Viewport de altura estable: el enunciado se ancla en una posición consistente
|
||||
(tercio superior), NO centrado vertical. La caja no crece/encoge con el contenido.
|
||||
- (2) Header y footer FIJOS: progreso arriba, navegación (Anterior/Siguiente) abajo,
|
||||
ambos fuera del área que cambia de tamaño. No "saltan" entre pantallas.
|
||||
- (3) Área de respuesta con altura máxima y SCROLL INTERNO cuando hay muchas opciones,
|
||||
con affordance visual (degradado sutil en borde inferior) que indica "hay más abajo".
|
||||
- Usar los tokens de marca existentes (el verde RE, variables CSS/Tailwind del proyecto).
|
||||
NO inventar colores nuevos.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Ampliar la query y construir el FormSchema (app/page.tsx)
|
||||
- Agregar `hint, instance_generator, instance_of` al `.select(...)`.
|
||||
- Transformar las filas de `questions` (incluidas las `type='section'`) en el `FormSchema`
|
||||
que consume el motor.
|
||||
- Pasar el schema al nuevo componente wizard.
|
||||
|
||||
### 4.2 Nuevo componente wizard (reemplaza survey-form.tsx)
|
||||
- Mantiene el `WizardState` del motor (respuestas con instancias, pantalla actual).
|
||||
- En cada render: pide al motor la pantalla actual y la dibuja.
|
||||
- Navegación: botones Anterior/Siguiente llaman `next`/`back` del motor.
|
||||
- Maneja los tipos de pantalla: sección-intro, pregunta, pregunta-de-instancia, fin.
|
||||
- Para pantallas de instancia: muestra el badge de contexto ("Familiar X de N").
|
||||
|
||||
### 4.3 Layout de altura estable (UX 1+2+3)
|
||||
Implementar el layout descrito en las decisiones. Estructura sugerida:
|
||||
```
|
||||
[header fijo: logo + progreso] ← no scrollea
|
||||
[viewport estable]
|
||||
[sección-header (si aplica)]
|
||||
[enunciado anclado: código + pregunta + hint] ← posición consistente
|
||||
[área de respuesta: max-height + scroll interno + fade inferior]
|
||||
[footer fijo: Anterior | indicador | Siguiente] ← no scrollea
|
||||
```
|
||||
- El enunciado aparece en el mismo lugar pantalla tras pantalla.
|
||||
- El área de respuesta scrollea internamente; header/footer nunca se mueven.
|
||||
|
||||
### 4.4 question-renderer: ajustes
|
||||
- Quitar el hardcode A1/A2 (no aplica a v3; 2.1/2.2 son single_choice genéricos).
|
||||
- Agregar el caso `yes_no` (lo usan 1.1/1.2). Crear `widgets/yes-no-field.tsx` si no existe,
|
||||
o resolver yes_no con el RadioGroup existente si es lo más simple — proponé cuál.
|
||||
- Migrar las shapes de `lib/types.ts` (v2) a `lib/form-engine.types.ts` (v3) donde el
|
||||
renderer reciba datos del motor.
|
||||
|
||||
### 4.5 Progreso
|
||||
- La barra/indicador de progreso se calcula desde el motor (pantalla actual / total visible),
|
||||
no desde "preguntas respondidas". Reutilizar `progress-bar.tsx` si encaja.
|
||||
|
||||
### 4.6 Consentimiento
|
||||
- Reutilizar `consent-screen.tsx` como pantalla 1 (Sección 1), o integrarlo como la primera
|
||||
pantalla del wizard — proponé cuál es más limpio dado que ahora todo es wizard.
|
||||
|
||||
## 5. Entregables (DoD)
|
||||
- [ ] Wizard funcional una-pregunta-por-pantalla consumiendo el motor.
|
||||
- [ ] Layout de altura estable: header/footer fijos, enunciado anclado, scroll interno + fade.
|
||||
- [ ] Query ampliada con las 3 columnas; FormSchema construido y pasado al componente.
|
||||
- [ ] yes_no renderiza; hardcode A1/A2 removido.
|
||||
- [ ] Progreso calculado desde el motor.
|
||||
- [ ] Widgets de v2 reutilizados (no reescritos).
|
||||
- [ ] **Validación visual del director** contra el mockup + criterios de UX estable.
|
||||
- [ ] Build sin errores; sin lógica de flujo en el componente (solo consume el motor).
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Wizard consume el motor (resolveScreens/next/back) ✓ — sin lógica de flujo propia
|
||||
- Layout estable: header/footer fijos ✓, enunciado anclado ✓, scroll interno + fade ✓
|
||||
- Query ampliada (hint/instance_generator/instance_of) ✓
|
||||
- yes_no resuelto vía [RadioGroup | widget nuevo] ✓
|
||||
- Hardcode A1/A2 removido ✓
|
||||
- Widgets reutilizados: [lista]
|
||||
- Build: [resultado]
|
||||
- Capturas/descripción visual de 3 pantallas: pregunta corta, pregunta con muchas opciones
|
||||
(scroll interno), sección-intro
|
||||
|
||||
Asumido / Decisiones de diseño:
|
||||
- [consentimiento como pantalla / yes_no / etc.]
|
||||
|
||||
Hallazgos no esperados:
|
||||
- [lo que aparezca]
|
||||
```
|
||||
Reporte con datos concretos, no "se ve bien" (Patrón C.4). Describí el comportamiento del
|
||||
layout estable: ¿el enunciado queda en el mismo lugar entre una pregunta de 3 opciones y
|
||||
una de 15? ¿El footer se mantiene fijo?
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No poner lógica de flujo/visibilidad/instancias en el componente (vive en el motor).
|
||||
- ❌ No reescribir los widgets de input (reutilizarlos).
|
||||
- ❌ No implementar el submit todavía (es 4G) ni la validación server-side.
|
||||
- ❌ No implementar el autocomplete de barrios (es 4F) — barrio queda como text_short por ahora.
|
||||
- ❌ No inventar colores fuera de los tokens de marca.
|
||||
- ❌ No tocar v2.1.0-stable, el motor (4C), ni el seed.
|
||||
|
||||
## 8. Versionado
|
||||
Commit sugerido:
|
||||
```
|
||||
feat(wizard): renderer pregunta-por-pantalla con layout estable [4D]
|
||||
|
||||
Reemplaza el formulario de una página por wizard que consume el motor (4C).
|
||||
Layout de altura estable: header/footer fijos, enunciado anclado, scroll
|
||||
interno con affordance. Reutiliza widgets de input. Agrega yes_no, quita
|
||||
hardcode A1/A2. Query ampliada con columnas declarativas de v3.
|
||||
```
|
||||
Reference in New Issue
Block a user