diff --git a/app/globals.css b/app/globals.css index e41aa52..4448360 100644 --- a/app/globals.css +++ b/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; diff --git a/app/page.tsx b/app/page.tsx index 6c10757..34d2e52 100644 --- a/app/page.tsx +++ b/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 + // 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 } diff --git a/components/question-renderer.tsx b/components/question-renderer.tsx index 904211b..e33803d 100644 --- a/components/question-renderer.tsx +++ b/components/question-renderer.tsx @@ -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 + 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 = ( + <> + {code} · {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 ( - 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 = <>{code} · {prompt} - - if (type === 'single_choice' && isArrayOptions(options)) { + if (type === 'single_choice' && options) { return ( onChange(code, v)} + onChange={onChange} error={error} /> ) } - if (type === 'multiple_choice' && isArrayOptions(options)) { + if (type === 'multiple_choice' && options) { return ( 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 ( - onChange(code, v)} + onChange={onChange} error={error} /> ) @@ -98,9 +74,9 @@ export function QuestionRenderer({ onChange(code, v)} + onChange={onChange} error={error} /> ) @@ -111,9 +87,9 @@ export function QuestionRenderer({ onChange(code, v)} + onChange={onChange} error={error} /> ) diff --git a/components/wizard-form.tsx b/components/wizard-form.tsx new file mode 100644 index 0000000..4f4210a --- /dev/null +++ b/components/wizard-form.tsx @@ -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('consent') + const [consentOperativo, setConsentOperativo] = useState(false) + const [consentMacroN1, setConsentMacroN1] = useState(false) + const [state, setState] = useState(() => initialState(schema)) + const [errors, setErrors] = useState>({}) + 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 ( + { + 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 ( +
+
+ {a11yAnnounce} +
+ +
+ {bc?.org_name && ( +

+ Encuesta de {bc.org_name} +

+ )} + +
+ +
+ {screen.kind === 'section' && screen.question && ( +
+

+ Sección {sectionOrdinal} de {sectionScreens.length} +

+

+ {screen.question.prompt} +

+ {screen.question.hint &&

{screen.question.hint}

} +
+ )} + + {screen.kind === 'question' && screen.question && ( + <> +
+ {instanceCtx && ( + + {instanceCtx.label} · familiar {instanceCtx.ordinal} de {instanceCtx.total} + + )} + {screen.question.code} +

{screen.question.prompt}

+ {screen.question.hint &&

{screen.question.hint}

} +
+
+ +
+ + )} + + {screen.kind === 'end' && ( +
+

Llegaste al final

+

+ El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus + respuestas con "Anterior". +

+
+ )} +
+ +
+ + + {screen.kind !== 'end' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/sprints/sprint-4/ETAPA_4D_PROMPT.md b/sprints/sprint-4/ETAPA_4D_PROMPT.md new file mode 100644 index 0000000..1fffa2e --- /dev/null +++ b/sprints/sprint-4/ETAPA_4D_PROMPT.md @@ -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. +``` \ No newline at end of file