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:
co-authored by
Claude Opus 5
parent
122e06a4bb
commit
01b817d098
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user