Terminado sprint 2A

This commit is contained in:
markosbenitez
2026-06-02 10:27:52 -03:00
parent 418c91fb12
commit 0f376a8b75
81 changed files with 5379 additions and 46 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Supabase — variables de cliente (públicas, respetan RLS con anon key)
# Copiar a .env.local y completar con los valores del proyecto Cloud
# NO agregar valores reales aquí; .env.local está en .gitignore
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key-publica>
# NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente
# La service_role bypasa RLS — solo para Edge Functions / backend server-side

2
.gitignore vendored
View File

@@ -17,4 +17,4 @@ node_modules/
# Supabase — claves y credenciales
*.key
service_role*
.env.production
.env.production.DS_Store

View File

@@ -0,0 +1,3 @@
{
"pages": {}
}

17
.next/build-manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pages": {
"/_app": []
},
"ampFirstPages": []
}

1
.next/cache/.rscinfo vendored Normal file
View File

@@ -0,0 +1 @@
{"encryption.key":"rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc=","encryption.expire_at":1781610552095}

1
.next/cache/.tsbuildinfo vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
.next/package.json Normal file
View File

@@ -0,0 +1 @@
{"type": "commonjs"}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"

View File

@@ -0,0 +1,19 @@
globalThis.__BUILD_MANIFEST = {
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pages": {
"/_app": []
},
"ampFirstPages": []
};
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
];

View File

@@ -0,0 +1,6 @@
{
"version": 3,
"middleware": {},
"functions": {},
"sortedMiddleware": []
}

View File

@@ -0,0 +1 @@
self.__REACT_LOADABLE_MANIFEST="{}"

View File

@@ -0,0 +1 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"

View File

@@ -0,0 +1 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"

View File

@@ -0,0 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc="
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:a,__routerFilterDynamic:a,sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()

View File

@@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

2
.next/trace Normal file

File diff suppressed because one or more lines are too long

141
.next/types/cache-life.d.ts vendored Normal file
View File

