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. + */} +