From 31ee899987e92cfad8f08df1a1279a6b44f64229 Mon Sep 17 00:00:00 2001 From: Uriel Jareth Date: Fri, 28 Aug 2026 02:37:42 -0600 Subject: [PATCH] fix: defectos encontrados por la suite de extremo a extremo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La primera corrida completa dio 57 fallas. Solo una parte eran del producto; el resto era instrumentación mal escrita. Queda en 261 en verde. Defectos del producto: - La atribución de campaña se perdía. La captura vivía en un efecto de React, así que si la persona llegaba con parámetros de campaña y navegaba antes de hidratar, el primer toque nunca se guardaba y utmCampaign llegaba vacío al CRM. Ahora se captura en un script en línea que corre al analizar el documento. - Siete páginas sin og:image: una página que declara su propio openGraph no hereda las imágenes del layout, las sobreescribe. Solo se nota al compartir el enlace. Se resuelve con una función compartida en seo.ts. - El panel no se podía usar desde Safari por HTTP: la cookie Secure no se guarda y el acceso falla en silencio. Queda explícito y documentado. - El panel se corría de lado en móvil: la cabecera no cabía a 375px. - Las tarjetas de perfil del acceso se cortaban en móvil. Un elemento de rejilla nace con min-width auto y no puede encogerse bajo su contenido. Era además el origen de un desplazamiento que se arrastraba al tablero. - Las tablas accesibles de las gráficas volvían desplazable la página: sr-only fija width 1px y el algoritmo de tablas lo ignora. - El campo de búsqueda del catálogo no tenía nombre accesible. - Límite de tasa de 5 envíos por IP: bloqueaba una demostración en vivo con varias personas en la misma red. Configurable, ahora 20. - El salto de contenido pasa de left:-9999px a recorte de 1px. Instrumentación corregida, con la razón escrita en cada caso: - El desborde horizontal se mide intentando desplazar la página, no con documentElement.scrollWidth, que sobreinforma con contenedores propios. - La comprobación de accesibilidad respeta el árbol de accesibilidad. - El aislamiento entre roles se prueba pidiendo por URL una oportunidad ajena, no comparando folios entre listados paginados. - Fuera las esperas por networkidle, que agotaban el tiempo sin fallar. - Que el panel no esté enlazado se comprueba leyendo el HTML servido. - Safari no enfoca enlaces con Tab: declarado como del navegador. Co-Authored-By: Claude Opus 5 (1M context) --- docs/DESPLIEGUE.md | 15 ++ src/app/(sitio)/aviso-de-privacidad/page.tsx | 2 + src/app/(sitio)/casos/page.tsx | 2 + src/app/(sitio)/contacto/page.tsx | 2 + src/app/(sitio)/cumplimiento-nom-002/page.tsx | 2 + src/app/(sitio)/evidencia/page.tsx | 2 + src/app/(sitio)/nosotros/page.tsx | 2 + src/app/(sitio)/preguntas-frecuentes/page.tsx | 2 + src/app/api/leads/route.ts | 12 +- src/app/globals.css | 36 ++++- src/app/layout.tsx | 29 ++++ src/components/admin/Cascaron.tsx | 11 +- .../admin/graficas/BarrasMagnitud.tsx | 4 +- src/components/admin/graficas/Embudo.tsx | 4 +- src/components/admin/graficas/SerieTiempo.tsx | 4 +- src/components/admin/login/Acceso.tsx | 7 +- .../site/catalogo/FiltrosCatalogo.tsx | 5 + src/lib/crm/auth.ts | 26 +++- src/lib/seo.ts | 16 ++ tests/02-responsivo.spec.ts | 43 ++++-- tests/03-captura.spec.ts | 4 +- tests/04-panel.spec.ts | 146 +++++++++++++----- tests/05-accesibilidad.spec.ts | 33 +++- tests/capturar.mjs | 23 --- 24 files changed, 346 insertions(+), 86 deletions(-) delete mode 100644 tests/capturar.mjs diff --git a/docs/DESPLIEGUE.md b/docs/DESPLIEGUE.md index 7b7d6e0..d9add89 100644 --- a/docs/DESPLIEGUE.md +++ b/docs/DESPLIEGUE.md @@ -12,6 +12,7 @@ SQLite en un volumen. No hay base de datos externa. | `NEXT_PUBLIC_SITE_URL` | Sí, **en construcción** | Las URL canónicas, el sitemap y Open Graph apuntan al dominio equivocado. Ver la advertencia de abajo. | | `NEXT_PUBLIC_WHATSAPP` | Sí, **en construcción** | El widget de WhatsApp abre una conversación con un número vacío. | | `DEMO_ACCESO_RAPIDO` | No | Con cualquier valor distinto de `false`, el acceso de un clic por perfil está activo en `/admin/login`. | +| `AUTH_COOKIE_INSECURE` | No | Solo para pruebas locales sin TLS. Ver la advertencia de HTTPS abajo. **Nunca en producción.** | | `NODE_ENV` | No | Ya viene en `production` dentro de la imagen. | ### Advertencia sobre las variables `NEXT_PUBLIC_*` @@ -38,6 +39,20 @@ node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" Uno por entorno. No reutilices el de desarrollo: cualquiera que lo tenga puede firmar una cookie de sesión válida y entrar al panel como administrador. +### El panel exige HTTPS + +La cookie de sesión va marcada como `Secure`, que es lo correcto. La +consecuencia es que **si el panel se sirve por HTTP plano, en Safari y en iOS +nadie puede entrar**: el navegador se niega a guardar la cookie, el acceso +devuelve a la pantalla de inicio de sesión y no dice por qué. Chromium hace una +excepción con `localhost`, así que el problema se ve solo en WebKit y es fácil +de atribuir a otra cosa. + +Detrás de Coolify con Cloudflare esto no ocurre, porque el tráfico llega por +HTTPS. Importa en dos casos: si alguien expone el contenedor por IP y puerto sin +TLS, y al probar en local. Para lo segundo existe `AUTH_COOKIE_INSECURE=true`, +que **no debe usarse en un entorno accesible desde internet**. + ## El volumen — la parte frágil **Si no montas `/app/data`, cada redespliegue borra los leads capturados.** diff --git a/src/app/(sitio)/aviso-de-privacidad/page.tsx b/src/app/(sitio)/aviso-de-privacidad/page.tsx index 0e5498f..d794513 100644 --- a/src/app/(sitio)/aviso-de-privacidad/page.tsx +++ b/src/app/(sitio)/aviso-de-privacidad/page.tsx @@ -4,6 +4,7 @@ import { FormularioContacto } from "@/components/formulario/FormularioContacto"; import { Migas } from "@/components/site/contenido/Migas"; import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding"; import { AVISO_DEMO, EMPRESA, SITIO_URL } from "@/data/empresa"; +import { imagenesOg } from "@/lib/seo"; /** * /aviso-de-privacidad @@ -32,6 +33,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/aviso-de-privacidad`, + ...imagenesOg(), }, }; diff --git a/src/app/(sitio)/casos/page.tsx b/src/app/(sitio)/casos/page.tsx index 38f5ac6..6634075 100644 --- a/src/app/(sitio)/casos/page.tsx +++ b/src/app/(sitio)/casos/page.tsx @@ -9,6 +9,7 @@ import { getCategoria } from "@/data/catalogo"; import type { ClaseFuego } from "@/data/clases-fuego"; import { EMPRESA, SITIO_URL } from "@/data/empresa"; import { getIndustria } from "@/data/industrias"; +import { imagenesOg } from "@/lib/seo"; /** * /casos — escenarios de aplicación con la estructura que pide el estudio: @@ -35,6 +36,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/casos`, + ...imagenesOg(), }, }; diff --git a/src/app/(sitio)/contacto/page.tsx b/src/app/(sitio)/contacto/page.tsx index 597d2db..7fbc90d 100644 --- a/src/app/(sitio)/contacto/page.tsx +++ b/src/app/(sitio)/contacto/page.tsx @@ -5,6 +5,7 @@ import { Migas } from "@/components/site/contenido/Migas"; import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding"; import { Revelar } from "@/components/site/Revelar"; import { EMPRESA, SITIO_URL, WHATSAPP } from "@/data/empresa"; +import { imagenesOg } from "@/lib/seo"; /** * /contacto @@ -30,6 +31,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/contacto`, + ...imagenesOg(), }, }; diff --git a/src/app/(sitio)/cumplimiento-nom-002/page.tsx b/src/app/(sitio)/cumplimiento-nom-002/page.tsx index aa66755..0f31333 100644 --- a/src/app/(sitio)/cumplimiento-nom-002/page.tsx +++ b/src/app/(sitio)/cumplimiento-nom-002/page.tsx @@ -8,6 +8,7 @@ import { Revelar } from "@/components/site/Revelar"; import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa"; import { ESCENARIOS, type Escenario } from "@/data/escenarios"; import { INDUSTRIAS } from "@/data/industrias"; +import { imagenesOg } from "@/lib/seo"; /** * /cumplimiento-nom-002 — la página de mayor intención de compra del sitio. @@ -34,6 +35,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/cumplimiento-nom-002`, + ...imagenesOg(), }, }; diff --git a/src/app/(sitio)/evidencia/page.tsx b/src/app/(sitio)/evidencia/page.tsx index ac56338..a57518f 100644 --- a/src/app/(sitio)/evidencia/page.tsx +++ b/src/app/(sitio)/evidencia/page.tsx @@ -6,6 +6,7 @@ import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding"; import { Revelar } from "@/components/site/Revelar"; import { CATEGORIAS, PRODUCTOS, productosDeCategoria } from "@/data/catalogo"; import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa"; +import { imagenesOg } from "@/lib/seo"; /** * /evidencia — el centro de evidencia de la recomendación 10.3 del estudio. @@ -32,6 +33,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/evidencia`, + ...imagenesOg(), }, }; diff --git a/src/app/(sitio)/nosotros/page.tsx b/src/app/(sitio)/nosotros/page.tsx index 9606c2c..8d36b52 100644 --- a/src/app/(sitio)/nosotros/page.tsx +++ b/src/app/(sitio)/nosotros/page.tsx @@ -8,6 +8,7 @@ import { Revelar } from "@/components/site/Revelar"; import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo"; import { CLIENTES, EMPRESA, PRENSA, SITIO_URL } from "@/data/empresa"; import { INDUSTRIAS } from "@/data/industrias"; +import { imagenesOg } from "@/lib/seo"; /** * /nosotros — origen de la tecnología, posicionamiento y postura editorial. @@ -31,6 +32,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/nosotros`, + ...imagenesOg(), }, }; diff --git a/src/app/(sitio)/preguntas-frecuentes/page.tsx b/src/app/(sitio)/preguntas-frecuentes/page.tsx index 1dbdbdc..00d8539 100644 --- a/src/app/(sitio)/preguntas-frecuentes/page.tsx +++ b/src/app/(sitio)/preguntas-frecuentes/page.tsx @@ -7,6 +7,7 @@ import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding"; import { Revelar } from "@/components/site/Revelar"; import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa"; import { ESCENARIOS, type Escenario } from "@/data/escenarios"; +import { imagenesOg } from "@/lib/seo"; /** * /preguntas-frecuentes @@ -36,6 +37,7 @@ export const metadata: Metadata = { title: `${TITULO} | ${EMPRESA.nombreCorto}`, description: DESCRIPCION, url: `${SITIO_URL}/preguntas-frecuentes`, + ...imagenesOg(), }, }; diff --git a/src/app/api/leads/route.ts b/src/app/api/leads/route.ts index 436ffda..65bec45 100644 --- a/src/app/api/leads/route.ts +++ b/src/app/api/leads/route.ts @@ -33,8 +33,16 @@ export const dynamic = "force-dynamic"; * instancia; en un despliegue real esto viviría en Redis con una clave por IP, * para que el límite valga entre todas las réplicas y sobreviva a un reinicio. */ -const MAX_ENVIOS = 5; -const VENTANA_MS = 10 * 60 * 1000; +/** + * El límite es configurable porque el valor correcto depende de quién está del + * otro lado. Cinco envíos por ventana frena a un bot, pero también bloquea una + * demostración en vivo donde tres personas prueban el formulario desde la misma + * IP de oficina, y una suite de pruebas de extremo a extremo lo agota en la + * primera corrida. Veinte sigue siendo un techo bajo para un abuso real y deja + * respirar a los dos casos legítimos. + */ +const MAX_ENVIOS = Number(process.env.LEADS_MAX_ENVIOS ?? 20); +const VENTANA_MS = Number(process.env.LEADS_VENTANA_MINUTOS ?? 10) * 60 * 1000; const registro = new Map(); function ipDe(peticion: NextRequest): string { diff --git a/src/app/globals.css b/src/app/globals.css index d8563a2..3cfe56b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -440,17 +440,49 @@ -webkit-tap-highlight-color: transparent; } + /* + * Defensa: una tabla con `sr-only` no se encoge. + * + * `sr-only` fija `width: 1px`, pero el algoritmo de tablas ignora el ancho + * cuando el contenido pide más. Las tablas equivalentes de las gráficas del + * panel medían 456px, quedaban invisibles por el `clip` y aun así volvían la + * página desplazable en horizontal en móvil. Lo correcto es poner `sr-only` + * en un envoltorio de bloque; esto es la red por si alguien vuelve a ponerlo + * en la tabla. + */ + .sr-only { + table-layout: fixed; + } + /* --- salto de accesibilidad --- */ + /* + * Salto de contenido. + * + * Se oculta recortando una caja de 1px, no sacándola a `left: -9999px`. La + * técnica antigua deja un cuadro real de ~174px fuera del lienzo, y eso + * agrandaba el área desplazable del documento: en el panel a 375px la página + * se corría 37px de lado sin que nada se viera fuera de lugar. Costó + * encontrarlo porque el elemento culpable era invisible y estaba a la + * izquierda, no a la derecha. + */ .skip { position: absolute; - left: -9999px; - top: 0; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; z-index: 200; } .skip:focus { + position: fixed; left: 1rem; top: 1rem; + width: auto; + height: auto; + overflow: visible; + clip-path: none; background-color: var(--color-signal); color: #fff; padding: 0.75rem 1rem; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e6d31f5..800180e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -75,6 +75,35 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + + {/* + CAPTURA DE ATRIBUCIÓN — corre al analizar el documento, antes de hidratar. + + Antes esto vivía solo en un `useEffect` del formulario, y ahí llegaba + tarde: si la persona aterrizaba con parámetros de campaña y navegaba + antes de que React hidratara, el primer toque no se alcanzaba a + guardar y la campaña se perdía. Se reprodujo en pruebas y le puede + pasar a cualquiera con conexión lenta o con la pestaña en segundo plano. + + Guardar el primer toque es una escritura de tres líneas que no necesita + React, así que se hace aquí. `src/lib/utm.ts` sigue siendo quien lee y + combina; si cambia la llave, hay que cambiarla en los dos lugares. + */} +