@@ -0,0 +1,141 @@
// Type definitions for Next.js cacheLife configs
declare module 'next/cache' {
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export {
revalidateTag,
revalidatePath,
unstable_expireTag,
unstable_expirePath,
} from 'next/dist/server/web/spec-extension/revalidate'
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
/**
* Cache this `"use cache"` for a timespan defined by the `"default"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 900 seconds (15 minutes)
* expire: never
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 15 minutes, start revalidating new values in the background.
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
*/
export function unstable_cacheLife(profile: "default"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"seconds"` profile.
* ```
* stale: 0 seconds
* revalidate: 1 seconds
* expire: 60 seconds (1 minute)
* ```
*
* This cache may be stale on clients for 0 seconds before checking with the server.
* If the server receives a new request after 1 seconds, start revalidating new values in the background.
* If this entry has no traffic for 1 minute it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "seconds"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"minutes"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 60 seconds (1 minute)
* expire: 3600 seconds (1 hour)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 minute, start revalidating new values in the background.
* If this entry has no traffic for 1 hour it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "minutes"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"hours"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 3600 seconds (1 hour)
* expire: 86400 seconds (1 day)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 hour, start revalidating new values in the background.
* If this entry has no traffic for 1 day it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "hours"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"days"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 86400 seconds (1 day)
* expire: 604800 seconds (1 week)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 day, start revalidating new values in the background.
* If this entry has no traffic for 1 week it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "days"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"weeks"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 604800 seconds (1 week)
* expire: 2592000 seconds (30 days)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 week, start revalidating new values in the background.
* If this entry has no traffic for 30 days it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "weeks"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"max"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 2592000 seconds (30 days)
* expire: never
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 30 days, start revalidating new values in the background.
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
*/
export function unstable_cacheLife(profile: "max"): void
/**
* Cache this `"use cache"` using a custom timespan.
* ```
* stale: ... // seconds
* revalidate: ... // seconds
* expire: ... // seconds
* ```
*
* This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate`
*
* If a value is left out, the lowest of other cacheLife() calls or the default, is used instead.
*/
export function unstable_cacheLife(profile: {
/**
* This cache may be stale on clients for ... seconds before checking with the server.
*/
stale?: number,
/**
* If the server receives a new request after ... seconds, start revalidating new values in the background.
*/
revalidate?: number,
/**
* If this entry has no traffic for ... seconds it will expire. The next request will recompute it.
*/
expire?: number
}): void
export { cacheTag as unstable_cacheTag } from 'next/dist/server/use-cache/cache-tag'
}

1
.next/types/package.json Normal file
View File

@@ -0,0 +1 @@
{"type": "module"}

425
app/globals.css Normal file
View File

@@ -0,0 +1,425 @@
@import "tailwindcss";
/* ── Tokens ── */
:root {
--color-bg: #f9fafb;
--color-surface: #ffffff;
--color-text: #111827;
--color-text-muted: #6b7280;
--color-primary: #1d4ed8;
--color-primary-hover: #1e40af;
--color-primary-light: #dbeafe;
--color-accent: #2563eb;
--color-error: #b91c1c;
--color-error-bg: #fef2f2;
--color-border: #d1d5db;
--color-border-focus: #2563eb;
--color-sensitive: #fef3c7;
--color-sensitive-border: #f59e0b;
--radius: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
}
/* ── Reset & base ── */
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ── Skip link ── */
.skip-link {
position: absolute;
left: -9999px;
top: 4px;
z-index: 9999;
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius);
font-weight: 600;
text-decoration: none;
}
.skip-link:focus { left: 4px; }
/* ── Screen reader only ── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* ── Survey wrapper ── */
.survey-wrapper {
max-width: 720px;
margin: 0 auto;
padding: 0 16px 80px;
}
/* ── Survey header ── */
.survey-header {
padding: 32px 0 16px;
border-bottom: 2px solid var(--color-border);
margin-bottom: 16px;
}
.survey-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 8px;
color: var(--color-text);
}
.survey-anon-note {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
/* ── Progress bar ── */
.survey-progress-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 8px 0;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
height: 8px;
position: relative;
}
.progress-bar-fill {
height: 8px;
background: var(--color-primary);
border-radius: 4px;
transition: width 0.3s ease;
flex-shrink: 0;
}
.progress-bar-label {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
position: absolute;
right: 0;
top: -18px;
}
/* ── Sections ── */
.survey-section {
margin-top: 32px;
}
.section-header { margin-bottom: 16px; }
.section-title {
font-size: 1.125rem;
font-weight: 700;
margin: 0 0 6px;
padding: 10px 14px;
background: var(--color-primary-light);
border-left: 4px solid var(--color-primary);
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--color-primary-hover);
}
.section-sensitive-note {
font-size: 0.8125rem;
color: #92400e;
background: var(--color-sensitive);
border: 1px solid var(--color-sensitive-border);
border-radius: var(--radius);
padding: 8px 12px;
margin: 0;
}
/* ── Question wrapper ── */
.question-wrapper {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 12px;
box-shadow: var(--shadow-card);
}
/* ── Fieldset (radio/checkbox/rating) ── */
.question-fieldset {
border: none;
padding: 0;
margin: 0;
min-width: 0;
}
.question-legend {
display: block;
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text);
line-height: 1.4;
}
.required-mark { color: var(--color-error); }
/* ── Options list ── */
.options-list { display: flex; flex-direction: column; gap: 8px; }
.option-item {}
.option-label {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1.5px solid var(--color-border);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
line-height: 1.4;
}
.option-label:hover { border-color: var(--color-accent); background: var(--color-primary-light); }
.option-label:has(input:checked) {
border-color: var(--color-primary);
background: var(--color-primary-light);
font-weight: 500;
}
.option-disabled { opacity: 0.45; cursor: not-allowed; }
.option-disabled:hover { border-color: var(--color-border); background: transparent; }
.option-input {
margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.option-input:disabled { cursor: not-allowed; }
.option-text { flex: 1; }
/* ── Max select hint ── */
.max-select-hint {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin: 0 0 10px;
}
/* ── Rating ── */
.rating-wrapper {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.rating-label {
font-size: 0.8125rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.rating-steps {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rating-step { display: flex; }
.rating-input { /* hidden via sr-only */ }
.rating-dot {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 2px solid var(--color-border);
border-radius: 50%;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
color: var(--color-text);
}
.rating-step:has(input:checked) .rating-dot {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.rating-step:has(input:focus-visible) .rating-dot {
outline: 3px solid var(--color-border-focus);
outline-offset: 2px;
}
.rating-step:hover .rating-dot { border-color: var(--color-accent); background: var(--color-primary-light); }
.rating-step:has(input:checked):hover .rating-dot { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; }
/* ── Textarea ── */
.text-area {
display: block;
width: 100%;
padding: 10px 12px;
border: 1.5px solid var(--color-border);
border-radius: var(--radius);
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
resize: vertical;
transition: border-color 0.15s;
color: var(--color-text);
background: var(--color-surface);
}
.text-area:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
}
/* ── Field error ── */
.field-error {
margin: 6px 0 0;
font-size: 0.875rem;
color: var(--color-error);
display: flex;
align-items: center;
gap: 4px;
}
.field-error::before { content: '⚠'; }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 28px;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
border: 2px solid transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
min-height: 44px;
text-decoration: none;
}
.btn:focus-visible {
outline: 3px solid var(--color-border-focus);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover { background: var(--color-primary-hover); }
.btn-secondary {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
}
.btn-secondary:hover { background: var(--color-primary-light); }
/* ── Form footer ── */
.form-footer {
padding: 24px 0 16px;
display: flex;
justify-content: flex-end;
}
/* ── Summary ── */
.summary-container {
max-width: 720px;
margin: 0 auto;
padding: 32px 16px 80px;
}
.summary-header { margin-bottom: 28px; }
.summary-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 8px; }
.summary-subtitle { color: var(--color-text-muted); margin: 0 0 12px; }
.summary-note {
font-size: 0.875rem;
background: var(--color-sensitive);
border: 1px solid var(--color-sensitive-border);
border-radius: var(--radius);
padding: 10px 14px;
margin: 0;
}
.summary-section-heading {
font-size: 1rem;
font-weight: 700;
padding: 8px 12px;
background: var(--color-primary-light);
border-left: 4px solid var(--color-primary);
border-radius: 0 var(--radius) var(--radius) 0;
margin: 24px 0 8px;
color: var(--color-primary-hover);
}
.summary-list { margin: 0; padding: 0; }
.summary-item {
border-bottom: 1px solid var(--color-border);
padding: 12px 0;
}
.summary-question {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 4px;
}
.summary-code {
font-weight: 700;
color: var(--color-primary);
margin-right: 4px;
}
.summary-answer {
font-size: 1rem;
font-weight: 500;
color: var(--color-text);
margin: 0;
}
.summary-actions {
padding-top: 28px;
display: flex;
justify-content: flex-start;
}
/* ── No survey state ── */
.no-survey {
max-width: 480px;
margin: 80px auto;
text-align: center;
padding: 0 16px;
}
.no-survey-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 12px; }
.no-survey-hint {
font-size: 0.875rem;
background: #f3f4f6;
border-radius: var(--radius);
padding: 12px 16px;
text-align: left;
font-family: 'Courier New', monospace;
margin-top: 16px;
}
/* ── Loading skeleton ── */
.loading-wrapper {
max-width: 720px;
margin: 0 auto;
padding: 32px 16px;
}
.skeleton { background: #e5e7eb; border-radius: var(--radius); animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ── Focus visible (global override) ── */
:focus-visible {
outline: 3px solid var(--color-border-focus);
outline-offset: 2px;
}
/* ── Mobile-first responsive ── */
@media (max-width: 480px) {
.survey-title { font-size: 1.25rem; }
.rating-wrapper { flex-direction: column; align-items: flex-start; }
.form-footer { justify-content: stretch; }
.btn { width: 100%; }
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

23
app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata, Viewport } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Mapeo de Cuidadores',
description:
'Encuesta anónima de mapeo de colaboradores con rol de cuidador en la empresa. ' +
'Tu información es confidencial y no te identifica personalmente.',
robots: 'noindex, nofollow', // Encuesta interna — no indexar
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>{children}</body>
</html>
)
}

15
app/loading.tsx Normal file
View File

@@ -0,0 +1,15 @@
export default function Loading() {
return (
<div className="loading-wrapper" role="status" aria-label="Cargando encuesta...">
<div className="skeleton" style={{ height: 32, width: '60%', marginBottom: 16 }} />
<div className="skeleton" style={{ height: 16, width: '40%', marginBottom: 32 }} />
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="skeleton"
style={{ height: 88, marginBottom: 12, borderRadius: 8 }}
/>
))}
</div>
)
}

