feat: sitio comercial FSPM con micro CRM interno

Sitio público orientado a venta consultiva e implementación de las
recomendaciones de la auditoría de marca y mercado:

- Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador
  de riesgo en el héroe, contra el hallazgo de que el visitante tenía que
  inferir qué solución le correspondía.
- Cinco páginas por segmento prioritario y catálogo comparable por clase
  de fuego, capacidad y agente.
- Los claims sin evidencia por modelo quedan aislados y se presentan como
  compromiso de documentación, nunca como característica del producto.
- SEO técnico completo: metadatos por página, sitemap derivado de los
  datos, JSON-LD, iconos y tarjeta social generadas.

Micro CRM:

- Captura por formulario embebido y por widget de WhatsApp que registra
  antes de abrir la conversación.
- Atribución de primer toque que sobrevive a la navegación.
- Deduplicación de contacto; un mismo contacto acumula oportunidades.
- Motor de asignación por reglas con reparto por turno de respaldo.
- Panel con dos roles de alcance distinto, tablero analítico, bitácora,
  pedidos y reglas. No enlazado desde el sitio público y con noindex.

Empaquetado en Docker multietapa con la base de demostración horneada en
la imagen y el volumen de datos separado, para que los leads capturados
sobrevivan a los redespliegues.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-08-28 00:07:20 -06:00
co-authored by Claude Opus 5
parent 122e06a4bb
commit 01b817d098
649 changed files with 26690 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { ExploradorCatalogo } from "@/components/site/catalogo/ExploradorCatalogo";
import { CATEGORIAS, PRODUCTOS, productosDeCategoria } from "@/data/catalogo";
import { CLASES_ORDENADAS } from "@/data/clases-fuego";
import { SITIO_URL } from "@/data/empresa";
/**
* /catalogo — la corrección directa al hallazgo 5.1 del estudio: ocho
* categorías sin rutas por riesgo ni aplicación.
*
* No es una galería de producto. Es un comparador: se entra por clase de fuego
* y por aplicación, y se sale con dos o tres equipos que descartar o cotizar.
*/
const TITULO = "Catálogo de equipos y agentes contra incendio";
const DESCRIPCION =
"Compara los 22 equipos y agentes de FSPM por clase de fuego, capacidad, agente y alcance declarado. Filtra por el riesgo que tienes, no por el nombre del producto.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/catalogo` },
openGraph: {
title: TITULO,
description: DESCRIPCION,
url: `${SITIO_URL}/catalogo`,
},
};
export default function PaginaCatalogo() {
const resumen = [
{ clave: "Categorías", valor: String(CATEGORIAS.length) },
{ clave: "Equipos y agentes", valor: String(PRODUCTOS.length) },
{ clave: "Clases de fuego", valor: `${CLASES_ORDENADAS.length} · A B C D K` },
];
return (
<>
{/* ---------- encabezado: la lógica de lectura del catálogo ---------- */}
<section className="grid-bg relative overflow-hidden bg-ink pt-14 pb-16 sm:pt-20 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Catálogo</li>
</ol>
</nav>
<div className="grid gap-12 lg:grid-cols-[1.15fr_1fr] lg:items-start">
<div className="anim-rise">
<p className="eyebrow mb-5">Catálogo técnico</p>
<h1 className="font-display text-[2.125rem] font-black uppercase leading-[1.02] tracking-tight sm:text-5xl lg:text-[3.5rem]">
Un catálogo se lee por clase de fuego, no por nombre de producto
</h1>
<p className="mt-6 max-w-xl text-base leading-relaxed text-muted sm:text-lg">
Nadie compra &laquo;un extintor&raquo;. Se compra la respuesta a un escenario: qué
se está quemando, dónde, quién va a operar el equipo y qué documentación tiene que
quedar en el expediente. Este catálogo está ordenado en ese sentido.
</p>
<dl className="spec mt-9 sm:grid-cols-3">
{resumen.map((r) => (
<div key={r.clave} className="spec-cell">
<dt className="spec-key">{r.clave}</dt>
<dd className="spec-val">{r.valor}</dd>
</div>
))}
</dl>
<div className="mt-9 flex flex-wrap gap-3">
<Link href="#cotizar" className="btn btn-signal">
Solicitar diagnóstico
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Ver qué documentación aplica
</Link>
</div>
</div>
{/* cómo elegir: el criterio, no el producto */}
<aside className="panel p-6 sm:p-7" aria-labelledby="criterio-titulo">
<p className="eyebrow eyebrow-muted mb-4">Cómo elegir</p>
<h2 id="criterio-titulo" className="font-display text-lg font-extrabold sm:text-xl">
Tres preguntas antes de mirar un solo equipo
</h2>
<ol className="mt-6 space-y-5">
{[
{
n: "01",
t: "¿Qué se quema?",
d: "Define la clase de fuego. El agente equivocado no solo falla: puede empeorar el incidente.",
},
{
n: "02",
t: "¿Quién responde y en cuántos segundos?",
d: "Determina si el equipo debe ser portátil, móvil, fijo o automático.",
},
{
n: "03",
t: "¿Qué te van a pedir por escrito?",
d: "Aseguradora, auditoría y Protección Civil piden documentos distintos. Eso define el expediente.",
},
].map((paso) => (
<li key={paso.n} className="flex gap-4">
<span className="font-mono text-[0.625rem] font-semibold tracking-[0.08em] text-signal">
{paso.n}
</span>
<div>
<h3 className="font-display text-sm font-bold uppercase tracking-wide text-bright">
{paso.t}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted">{paso.d}</p>
</div>
</li>
))}
</ol>
<hr className="hair my-6" />
<p className="text-xs leading-relaxed text-faint">
Si la tercera pregunta no tiene respuesta todavía, empieza por{" "}
<Link href="/cumplimiento-nom-002" className="link">
cumplimiento NOM-002
</Link>
.
</p>
</aside>
</div>
</div>
</section>
{/* ---------- filtros + tabla comparativa ---------- */}
<ExploradorCatalogo />
{/* ---------- las 8 categorías con su tagline ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="categorias-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Ocho categorías</p>
<h2 id="categorias-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Cada categoría responde a una forma distinta de llegar al fuego
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">
Portátil, móvil, fijo, automático o el agente por separado. La diferencia no es de
gama: es de quién opera, en cuánto tiempo y con qué instalación.
</p>
</Revelar>
<ul className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{CATEGORIAS.map((categoria) => {
const cuantos = productosDeCategoria(categoria.slug).length;
return (
<li key={categoria.slug}>
<Link
href={`/catalogo/${categoria.slug}`}
className="group flex h-full flex-col overflow-hidden rounded-[6px] border border-rule bg-ink-2 transition-colors duration-200 hover:border-rule-2 [-webkit-tap-highlight-color:transparent]"
>
<div className="relative aspect-[16/9] w-full overflow-hidden border-b border-rule bg-ink-3">
<Image
src={categoria.imagen}
alt={categoria.nombre}
fill
sizes="(max-width: 640px) 92vw, (max-width: 1024px) 44vw, 22rem"
className="object-contain p-6 transition-transform duration-300 group-hover:scale-[1.04]"
/>
</div>
<div className="flex flex-1 flex-col p-5">
<div className="flex items-start justify-between gap-3">
<h3 className="font-display text-lg font-extrabold leading-tight text-bright">
{categoria.nombre}
</h3>
<span className="badge shrink-0">
{cuantos} {cuantos === 1 ? "equipo" : "equipos"}
</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted">{categoria.tagline}</p>
<span className="mt-auto pt-5 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal transition-colors group-hover:text-signal-hi">
Ver la categoría &rarr;
</span>
</div>
</Link>
</li>
);
})}
</ul>
</div>
</section>
{/* ---------- formulario embebido: requisito del cliente ---------- */}
<section id="cotizar" className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20">
<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 className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Dinos qué hay que proteger y te devolvemos la selección, no un folleto
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted sm:text-base">
Con la clase de fuego, la superficie y quién opera el equipo alcanza para proponer
una configuración y decirte qué documentación te corresponde exigir. Sin costo y sin
compromiso de compra.
</p>
</Revelar>
<ul className="mt-8 space-y-3.5">
{[
"Selección de equipo y agente por escenario, no por catálogo completo.",
"Índice de la documentación que debe acompañar la compra.",
"Referencia de lo que la NOM-002 exige según tu nivel de riesgo.",
].map((punto) => (
<li key={punto} className="flex gap-3 text-sm leading-relaxed text-deep-muted">
<span aria-hidden="true" className="mt-2 h-1 w-1 shrink-0 rounded-full bg-signal" />
{punto}
</li>
))}
</ul>
</div>
<FormularioContacto
canal="catalogo"
variante="claro"
titulo="Solicitar diagnóstico"
descripcion="Respondemos con el escenario interpretado, el equipo que aplica y la lista de documentos que deberíamos entregarte."
/>
</div>
</div>
</section>
</>
);
}