Files
fspm-web/src/app/(sitio)/casos/page.tsx
T
Uriel JarethandClaude Opus 5 31ee899987 fix: defectos encontrados por la suite de extremo a extremo
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) <[email protected]>
2026-08-28 02:37:42 -06:00

503 lines
23 KiB
TypeScript

import type { Metadata } from "next";
import Link from "next/link";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { RielClases } from "@/components/marca/RielClases";
import { Migas } from "@/components/site/contenido/Migas";
import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
import { Revelar } from "@/components/site/Revelar";
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:
* problema → riesgo → solución → prueba → resultado → documentación.
*
* REGLA DURA: son escenarios construidos con fines de demostración. No
* corresponden a clientes reales, no llevan nombres de empresa y no presentan
* cifras como si fueran mediciones. El rótulo va en el héroe y se repite arriba
* del contenido, nunca en una nota al pie.
*
* Cada caso abre con un resumen en una frase, para quien no va a leer los seis
* pasos, y después desarrolla el detalle técnico para quien sí.
*/
const TITULO = "Casos de aplicación: cómo se resuelve un escenario real";
const DESCRIPCION =
"Cuatro escenarios ilustrativos de protección contra incendios —planta química, bodega, gasolinera y cocina de hotel— con la estructura problema, riesgo, solución, prueba, resultado y documentación. Construidos con fines de demostración.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/casos` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/casos`,
...imagenesOg(),
},
};
const MENSAJE_INICIAL = "Mi escenario se parece al de ";
type Caso = {
id: string;
industriaSlug: string;
titulo: string;
escenario: string;
/** El caso en una frase, para quien no va a leer los seis pasos. */
resumen: string;
clases: ClaseFuego[];
categorias: string[];
problema: string;
riesgo: string;
solucion: string[];
prueba: string;
resultado: string[];
documentacion: string[];
};
const CASOS: Caso[] = [
{
id: "trasvase",
industriaSlug: "industria-energia",
titulo: "Patio de trasvase de solventes en una planta química",
escenario:
"Escenario ilustrativo · Planta química, patio de trasvase con contención secundaria",
resumen:
"El equipo estaba a cien metros de donde puede arder. Se cronometró el recorrido y se movió el equipo, no se compró más.",
clases: ["A", "B", "C"],
categorias: ["agentes-extinguidores", "productos-moviles"],
problema:
"El patio de trasvase concentra el riesgo más alto de la planta y está a más de cien metros de la caseta de seguridad. La respuesta disponible es equipo portátil montado en pared, pensado para un conato de oficina, no para un derrame inflamado con producto a presión.",
riesgo:
"Un derrame encendido en contención secundaria no da minutos: escala en segundos y compromete el resto del patio. El costo real no es el equipo perdido, es el paro de línea, la investigación posterior y la renovación de la póliza.",
solucion: [
"Diagnóstico por zona con inventario de combustibles y volúmenes reales de operación.",
"Equipo móvil de mayor capacidad posicionado a distancia de recorrido, no a distancia en línea recta.",
"Selección del agente por tipo de combustible, incluida la condición de alcoholes si hay producto polar.",
"Capacitación de la brigada del turno que efectivamente está en el patio de noche.",
],
prueba:
"Demostración con fuego controlado del mismo combustible que se maneja en el patio, operada por el personal del turno y no por un instructor. Se mide el tiempo desde la alarma hasta la primera aplicación de agente y se identifica en qué punto del recorrido se pierde tiempo.",
resultado: [
"Queda documentado cuánto tarda hoy la primera respuesta en el punto más lejano, que es el dato que la operación no tenía.",
"El reposicionamiento del equipo se decide con el recorrido cronometrado, no con un plano.",
"La brigada opera el equipo por primera vez antes del incidente, no durante.",
],
documentacion: [
"Memoria del diagnóstico por zona con inventario de combustibles.",
"Ficha técnica y hoja de datos de seguridad del agente seleccionado.",
"Registro de la demostración con fecha, participantes y tiempos observados.",
"Constancia de capacitación por persona y turno.",
],
},
{
id: "bodega",
industriaSlug: "logistica-bodegas",
titulo: "Nave de almacenamiento con carga combustible y montacargas eléctricos",
escenario: "Escenario ilustrativo · Centro logístico, nave única con racks de gran altura",
resumen:
"Había extintores suficientes en el conteo, pero no los correctos en los pasillos correctos.",
clases: ["A", "B", "C"],
categorias: ["extintores-portatiles", "rociadores"],
problema:
"La nave tiene extintores suficientes en el conteo, pero el riesgo cambia de pasillo a pasillo: producto terminado en cartón, un área de carga de baterías de montacargas y una zona de residuos al fondo. El equipo está distribuido de forma uniforme sobre un riesgo que no es uniforme.",
riesgo:
"El área de carga de baterías introduce riesgo eléctrico dentro de una nave de carga combustible, y la zona de residuos genera reencendido cuando se ataca con un agente que apaga la llama pero deja brasa. Una sola ruta de salida agrava cualquier retraso.",
solucion: [
"Mapa de riesgo por zona, con la altura de almacenamiento y el tipo de carga de cada pasillo.",
"Redistribución del equipo por distancia real de recorrido del operador, no por metros cuadrados.",
"Solución de activación automática en las zonas de mayor acumulación y en el área de carga de baterías.",
"Programa de inspección mensual con responsable nombrado y bitácora por equipo.",
],
prueba:
"Recorrido cronometrado desde el punto más desfavorable de cada zona hasta el equipo más cercano, con un operador de piso y no con personal de seguridad. Verificación de que cada equipo corresponde a la clase de fuego de su zona.",
resultado: [
"Se identifican las zonas donde el equipo existente no corresponde a la clase de fuego presente.",
"La distribución deja de basarse en el conteo total y empieza a basarse en el recorrido.",
"La inspección mensual pasa de ser una intención a tener nombre, formato y fecha.",
],
documentacion: [
"Mapa de riesgo por zona con la clasificación aplicada.",
"Inventario de equipo con ubicación, clase y capacidad.",
"Bitácora de revisión mensual con responsable asignado.",
"Constancia de instalación y puesta en marcha de las soluciones automáticas.",
],
},
{
id: "gasolinera",
industriaSlug: "gasolineras-flotas",
titulo: "Estación de servicio con público en isla y autotanques en patio",
escenario: "Escenario ilustrativo · Estación de servicio urbana con seis posiciones de carga",
resumen:
"Quien responde primero es el despachador. Se entrenó a los tres turnos y se dejó constancia de quién puede operar el equipo.",
clases: ["A", "B", "C"],
categorias: ["productos-moviles", "extintores-portatiles"],
problema:
"La primera respuesta la da el despachador de turno, no un bombero. El equipo instalado requiere una secuencia de operación que nadie del personal ha practicado, y en la isla hay clientes a pocos metros del punto de carga.",
riesgo:
"Un fuego de hidrocarburo en isla con público presente convierte cualquier duda de operación en un problema de evacuación. Aplicar agua a chorro sobre combustible derramado esparce el fuego en lugar de contenerlo.",
solucion: [
"Equipo operable en el primer intento por personal sin entrenamiento profundo, en isla y en patio.",
"Agente adecuado para hidrocarburos, con la condición de aplicación declarada por escrito.",
"Entrenamiento del personal de los tres turnos, no solo del encargado.",
"Procedimiento de respuesta y control de derrame colocado donde ocurre el incidente.",
],
prueba:
"Práctica con fuego controlado en la que cada despachador opera el equipo una vez, sin asistencia. Se registra quién completó la secuencia y quién no, y se repite hasta que todos la completan.",
resultado: [
"Queda constancia nominal de quién puede operar el equipo y quién todavía no.",
"El procedimiento deja de vivir en una carpeta y queda colocado en la isla.",
"Se documenta la selección del agente frente a los combustibles que la estación despacha.",
],
documentacion: [
"Ficha técnica del equipo y hoja de datos de seguridad del agente.",
"Procedimiento de respuesta y control de derrame, firmado por el responsable de la estación.",
"Constancias de capacitación por persona y turno.",
"Requisitos adicionales de Protección Civil municipal, cuando la autoridad local los establezca.",
],
},
{
id: "cocina",
industriaSlug: "cocinas-hoteles",
titulo: "Cocina de hotel con freidoras bajo una sola campana",
escenario: "Escenario ilustrativo · Hotel de servicio completo, cocina central y comedor",
resumen:
"Lo que estaba colgado en la pared no sirve para aceite hirviendo. Se cambió por equipo apto para clase K y se entrenó al turno de cierre.",
clases: ["A", "B", "K"],
categorias: ["rociadores", "extintores-portatiles"],
problema:
"Bajo la misma campana conviven varias freidoras y planchas. El equipo colgado en la pared no está calificado para fuego de aceite y grasa a temperatura de fritura, y el ducto de extracción acumula grasa entre limpiezas.",
riesgo:
"Aplicar agua sobre aceite hirviendo provoca proyección violenta de grasa encendida sobre quien opera el equipo. Además del riesgo a la persona, un incidente en campana y ducto detiene el servicio de alimentos y arrastra la ocupación del hotel.",
solucion: [
"Solución específica por cocina, con atención separada a campana y ducto.",
"Equipo apto para clase K en la línea de fritura, con su condición de aplicación documentada.",
"Demostración con el personal que realmente opera de noche, incluido el turno de cierre.",
"Procedimiento posterior al incidente para reanudar el servicio sin improvisar.",
],
prueba:
"Demostración de fuego de aceite en condiciones controladas, con el personal de cocina operando. Verificación de que la limpieza del ducto está registrada y de que el registro corresponde a lo que se observa en el ducto.",
resultado: [
"El equipo genérico de la pared se sustituye o se complementa con lo que sí aplica a clase K.",
"El personal de cierre sabe qué hacer y qué no hacer con el aceite encendido.",
"La reanudación del servicio queda escrita antes de necesitarla, no después.",
],
documentacion: [
"Ficha técnica del equipo apto para clase K y condiciones de aplicación.",
"Registro de limpieza de campanas y ductos con fecha y responsable.",
"Procedimiento posterior al incidente y de reanudación del servicio.",
"Constancias de capacitación del personal de cocina por turno.",
],
},
];
export default function PaginaCasos() {
return (
<>
<HeroeLanding
antes={<Migas actual="Casos de aplicación" />}
eyebrow="Escenarios ilustrativos"
titulo="Cuatro escenarios y cómo se resuelve cada uno"
entrada="Planta química, bodega, gasolinera y cocina de hotel. Cada caso sigue seis pasos: problema, riesgo, solución, prueba, resultado y documentación."
foto={FOTOS.planta}
enfoque="50% 62%"
altura="alto"
prioridad
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<Link href="#plantear" className="btn btn-signal w-full sm:w-auto">
Plantear mi escenario
</Link>
<p className="max-w-md font-mono text-[0.625rem] uppercase leading-relaxed tracking-[0.1em] text-ember">
Construidos para demostración · No corresponden a clientes reales
</p>
</div>
</HeroeLanding>
{/* ---------- el rótulo, arriba del contenido ---------- */}
<section className="border-t border-rule bg-ink-2 py-10 sm:py-12" aria-labelledby="rotulo">
<div className="shell">
<div className="rounded-[6px] border border-rule bg-ink p-0">
<div className="hazard h-1.5 w-full rounded-t-[6px]" aria-hidden="true" />
<div className="p-5 sm:p-6">
<h2
id="rotulo"
className="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.16em] text-ember"
>
Qué son y qué no son estos casos
</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted">
Los cuatro casos de esta página están{" "}
<span className="text-bright">
construidos con fines de demostración y no corresponden a clientes reales
</span>
. No incluyen nombres de empresa, y sus resultados se describen de forma
cualitativa: ninguna cifra de esta página debe leerse como una medición. Están
escritos para mostrar cómo se estructura un caso, no para acreditar uno.
</p>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted">
Aun así, la estructura es la real. Cuando la prueba se hace antes de la compra, la
documentación deja de ser un trámite y se vuelve la constancia de algo que sí
ocurrió.
</p>
</div>
</div>
</div>
</section>
{/* ---------- los casos ---------- */}
{CASOS.map((caso, indice) => {
const industria = getIndustria(caso.industriaSlug);
const claro = indice % 2 === 0;
return (
<section
key={caso.id}
id={caso.id}
aria-labelledby={`caso-${caso.id}`}
className={[
"border-t py-14 sm:py-20",
claro ? "grid-bg-lite border-rule bg-paper" : "border-rule bg-ink",
].join(" ")}
>
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.35fr] lg:gap-16">
{/* identificación del caso */}
<Revelar className="lg:sticky lg:top-24 lg:self-start">
<p
className={[
"font-mono text-[0.625rem] uppercase tracking-[0.16em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Caso {String(indice + 1).padStart(2, "0")} de {CASOS.length}
</p>
<h2
id={`caso-${caso.id}`}
className={[
"mt-4 font-display text-2xl font-extrabold leading-tight sm:text-3xl",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{caso.titulo}
</h2>
<p
className={[
"mt-4 text-base leading-relaxed",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{caso.resumen}
</p>
<p
className={[
"mt-4 font-mono text-[0.6875rem] uppercase leading-relaxed tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{caso.escenario}
</p>
<div className="mt-7">
<RielClases
activas={caso.clases}
tamano="md"
claro={claro}
etiqueta="Clases del escenario"
/>
</div>
<ul className="mt-7 flex flex-wrap gap-2">
{caso.categorias.map((slug) => {
const categoria = getCategoria(slug);
if (!categoria) return null;
return (
<li key={slug}>
<Link
href={`/catalogo/${categoria.slug}`}
className={[
"badge [-webkit-tap-highlight-color:transparent]",
claro
? "!border-rule-lite !text-deep-muted hover:!border-signal hover:!text-deep"
: "hover:border-signal hover:text-bright",
].join(" ")}
>
{categoria.nombre}
</Link>
</li>
);
})}
</ul>
{industria ? (
<Link
href={`/soluciones/${industria.slug}`}
className="mt-7 inline-block font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal hover:text-signal-hi"
>
Segmento: {industria.nombre} &rarr;
</Link>
) : null}
</Revelar>
{/* los seis pasos */}
<div className="space-y-8">
<Paso claro={claro} n="01" titulo="Problema">
<p className={parrafo(claro)}>{caso.problema}</p>
</Paso>
<Paso claro={claro} n="02" titulo="Riesgo" acento>
<p className={parrafo(claro)}>{caso.riesgo}</p>
</Paso>
<Paso claro={claro} n="03" titulo="Solución">
<Lista claro={claro} elementos={caso.solucion} />
</Paso>
<Paso claro={claro} n="04" titulo="Prueba">
<p className={parrafo(claro)}>{caso.prueba}</p>
</Paso>
<Paso claro={claro} n="05" titulo="Resultado">
<Lista claro={claro} elementos={caso.resultado} />
<p
className={[
"mt-4 font-mono text-[0.625rem] uppercase leading-relaxed tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Resultados descritos de forma cualitativa: este escenario no tiene mediciones
reales que reportar.
</p>
</Paso>
<Paso claro={claro} n="06" titulo="Documentación">
<Lista claro={claro} elementos={caso.documentacion} />
</Paso>
<div>
<Link
href="#plantear"
className={["btn btn-sm", claro ? "btn-deep" : "btn-signal"].join(" ")}
>
Mi caso se parece a este
</Link>
</div>
</div>
</div>
</div>
</section>
);
})}
{/* ---------- conversión ---------- */}
<section
id="plantear"
className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20"
aria-labelledby="plantear-titulo"
>
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.05fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Siguiente paso</p>
<h2
id="plantear-titulo"
className="font-display text-2xl font-extrabold text-deep sm:text-3xl"
>
Cuéntanos tu escenario y lo estructuramos igual
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted sm:text-base">
Los seis pasos no son un formato de presentación: son el orden en el que conviene
trabajar. Empieza describiendo el lugar y nosotros hacemos las preguntas que
faltan.
</p>
</Revelar>
<hr className="hair-lite my-8" />
<h3 className="font-display text-lg font-extrabold text-deep">
Por qué todavía no publicamos casos con nombre
</h3>
<p className="mt-3 text-sm leading-relaxed text-deep-muted">
Un caso de estudio requiere autorización del cliente sobre qué se puede contar, qué
periodo cubre y qué cifras se pueden publicar. Mientras eso no exista por escrito,
preferimos escenarios rotulados como estos antes que atribuir un resultado a una
marca que no lo ha confirmado.
</p>
</div>
<FormularioContacto
id="formulario-casos"
canal="catalogo"
variante="claro"
titulo="Plantear mi escenario"
descripcion="Completa la frase del mensaje con el caso que más se parezca al tuyo, o descríbelo con tus palabras. Respondemos con el escenario estructurado."
mensajeInicial={MENSAJE_INICIAL}
textoBoton="Enviar mi escenario"
/>
</div>
</div>
</section>
</>
);
}
function parrafo(claro: boolean): string {
return ["text-sm leading-relaxed sm:text-base", claro ? "text-deep-muted" : "text-muted"].join(
" ",
);
}
function Paso({
n,
titulo,
claro,
acento = false,
children,
}: {
n: string;
titulo: string;
claro: boolean;
acento?: boolean;
children: React.ReactNode;
}) {
return (
<div
className={[
"border-l-2 pl-5 sm:pl-6",
acento ? "border-l-ember" : claro ? "border-l-rule-lite" : "border-l-rule",
].join(" ")}
>
<p
className={[
"font-mono text-[0.625rem] font-semibold uppercase tracking-[0.16em]",
acento ? (claro ? "text-ember-dim" : "text-ember") : "text-signal",
].join(" ")}
>
{n} · {titulo}
</p>
<div className="mt-3.5">{children}</div>
</div>
);
}
function Lista({ elementos, claro }: { elementos: string[]; claro: boolean }) {
return (
<ul className="space-y-3">
{elementos.map((elemento) => (
<li key={elemento} className="flex gap-3">
<span aria-hidden="true" className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-signal" />
<p className={parrafo(claro)}>{elemento}</p>
</li>
))}
</ul>
);
}