80
app/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { createClient } from '@/lib/supabase'
import type { Question, Survey } from '@/lib/types'
import { SurveyForm } from '@/components/survey-form'
// Force dynamic rendering — page fetches from Supabase at request time.
// Never statically generated: credentials live in .env.local (runtime only).
export const dynamic = 'force-dynamic'
// Server Component — fetches survey with anon key; RLS ensures only status='live' is returned.
export default async function Page() {
const supabase = createClient()
// 1. Fetch the live survey
const { data: surveyRow, error: surveyError } = await supabase
.from('surveys')
.select('id, title, status, is_anonymous, k_threshold')
.eq('status', 'live')
.maybeSingle()
if (surveyError) {
return (
<div className="no-survey" role="main">
<h1 className="no-survey-title">Error al cargar la encuesta</h1>
<p>No fue posible conectarse a la base de datos. Revisá las variables de entorno.</p>
<pre className="no-survey-hint">{surveyError.message}</pre>
</div>
)
}
if (!surveyRow) {
return (
<div className="no-survey" role="main">
<h1 className="no-survey-title">No hay encuesta activa</h1>
<p>
Para ver el formulario, la encuesta debe estar en estado <code>live</code>.
</p>
<pre className="no-survey-hint">
{`-- Activar la encuesta en Supabase Cloud:\nUPDATE surveys\nSET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
</pre>
</div>
)
}
// 2. Fetch questions ordered by position
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'
)
.eq('survey_id', surveyRow.id)
.order('position', { ascending: true })
if (questionsError || !questionRows) {
return (
<div className="no-survey" role="main">
<h1 className="no-survey-title">Error al cargar las preguntas</h1>
<p>{questionsError?.message ?? 'Respuesta vacía del servidor.'}</p>
</div>
)
}
const questions = questionRows as Question[]
// 3. Log question count for validation (visible in server logs and browser Network tab)
console.log(
`[2A] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`
)
const survey: Survey = {
...(surveyRow as Omit<Survey, 'questions'>),
questions,
}
return (
<SurveyForm
survey={survey}
questions={questions}
/>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
interface ProgressBarProps {
answered: number
total: number
}
export function ProgressBar({ answered, total }: ProgressBarProps) {
const pct = total === 0 ? 0 : Math.round((answered / total) * 100)
return (
<div className="progress-bar-container">
<div
className="progress-bar-fill"
role="progressbar"
aria-label={`Progreso de la encuesta: ${pct}%`}
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
style={{ width: `${pct}%` }}
/>
<span className="progress-bar-label" aria-hidden="true">
{answered}/{total} respondidas
</span>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/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'
interface QuestionRendererProps {
question: Question
answers: FormAnswers
onChange: (code: string, value: FormAnswers[string]) => void
errors: Record<string, string>
}
function isArrayOptions(opts: unknown): opts is QuestionOption[] {
return Array.isArray(opts)
}
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) {
const { code, type, prompt, required, max_select, options } = question
const error = errors[code]
if (type === 'single_choice' && isArrayOptions(options)) {
return (
<RadioGroup
name={code}
legend={prompt}
options={options}
value={answers[code] as string | undefined}
required={required}
onChange={(v) => onChange(code, v)}
error={error}
/>
)
}
if (type === 'multiple_choice' && isArrayOptions(options)) {
return (
<CheckboxGroup
name={code}
legend={prompt}
options={options}
value={(answers[code] as string[]) ?? []}
maxSelect={max_select}
required={required}
onChange={(v) => onChange(code, v)}
error={error}
/>
)
}
if (type === 'rating' && isRatingOptions(options)) {
return (
<RatingScale
name={code}
legend={prompt}
ratingOptions={options}
value={answers[code] as number | undefined}
required={required}
onChange={(v) => onChange(code, v)}
error={error}
/>
)
}
if (type === 'text_long' || type === 'text_short') {
return (
<TextAreaField
name={code}
label={prompt}
value={(answers[code] as string) ?? ''}
required={required}
onChange={(v) => onChange(code, v)}
error={error}
/>
)
}
return null
}

View File

@@ -0,0 +1,110 @@
'use client'
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/types'
import { isQuestionVisible } from '@/lib/conditional'
interface SummaryScreenProps {
surveyTitle: string
questions: Question[]
answers: FormAnswers
onStartOver: () => void
}
function formatAnswer(question: Question, raw: FormAnswers[string]): string {
if (raw === null || raw === undefined || raw === '') return '—'
const { type, options } = question
if ((type === 'single_choice') && Array.isArray(options)) {
const opt = (options as QuestionOption[]).find((o) => o.value === raw)
return opt?.label ?? String(raw)
}
if (type === 'multiple_choice' && Array.isArray(raw) && Array.isArray(options)) {
const opts = options as QuestionOption[]
const labels = (raw as string[]).map(
(v) => opts.find((o) => o.value === v)?.label ?? v
)
return labels.length > 0 ? labels.join(', ') : '—'
}
if (type === 'rating' && options && !Array.isArray(options)) {
const ro = options as RatingOptions
return `${raw} / ${ro.max} (${ro.min_label}${ro.max_label})`
}
return String(raw)
}
// Section titles by first question code prefix
const SECTION_TITLES: Record<string, string> = {
A: 'Datos Generales',
B: '¿Vivís con discapacidad?',
C: 'Familiares con Discapacidad',
D: 'Adultos Mayores a Cargo',
E: 'Tu Rol como Cuidador/a',
F: 'Impacto Económico',
G: 'Bienestar y Descanso',
H: 'Necesidades',
}
export function SummaryScreen({
surveyTitle,
questions,
answers,
onStartOver,
}: SummaryScreenProps) {
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
let lastSection = ''
return (
<div className="summary-container" role="main">
<div className="summary-header">
<h1 className="summary-title">Resumen de tus respuestas</h1>
<p className="summary-subtitle">
{surveyTitle} revisá lo que respondiste antes de continuar al envío.
</p>
<p className="summary-note" role="note">
<strong>Nota:</strong> en esta etapa no se envía nada a la base de datos.
El envío definitivo (con consentimiento) será en el paso siguiente.
</p>
</div>
<dl className="summary-list">
{visibleQuestions.map((q) => {
const section = q.code.replace(/\d+$/, '')
const showHeader = section !== lastSection
lastSection = section
return (
<div key={q.id}>
{showHeader && (
<h2 className="summary-section-heading">
{SECTION_TITLES[section] ?? section}
</h2>
)}
<div className="summary-item">
<dt className="summary-question">
<span className="summary-code">{q.code}</span> {q.prompt}
</dt>
<dd className="summary-answer">
{formatAnswer(q, answers[q.code] ?? null)}
</dd>
</div>
</div>
)
})}
</dl>
<div className="summary-actions">
<button
type="button"
onClick={onStartOver}
className="btn btn-secondary"
>
Volver a la encuesta
</button>
</div>
</div>
)
}

249
components/survey-form.tsx Normal file
View File

@@ -0,0 +1,249 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { FormAnswers, Question, Survey } from '@/lib/types'
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
import { getHiddenCodes, isQuestionVisible } from '@/lib/conditional'
import { ProgressBar } from './progress-bar'
import { QuestionRenderer } from './question-renderer'
import { SummaryScreen } from './summary-screen'
// Section metadata (code prefix → display title)
const SECTIONS: { prefix: string; title: string; sensitive?: boolean }[] = [
{ prefix: 'A', title: 'Datos Generales' },
{ prefix: 'B', title: '¿Vivís con discapacidad?', sensitive: true },
{ prefix: 'C', title: 'Familiares con Discapacidad', sensitive: true },
{ prefix: 'D', title: 'Adultos Mayores a Cargo', sensitive: true },
{ prefix: 'E', title: 'Tu Rol como Cuidador/a' },
{ prefix: 'F', title: 'Impacto Económico' },
{ prefix: 'G', title: 'Bienestar y Descanso' },
{ prefix: 'H', title: 'Necesidades' },
]
function getSectionPrefix(code: string) {
return code.replace(/\d+$/, '')
}
interface SurveyFormProps {
survey: Survey
questions: Question[]
}
export function SurveyForm({ survey, questions }: SurveyFormProps) {
const [answers, setAnswers] = useState<FormAnswers>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitted, setSubmitted] = useState(false)
const [hydrated, setHydrated] = useState(false)
// Announce when conditional blocks appear/disappear
const [a11yAnnounce, setA11yAnnounce] = useState('')
// Track which trigger codes had "Sí" on prev render to detect toggle
const prevVisibility = useRef<Record<string, boolean>>({})
// 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 on every change (after hydration)
useEffect(() => {
if (!hydrated) return
saveDraft(survey.id, answers)
}, [answers, hydrated, survey.id])
const handleChange = useCallback(
(code: string, value: FormAnswers[string]) => {
setAnswers((prev) => {
const updated = { ...prev, [code]: value }
// Clear any dependent questions that become hidden
const toHide = getHiddenCodes(code, value, questions, prev)
for (const hiddenCode of toHide) {
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
}
// Announce block visibility change to screen readers
if (toHide.length > 0) {
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
} else {
// Check if a conditional block just became visible
const appeared = questions.some(
(q) =>
q.conditional_rules?.some((r) => r.if_question === code) &&
isQuestionVisible(q, updated) &&
!isQuestionVisible(q, prev)
)
if (appeared) {
setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
}
}
return updated
})
// Clear validation error for this field
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 (!validate()) {
// Move focus to first error
const firstErrorCode = Object.keys(errors)[0]
if (firstErrorCode) {
document.getElementById(firstErrorCode)?.focus()
}
return
}
// 2A: no write to DB — just show summary
clearDraft(survey.id)
setSubmitted(true)
}
function handleStartOver() {
setAnswers({})
setSubmitted(false)
setErrors({})
}
// Visible questions driven by conditional_rules
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
if (submitted) {
return (
<SummaryScreen
surveyTitle={survey.title}
questions={questions}
answers={answers}
onStartOver={handleStartOver}
/>
)
}
// Group visible questions by section prefix, in order
type Section = { prefix: string; title: string; sensitive?: boolean; 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,
sensitive: meta?.sensitive,
questions: [],
})
}
sectionMap.get(prefix)!.questions.push(q)
}
return (
<div className="survey-wrapper">
{/* Skip link for keyboard users */}
<a href="#survey-form" className="skip-link">
Ir al formulario
</a>
{/* Screen-reader live region for conditional announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
role="status"
>
{a11yAnnounce}
</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">
{section.title}
</h2>
{section.sensitive && (
<p className="section-sensitive-note" role="note">
Esta sección contiene preguntas sensibles sobre salud o discapacidad.
Responder es completamente voluntario.
</p>
)}
</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">
Ver resumen de respuestas
</button>
</div>
</form>
</main>
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import type { QuestionOption } from '@/lib/types'
interface CheckboxGroupProps {
name: string
legend: string
options: QuestionOption[]
value: string[]
maxSelect: number | null
required: boolean
onChange: (value: string[]) => void
error?: string
}
export function CheckboxGroup({
name,
legend,
options,
value,
maxSelect,
required,
onChange,
error,
}: CheckboxGroupProps) {
const atLimit = maxSelect !== null && value.length >= maxSelect
function toggle(optValue: string) {
if (value.includes(optValue)) {
onChange(value.filter((v) => v !== optValue))
} else if (!atLimit) {
onChange([...value, optValue])
}
}
return (
<fieldset className="question-fieldset">
<legend className="question-legend">
{legend}
{required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>}
</legend>
{maxSelect !== null && (
<p className="max-select-hint" aria-live="polite">
Elegí hasta {maxSelect} opciones
{value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`}
</p>
)}
<div className="options-list" role="list">
{options.map((opt) => {
const id = `${name}_${opt.value.replace(/\s+/g, '_')}`
const checked = value.includes(opt.value)
const disabled = atLimit && !checked
return (
<div key={opt.value} className="option-item" role="listitem">
<label
className={`option-label${disabled ? ' option-disabled' : ''}`}
htmlFor={id}
>
<input
type="checkbox"
id={id}
name={name}
value={opt.value}
checked={checked}
disabled={disabled}
onChange={() => toggle(opt.value)}
aria-required={required}
aria-disabled={disabled}
className="option-input"
/>
<span className="option-text">{opt.label}</span>
</label>
</div>
)
})}
</div>
{error && (
<p role="alert" className="field-error" id={`${name}-error`}>
{error}
</p>
)}
</fieldset>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import type { QuestionOption } from '@/lib/types'
interface RadioGroupProps {
name: string
legend: string
options: QuestionOption[]
value: string | undefined
required: boolean
onChange: (value: string) => void
error?: string
}
export function RadioGroup({
name,
legend,
options,
value,
required,
onChange,
error,
}: RadioGroupProps) {
return (
<fieldset className="question-fieldset">
<legend className="question-legend">
{legend}
{required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>}
</legend>
<div className="options-list" role="list">
{options.map((opt) => {
const id = `${name}_${opt.value.replace(/\s+/g, '_')}`
return (
<div key={opt.value} className="option-item" role="listitem">
<label className="option-label" htmlFor={id}>
<input
type="radio"
id={id}
name={name}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
aria-required={required}
className="option-input"
/>
<span className="option-text">{opt.label}</span>
</label>
</div>
)
})}
</div>
{error && (
<p role="alert" className="field-error" id={`${name}-error`}>
{error}
</p>
)}
</fieldset>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import type { RatingOptions } from '@/lib/types'
interface RatingScaleProps {
name: string
legend: string
ratingOptions: RatingOptions
value: number | undefined
required: boolean
onChange: (value: number) => void
error?: string
}
export function RatingScale({
name,
legend,
ratingOptions,
value,
required,
onChange,
error,
}: RatingScaleProps) {
const { min, max, min_label, max_label } = ratingOptions
const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i)
return (
<fieldset className="question-fieldset">
<legend className="question-legend">
{legend}
{required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>}
</legend>
<div className="rating-wrapper" role="group" aria-label={`Escala ${min} a ${max}`}>
<span className="rating-label rating-label-min" aria-hidden="true">
{min_label}
</span>
<div className="rating-steps">
{steps.map((step) => {
const id = `${name}_${step}`
return (
<label key={step} className="rating-step" htmlFor={id}>
<input
type="radio"
id={id}
name={name}
value={step}
checked={value === step}
onChange={() => onChange(step)}
aria-required={required}
aria-label={`${step}${step === min ? min_label : step === max ? max_label : ''}`}
className="rating-input sr-only"
/>
<span className="rating-dot" aria-hidden="true">
{step}
</span>
</label>
)
})}
</div>
<span className="rating-label rating-label-max" aria-hidden="true">
{max_label}
</span>
</div>
{error && (
<p role="alert" className="field-error" id={`${name}-error`}>
{error}
</p>
)}
</fieldset>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
interface TextAreaFieldProps {
name: string
label: string
value: string
required: boolean
onChange: (value: string) => void
error?: string
}
export function TextAreaField({
name,
label,
value,
required,
onChange,
error,
}: TextAreaFieldProps) {
return (
<div className="question-fieldset">
<label className="question-legend" htmlFor={name}>
{label}
{required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>}
</label>
<textarea
id={name}
name={name}
value={value}
rows={4}
required={required}
aria-required={required}
aria-describedby={error ? `${name}-error` : undefined}
aria-invalid={!!error}
onChange={(e) => onChange(e.target.value)}
className="text-area"
placeholder="Escribí tu respuesta acá (opcional)…"
/>
{error && (
<p role="alert" className="field-error" id={`${name}-error`}>
{error}
</p>
)}
</div>
)
}

53
lib/conditional.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { ConditionalRule, FormAnswers, Question } from './types'
/** Returns true if the question should be shown given the current answers. */
export function isQuestionVisible(
question: Question,
answers: FormAnswers
): boolean {
const rules = question.conditional_rules
if (!rules || rules.length === 0) return true
// All rules must pass (AND semantics)
return rules.every((rule) => evaluateRule(rule, answers))
}
function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
if (rule.op === 'eq') {
return answers[rule.if_question] === rule.value
}
return false
}
/**
* Returns the codes of questions that will become hidden when `triggerCode`
* changes to `newValue`. Used to clear those answers reactively.
*/
export function getHiddenCodes(
triggerCode: string,
newValue: string | string[] | number | null,
questions: Question[],
currentAnswers: FormAnswers
): string[] {
const projected = { ...currentAnswers, [triggerCode]: newValue }
return questions
.filter((q) => {
if (!q.conditional_rules?.length) return false
const dependsOnTrigger = q.conditional_rules.some(
(r) => r.if_question === triggerCode
)
if (!dependsOnTrigger) return false
return !isQuestionVisible(q, projected)
})
.map((q) => q.code)
}
/** All trigger codes in the survey (questions that have dependents). */
export function getTriggerCodes(questions: Question[]): Set<string> {
const triggers = new Set<string>()
for (const q of questions) {
for (const rule of q.conditional_rules ?? []) {
triggers.add(rule.if_question)
}
}
return triggers
}

27
lib/storage.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { FormAnswers } from './types'
const PREFIX = 'survey_draft_'
// Only store answer data — nothing identifiable about the respondent.
export function saveDraft(surveyId: string, answers: FormAnswers): void {
try {
localStorage.setItem(PREFIX + surveyId, JSON.stringify(answers))
} catch {
// Storage quota or private-mode — silently ignore
}
}
export function loadDraft(surveyId: string): FormAnswers {
try {
const raw = localStorage.getItem(PREFIX + surveyId)
return raw ? (JSON.parse(raw) as FormAnswers) : {}
} catch {
return {}
}
}
export function clearDraft(surveyId: string): void {
try {
localStorage.removeItem(PREFIX + surveyId)
} catch {}
}

17
lib/supabase.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
// Browser-side client using the anon key (public, respects RLS)
// NEVER put SUPABASE_SERVICE_ROLE_KEY in a NEXT_PUBLIC_ variable or in this file.
export function createClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!url || !anonKey) {
throw new Error(
'Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY. ' +
'Copy .env.example to .env.local and fill in the values from your Supabase project.'
)
}
return createSupabaseClient(url, anonKey)
}

52
lib/types.ts Normal file
View File

@@ -0,0 +1,52 @@
export type QuestionType =
| 'single_choice'
| 'multiple_choice'
| 'rating'
| 'text_long'
| 'text_short'
| 'section'
export interface QuestionOption {
value: string
label: string
}
export interface RatingOptions {
min: number
max: number
min_label: string
max_label: string
}
export interface ConditionalRule {
if_question: string
op: 'eq'
value: string
action: 'show'
}
export interface Question {
id: string
survey_id: string
code: string
type: QuestionType
prompt: string
position: number
is_sensitive: boolean
required: boolean
max_select: number | null
options: QuestionOption[] | RatingOptions | null
conditional_rules: ConditionalRule[] | null
}
export interface Survey {
id: string
title: string
status: string
is_anonymous: boolean
k_threshold: number
questions: Question[]
}
// Answers keyed by question code
export type FormAnswers = Record<string, string | string[] | number | null>

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: "standalone"
}
export default nextConfig

