Terminado sprint 2A
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
2
.gitignore
vendored
@@ -17,4 +17,4 @@ node_modules/
|
||||
# Supabase — claves y credenciales
|
||||
*.key
|
||||
service_role*
|
||||
.env.production
|
||||
.env.production.DS_Store
|
||||
|
||||
3
.next/app-build-manifest.json
Normal file
3
.next/app-build-manifest.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": {}
|
||||
}
|
||||
17
.next/build-manifest.json
Normal file
17
.next/build-manifest.json
Normal 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
1
.next/cache/.rscinfo
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"encryption.key":"rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc=","encryption.expire_at":1781610552095}
|
||||
1
.next/cache/.tsbuildinfo
vendored
Normal file
1
.next/cache/.tsbuildinfo
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/2.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/2.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/3.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/3.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/4.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/4.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/5.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/5.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/index.pack.gz.old
vendored
Normal file
BIN
.next/cache/webpack/client-development/index.pack.gz.old
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/0.pack
vendored
Normal file
BIN
.next/cache/webpack/client-production/0.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/1.pack
vendored
Normal file
BIN
.next/cache/webpack/client-production/1.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/2.pack
vendored
Normal file
BIN
.next/cache/webpack/client-production/2.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/3.pack
vendored
Normal file
BIN
.next/cache/webpack/client-production/3.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/4.pack
vendored
Normal file
BIN
.next/cache/webpack/client-production/4.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/index.pack
vendored
Normal file
BIN
.next/cache/webpack/client-production/index.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-production/index.pack.old
vendored
Normal file
BIN
.next/cache/webpack/client-production/index.pack.old
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/edge-server-production/0.pack
vendored
Normal file
BIN
.next/cache/webpack/edge-server-production/0.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/edge-server-production/index.pack
vendored
Normal file
BIN
.next/cache/webpack/edge-server-production/index.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/1.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/1.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/2.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/2.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/3.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/3.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/index.pack.gz.old
vendored
Normal file
BIN
.next/cache/webpack/server-development/index.pack.gz.old
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/0.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/0.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/1.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/1.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/2.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/2.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/3.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/3.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/index.pack
vendored
Normal file
BIN
.next/cache/webpack/server-production/index.pack
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-production/index.pack.old
vendored
Normal file
BIN
.next/cache/webpack/server-production/index.pack.old
vendored
Normal file
Binary file not shown.
1
.next/package.json
Normal file
1
.next/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"type": "commonjs"}
|
||||
1
.next/react-loadable-manifest.json
Normal file
1
.next/react-loadable-manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
.next/server/app-paths-manifest.json
Normal file
1
.next/server/app-paths-manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
.next/server/interception-route-rewrite-manifest.js
Normal file
1
.next/server/interception-route-rewrite-manifest.js
Normal file
@@ -0,0 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
19
.next/server/middleware-build-manifest.js
Normal file
19
.next/server/middleware-build-manifest.js
Normal 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",
|
||||
|
||||
];
|
||||
6
.next/server/middleware-manifest.json
Normal file
6
.next/server/middleware-manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 3,
|
||||
"middleware": {},
|
||||
"functions": {},
|
||||
"sortedMiddleware": []
|
||||
}
|
||||
1
.next/server/middleware-react-loadable-manifest.js
Normal file
1
.next/server/middleware-react-loadable-manifest.js
Normal file
@@ -0,0 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
1
.next/server/next-font-manifest.js
Normal file
1
.next/server/next-font-manifest.js
Normal file
@@ -0,0 +1 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
||||
1
.next/server/next-font-manifest.json
Normal file
1
.next/server/next-font-manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
||||
1
.next/server/pages-manifest.json
Normal file
1
.next/server/pages-manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
.next/server/server-reference-manifest.js
Normal file
1
.next/server/server-reference-manifest.js
Normal file
@@ -0,0 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
||||
5
.next/server/server-reference-manifest.json
Normal file
5
.next/server/server-reference-manifest.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc="
|
||||
}
|
||||
1
.next/static/chunks/polyfills.js
Normal file
1
.next/static/chunks/polyfills.js
Normal file
File diff suppressed because one or more lines are too long
1
.next/static/development/_buildManifest.js
Normal file
1
.next/static/development/_buildManifest.js
Normal 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()
|
||||
1
.next/static/development/_ssgManifest.js
Normal file
1
.next/static/development/_ssgManifest.js
Normal file
@@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
2
.next/trace
Normal file
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
141
.next/types/cache-life.d.ts
vendored
Normal 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
1
.next/types/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"type": "module"}
|
||||
425
app/globals.css
Normal file
425
app/globals.css
Normal 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
23
app/layout.tsx
Normal 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
15
app/loading.tsx
Normal 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
80
app/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
26
components/progress-bar.tsx
Normal file
26
components/progress-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
components/question-renderer.tsx
Normal file
89
components/question-renderer.tsx
Normal 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
|
||||
}
|
||||
110
components/summary-screen.tsx
Normal file
110
components/summary-screen.tsx
Normal 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
249
components/survey-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
components/widgets/checkbox-group.tsx
Normal file
85
components/widgets/checkbox-group.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
components/widgets/radio-group.tsx
Normal file
59
components/widgets/radio-group.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
components/widgets/rating-scale.tsx
Normal file
71
components/widgets/rating-scale.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
components/widgets/text-area-field.tsx
Normal file
47
components/widgets/text-area-field.tsx
Normal 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
53
lib/conditional.ts
Normal 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
27
lib/storage.ts
Normal 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
17
lib/supabase.ts
Normal 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
52
lib/types.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone"
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
3516
package-lock.json
generated
3516
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -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
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
export default config
|
||||
56
scripts/a11y-audit.mjs
Normal file
56
scripts/a11y-audit.mjs
Normal 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
BIN
sprints/.DS_Store
vendored
Normal file
Binary file not shown.
50
sprints/sprint-2/BRIEF.md
Normal file
50
sprints/sprint-2/BRIEF.md
Normal 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.
|
||||
88
sprints/sprint-2/ETAPA_2A_PROMPT.md
Normal file
88
sprints/sprint-2/ETAPA_2A_PROMPT.md
Normal 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
BIN
supabase/.DS_Store
vendored
Normal file
Binary file not shown.
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user