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
+270
View File
@@ -0,0 +1,270 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ROL_ETIQUETA, type Rol } from "@/lib/crm/constantes";
import { AVISO_DEMO } from "@/data/empresa";
import { accionCerrarSesion } from "@/lib/crm/acciones";
/**
* Cascarón del panel: barra lateral fija en escritorio, cajón deslizable en
* móvil. Es lo único de cliente del layout —necesita `usePathname` para marcar
* la sección activa y estado para el cajón—; el contenido llega ya renderizado
* desde el servidor.
*
* El panel no monta el widget de WhatsApp del sitio público: aquí adentro el
* usuario ya es de la casa.
*/
type Enlace = { href: string; label: string; soloAdmin?: boolean };
const ENLACES: Enlace[] = [
{ href: "/admin", label: "Tablero" },
{ href: "/admin/oportunidades", label: "Oportunidades" },
{ href: "/admin/contactos", label: "Contactos" },
{ href: "/admin/pedidos", label: "Pedidos" },
{ href: "/admin/analitica", label: "Analítica" },
{ href: "/admin/equipo", label: "Equipo", soloAdmin: true },
{ href: "/admin/reglas", label: "Reglas", soloAdmin: true },
];
function esActivo(pathname: string, href: string): boolean {
if (href === "/admin") return pathname === "/admin";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function Cascaron({
nombre,
rol,
children,
}: {
nombre: string;
rol: Rol;
children: ReactNode;
}) {
const pathname = usePathname();
const [abierto, setAbierto] = useState(false);
const cajonId = useId();
const botonRef = useRef<HTMLButtonElement>(null);
const enlaces = ENLACES.filter((e) => !e.soloAdmin || rol === "ADMIN");
// El cajón se cierra al navegar: en móvil nadie quiere cerrarlo a mano.
useEffect(() => {
setAbierto(false);
}, [pathname]);
useEffect(() => {
if (!abierto) return;
function alTecla(e: KeyboardEvent) {
if (e.key === "Escape") {
setAbierto(false);
botonRef.current?.focus();
}
}
document.addEventListener("keydown", alTecla);
return () => document.removeEventListener("keydown", alTecla);
}, [abierto]);
return (
<div className="min-h-screen bg-ink text-bright" style={{ minHeight: "100dvh" }}>
<a href="#panel" className="skip">
Ir al contenido del panel
</a>
{/* ---------- barra lateral, escritorio ---------- */}
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-rule bg-ink-2 lg:flex">
<Marca />
<Navegacion enlaces={enlaces} pathname={pathname} />
<PieLateral rol={rol} />
</aside>
{/* ---------- cajón, móvil ---------- */}
<div
aria-hidden={!abierto}
className={[
"fixed inset-0 z-40 lg:hidden",
abierto ? "pointer-events-auto" : "pointer-events-none",
].join(" ")}
>
<button
type="button"
tabIndex={abierto ? 0 : -1}
aria-label="Cerrar la navegación"
onClick={() => setAbierto(false)}
className={[
"absolute inset-0 bg-ink/80 transition-opacity duration-200",
abierto ? "opacity-100" : "opacity-0",
].join(" ")}
style={{
backdropFilter: "blur(3px)",
WebkitBackdropFilter: "blur(3px)",
WebkitTapHighlightColor: "transparent",
appearance: "none",
}}
/>
<nav
id={cajonId}
aria-label="Navegación del panel"
className={[
"absolute inset-y-0 left-0 flex h-screen w-[17rem] max-w-[85vw] flex-col border-r border-rule bg-ink-2 transition-transform duration-200 ease-out",
abierto ? "translate-x-0" : "-translate-x-full",
].join(" ")}
style={{ height: "100dvh" }}
>
<Marca />
<Navegacion enlaces={enlaces} pathname={pathname} tabbable={abierto} />
<PieLateral rol={rol} />
</nav>
</div>
{/* ---------- columna de contenido ---------- */}
<div className="flex min-w-0 flex-col lg:pl-60">
<FranjaDemo />
<header
className="sticky top-0 z-20 flex min-w-0 items-center gap-3 border-b border-rule bg-ink-2/95 px-4 py-2.5 sm:px-6"
style={{ backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" }}
>
<button
ref={botonRef}
type="button"
onClick={() => setAbierto((v) => !v)}
aria-expanded={abierto}
aria-controls={cajonId}
className="btn btn-ghost btn-sm shrink-0 lg:hidden"
style={{ WebkitTapHighlightColor: "transparent", appearance: "none" }}
>
<span aria-hidden="true" className="block">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
<path d="M0 1h14M0 6h14M0 11h14" stroke="currentColor" strokeWidth="1.5" />
</svg>
</span>
Menú
</button>
<p className="min-w-0 truncate font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-faint">
Panel comercial
</p>
<div className="ml-auto flex min-w-0 items-center gap-2 sm:gap-3">
<span className="hidden min-w-0 truncate text-sm text-bright sm:block">{nombre}</span>
<span className={rol === "ADMIN" ? "badge badge-signal" : "badge"}>
{ROL_ETIQUETA[rol]}
</span>
<BotonSalir />
</div>
</header>
<main id="panel" className="min-w-0 flex-1 px-4 py-6 sm:px-6 sm:py-8">
{children}
</main>
</div>
</div>
);
}
function Marca() {
return (
<div className="flex items-center gap-3 border-b border-rule px-4 py-4">
<Image
src="/media/marca/fspm-logo-blanco.png"
alt="Fire Service Plus México"
width={120}
height={32}
priority
className="h-7 w-auto"
/>
<span className="font-mono text-[0.5625rem] uppercase tracking-[0.16em] text-faint">
CRM
</span>
</div>
);
}
function Navegacion({
enlaces,
pathname,
tabbable = true,
}: {
enlaces: Enlace[];
pathname: string;
tabbable?: boolean;
}) {
return (
<ul className="flex-1 overflow-y-auto py-3" style={{ WebkitOverflowScrolling: "touch" }}>
{enlaces.map((e) => {
const activo = esActivo(pathname, e.href);
return (
<li key={e.href}>
<Link
href={e.href}
tabIndex={tabbable ? undefined : -1}
aria-current={activo ? "page" : undefined}
className={[
"flex items-center gap-3 border-l-2 px-4 py-2.5 font-mono text-[0.75rem] uppercase tracking-[0.1em] transition-colors",
activo
? "border-signal bg-ink-3 text-bright"
: "border-transparent text-muted hover:bg-ink-3/60 hover:text-bright",
].join(" ")}
style={{ WebkitTapHighlightColor: "transparent" }}
>
{e.label}
</Link>
</li>
);
})}
</ul>
);
}
function PieLateral({ rol }: { rol: Rol }) {
return (
<div className="border-t border-rule px-4 py-3">
<p className="font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.12em] text-faint">
{rol === "ADMIN"
? "Alcance: toda la operación"
: "Alcance: solo lo asignado a ti"}
</p>
</div>
);
}
function FranjaDemo() {
return (
<div className="flex items-start gap-2.5 border-b border-rule bg-ink-3 px-4 py-2 sm:px-6">
<span aria-hidden="true" className="mt-1 block h-2.5 w-2.5 shrink-0 hazard rounded-[1px]" />
<p className="min-w-0 font-mono text-[0.625rem] uppercase leading-relaxed tracking-[0.1em] text-muted">
Entorno de demostración · {AVISO_DEMO}
</p>
</div>
);
}
function BotonSalir() {
const [saliendo, setSaliendo] = useState(false);
async function salir() {
setSaliendo(true);
try {
await accionCerrarSesion();
} finally {
setSaliendo(false);
}
}
return (
<form action={salir}>
<button
type="submit"
disabled={saliendo}
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent", appearance: "none" }}
>
{saliendo ? "Saliendo…" : "Salir"}
</button>
</form>
);
}