3516
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,34 @@
{
"devDependencies": {
"supabase": "^2.102.0"
},
"name": "motor-de-encuestas",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:reset": "supabase db reset",
"db:test": "supabase test db",
"migration:new": "supabase migration new"
},
"dependencies": {
"@axe-core/cli": "^4.11.3",
"@hookform/resolvers": "^5.4.0",
"@supabase/supabase-js": "^2.49.8",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^29.1.1",
"supabase": "^2.102.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
}
}

6
postcss.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

56
scripts/a11y-audit.mjs Normal file
View File

@@ -0,0 +1,56 @@
/**
* a11y-audit.mjs — Axe-core + jsdom audit sobre el HTML servidor por Next.js
* Uso: node scripts/a11y-audit.mjs [url]
*/
import { JSDOM } from 'jsdom'
import axe from 'axe-core'
const url = process.argv[2] || 'http://localhost:3001'
console.log(`Auditando ${url}\n`)
// 1. Fetch the rendered HTML from the dev server
const res = await fetch(url)
if (!res.ok) {
console.error(`ERROR: servidor respondió ${res.status}`)
process.exit(1)
}
const html = await res.text()
// 2. Parse in jsdom
const dom = new JSDOM(html, {
url,
runScripts: 'dangerously',
resources: 'usable',
})
const { window } = dom
const { document } = window
// Wait a tick for DOM to stabilize
await new Promise((r) => setTimeout(r, 200))
// 3. Configure axe and run
axe.configure({ allowedOrigins: ['<unsafe_all_origins>'] })
const results = await axe.run(document.body, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] },
})
const { violations, passes, inapplicable, incomplete } = results
console.log(`✅ Reglas superadas: ${passes.length}`)
console.log(`⚠️ Incompletas: ${incomplete.length}`)
console.log(`— No aplican: ${inapplicable.length}`)
console.log(`\n❌ Violaciones AA: ${violations.length}`)
if (violations.length > 0) {
for (const v of violations) {
console.log(`\n [${v.impact?.toUpperCase()}] ${v.id}${v.description}`)
console.log(` Help: ${v.helpUrl}`)
for (const node of v.nodes.slice(0, 3)) {
console.log(` HTML: ${node.html.slice(0, 120)}`)
}
}
process.exit(1)
} else {
console.log('\n✅ 0 violaciones AA — PASS\n')
}

BIN
sprints/.DS_Store vendored Normal file

Binary file not shown.

50
sprints/sprint-2/BRIEF.md Normal file
View File

@@ -0,0 +1,50 @@
# BRIEF — Sprint 2: Formulario funcional
> Brief versionado (Patrón A.1). Enmarca el sprint completo; el detalle ejecutable de cada etapa va
> en su `ETAPA_*_PROMPT.md`.
## Objetivo (una frase)
Que un respondente pueda abrir un link, ver la encuesta de cuidadores renderizada desde el esquema,
responderla con su lógica condicional, y enviarla — primero el motor del formulario (2A), luego el
consentimiento y la persistencia (2B).
## Por qué este sprint, y por qué partido
Es la primera pieza de cara al usuario real: permite **probar UX, lógica condicional, accesibilidad y
mobile, y dar feedback** antes de invertir en lo demás. Se parte en dos porque mezclan dos riesgos
distintos y conviene auditarlos por separado:
- **2A = riesgo de UX/funcionalidad** (¿se ve bien?, ¿la condicional anda?, ¿es accesible y mobile?).
- **2B = riesgo legal/privacidad** (consentimiento versionado inmutable, texto honesto, escritura del dato sensible).
## Alcance
- **Etapa 2A — Motor del formulario:** lee la encuesta live desde Supabase (anon key), renderiza las
34 preguntas con el widget correcto por tipo, ejecuta la lógica condicional, valida, mobile-first,
WCAG AA, resiliencia lite. **Al enviar muestra un resumen; NO escribe en la base.**
- **Etapa 2B — Consentimiento + persistencia:** pantalla de consentimiento granular versionado
(ADR-003) con texto honesto sobre anonimato y no-revocación, que crea el `consent_record` y recién
ahí escribe `response` + `answers`, con dedup por `ip_hash`.
## Fuera de alcance (Sprint 2)
- Dashboard / reportes (Sprint 3).
- Builder visual de encuestas (post-validación del caso fijo).
- Login de cliente / multi-tenant (org_admin queda inerte; ver `DATA_MODEL.md`).
- Integración LLM, Modo B, Nivel 3.
## Decisiones ya tomadas (no renegociar — ver ADRs y CLAUDE.md)
- Supabase como backend (ADR-002): el cliente habla directo a Supabase vía SDK + RLS.
- El formulario corre como **`anon`** (sin login). `service_role` NUNCA en el cliente (regla 9).
- El respondente nunca se ata a un IdP (regla 10 / ADR-004).
- `options`: array para `single_choice`/`multiple_choice`, objeto `{min,max,min_label,max_label}` para `rating`.
- Condicionales en la pregunta dependiente; disparador value `'Sí'` (B1/C1/D1).
- WCAG 2.2 AA es pilar (el sujeto es la discapacidad), no checkbox.
- El mockup `formulario_mapeo.html` es el **contrato visual** (look y flujo ya aprobados); 2A lo
replica pero data-driven desde el esquema, no hardcodeado.
## Criterios de éxito (Etapa 2A)
- [ ] Renderiza las 34 preguntas leídas desde Supabase (no hardcodeadas).
- [ ] Lógica condicional: B1/C1/D1 = "Sí" despliega sus bloques; "No"/"Prefiero no decir" los oculta y limpia.
- [ ] H1 limita a 3 selecciones; `rating` (F3/G4) maneja el formato objeto.
- [ ] Mobile-first fluido + navegable solo con teclado + WCAG AA base.
- [ ] Refresh no pierde lo cargado (resiliencia lite).
- [ ] Enviar muestra resumen; no escribe en la base.
- [ ] Build limpio; sin `service_role` en variables del cliente.
- [ ] **Validación humana (P2):** el director lo abre en desktop y mobile y lo clickea de punta a punta.

View File

@@ -0,0 +1,88 @@
# ETAPA 2A — Motor del formulario (render + lógica condicional, sin persistir)
> Prompt versionado (Patrón B.1). Primera pieza de cara al usuario. Claude Code lo ejecuta tras leer
> `CLAUDE.md`, `DATA_MODEL.md`, `SEED_SPEC.md`, `ADR-002`, `sprints/sprint-2/BRIEF.md` y este prompt.
## 1. Objetivo
Construir el motor del formulario: una app Next.js que **lee** la encuesta live desde Supabase con la
`anon` key, renderiza las 34 preguntas con el widget correcto por tipo, ejecuta la lógica condicional,
valida, es mobile-first y accesible (WCAG AA), no pierde lo cargado al refrescar, y al enviar muestra
un **resumen de respuestas**. **NO escribe nada en la base** (eso es 2B).
## 2. Contexto previo
La fundación de datos (Sprint 1) está cerrada y verificada en Cloud: esquema, RLS, seed real de 34
preguntas, k-anonimato. El proyecto Supabase está linkeado. Todavía no existe app frontend — esta
etapa la scaffolding. `responses.consent_record_id` es `NOT NULL`, por eso 2A **no** persiste: la
escritura requiere el consentimiento, que es 2B. El mockup `formulario_mapeo.html` (en el historial
del proyecto) es el contrato visual: replicar su look y flujo, pero leyendo del esquema, no hardcoded.
## 3. Decisiones ya tomadas (no consultar de nuevo)
- Stack: **Next.js (App Router)** + **react-hook-form + zod** + **@supabase/supabase-js** (o
`@supabase/ssr`). Sin librería de gráficos (eso es Sprint 3).
- El cliente usa la **`anon` key** (pública, respeta RLS), desde `NEXT_PUBLIC_SUPABASE_URL` y
`NEXT_PUBLIC_SUPABASE_ANON_KEY` en `.env.local`. **El `service_role` NUNCA va a una variable
`NEXT_PUBLIC_` ni al cliente** (regla 9 de CLAUDE.md).
- `options`: **array** para `single_choice`/`multiple_choice`; **objeto** `{min,max,min_label,max_label}`
para `rating` (F3, G4). Manejar las dos formas.
- Condicionales: cada pregunta dependiente trae `conditional_rules` con `{if_question, op:'eq',
value:'Sí', action:'show'}`. Mostrar solo si el disparador (B1/C1/D1) = "Sí"; con "No"/"Prefiero no
decir" ocultar **y limpiar** las respuestas de ese bloque.
- H1 = `multiple_choice` con `max_select=3` (impedir marcar más de 3).
- 2A **no** escribe `responses`/`answers`/`consent_records`. Al enviar → pantalla de resumen.
## 4. Tareas
1. Scaffolding de la app Next.js (App Router) en el repo. Setear el cliente Supabase con la `anon`
key desde `.env.local`. Crear `.env.example` (sin valores) documentando las dos variables.
2. Leer desde Supabase (anon) la encuesta `status='live'` + sus 34 `questions` ordenadas por
`position`, con sus `options`, `type`, `max_select`, `is_sensitive`, `conditional_rules`.
(Si la encuesta seed no está `live`, dejar documentado que el director debe setearla a `live`, o
leer por id — NO cambiar el estado desde el cliente.)
3. Renderizar cada tipo con su widget: `single_choice`→radios, `multiple_choice`→checkboxes (con tope
`max_select`), `rating`→escala 1-5 (usando el objeto options), `text_long`→textarea.
4. Motor de lógica condicional data-driven: evaluar `conditional_rules` para mostrar/ocultar; al
ocultar un bloque, limpiar sus valores.
5. Validación con zod (tipos correctos; respetar `max_select`; `required` según el campo `required`
de cada pregunta — hoy todas `false`, así que validación liviana).
6. UX: mobile-first fluida, replicando el contrato visual del mockup (secciones, progreso, estados de
opción). Accesibilidad WCAG 2.2 AA: HTML semántico, `<label>` asociado a cada input, navegación y
operación completa por teclado, foco visible, contraste suficiente, y anunciar a lectores de
pantalla la aparición/desaparición de los bloques condicionales (`aria-expanded`/`aria-live`).
7. Resiliencia lite: persistir las respuestas en progreso en `localStorage` (clave por `survey_id`)
para sobrevivir un refresh; limpiar al enviar. No guardar nada identificable.
8. Al enviar: validar y mostrar una **pantalla de resumen** con las respuestas dadas. NO llamar a la
base para escribir.
## 5. Definition of Done
- [ ] `npm run dev` levanta la app y carga la encuesta desde Supabase (anon).
- [ ] Se renderizan las 34 preguntas; los 3 bloques condicionales aparecen/desaparecen con B1/C1/D1.
- [ ] H1 no deja marcar más de 3; rating funciona con el formato objeto.
- [ ] Navegable y operable solo con teclado; pasa una auditoría a11y básica (axe o Lighthouse) sin errores AA.
- [ ] Refresh conserva lo cargado; enviar limpia el almacenamiento y muestra el resumen.
- [ ] `npm run build` limpio; ninguna variable `NEXT_PUBLIC_` contiene el `service_role`.
- [ ] `git diff --name-only HEAD` solo con archivos de la app nueva + `.env.example`.
## 6. Validaciones automatizadas mandatorias (antes de entregar)
- La carga lee **34** preguntas desde la base (logear el conteo), no un número hardcodeado.
- Las 12 condicionales se evalúan desde `conditional_rules` (no con `if` cableados por código).
- `max_select` de H1 se respeta en runtime (test o verificación manual reproducible).
- `npm run build` sin errores.
- Auditoría a11y (axe-core o `lighthouse`) sobre la página del formulario: **0 violaciones de nivel AA**.
- Grep de seguridad: `service_role` NO aparece en ningún archivo del cliente ni en `NEXT_PUBLIC_*`.
- Si CUALQUIERA falla: NO declarar lista la etapa. Reportar y esperar.
## 7. Qué reportar
- **Verificado:** conteo de preguntas cargadas, resultado de la auditoría a11y (con la herramienta y
el puntaje/violaciones), confirmación de que el build pasa, salida del grep de `service_role`.
- **Asumido / Hallazgos / Decisiones por iniciativa** (clasificadas por nivel).
- **`git diff --name-only HEAD`** pegado.
- Indicar cómo levantar la app localmente para que el director la abra (el comando y la URL).
## 8. Qué NO hacer
- NO escribir en `responses`, `answers` ni `consent_records` (es 2B).
- NO construir el flujo de consentimiento ni el dashboard.
- NO hardcodear las preguntas: todo se lee del esquema.
- NO poner el `service_role` en el cliente ni en variables `NEXT_PUBLIC_`.
- NO recolectar ni guardar nada identificable del respondente (sin `respondent_id`, sin fingerprinting,
sin claves de localStorage que identifiquen a la persona). El anonimato es la premisa.
- NO cambiar el estado de la encuesta (`draft`/`live`) desde el cliente.
- Ante conflicto entre una restricción y "que compile/funcione": **detenerse y reportar**.

BIN
supabase/.DS_Store vendored Normal file

Binary file not shown.

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}