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
@@ -0,0 +1,500 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { RielClasesSelector } from "@/components/marca/RielClases";
import type { ClaseFuego } from "@/data/clases-fuego";
import { OPCIONES_INDUSTRIA } from "@/data/industrias";
import type { Canal } from "@/lib/crm/constantes";
import type { RespuestaLead } from "@/lib/leads/esquema";
import { capturarAtribucion, type Atribucion } from "@/lib/utm";
/**
* Formulario de captación embebido. Va en el inicio, en el catálogo y en cada
* ficha de producto, y siempre viaja con su contexto: qué producto se estaba
* viendo, en qué URL y con qué campaña llegó el visitante.
*
* No pide "solicita información": pide los datos que un ingeniero necesita para
* cotizar sin una segunda vuelta de correos.
*/
export type ProductoInteres = {
slug: string;
nombre: string;
categoriaSlug?: string;
};
type Props = {
canal: Canal;
titulo?: string;
descripcion?: string;
/** Cuando el formulario vive en una ficha, el producto ya viene resuelto. */
producto?: ProductoInteres;
industriaPreseleccionada?: string;
clasesPreseleccionadas?: ClaseFuego[];
/** "claro" para las secciones sobre papel. */
variante?: "oscuro" | "claro";
/** Solo nombre, correo, teléfono y mensaje. Para espacios reducidos. */
compacto?: boolean;
className?: string;
};
type Estado = "listo" | "enviando" | "hecho" | "error";
const URGENCIAS = [
{ value: "inmediata", label: "Inmediata — tengo un hallazgo abierto" },
{ value: "trimestre", label: "Este trimestre" },
{ value: "presupuesto", label: "Estoy armando presupuesto" },
{ value: "explorando", label: "Explorando opciones" },
];
export function FormularioContacto({
canal,
titulo = "Solicitar diagnóstico",
descripcion = "Cuéntanos qué operación hay que proteger. Te respondemos con el escenario, el agente adecuado y qué documentación te corresponde.",
producto,
industriaPreseleccionada,
clasesPreseleccionadas = [],
variante = "oscuro",
compacto = false,
className = "",
}: Props) {
const claro = variante === "claro";
const [estado, setEstado] = useState<Estado>("listo");
const [errores, setErrores] = useState<Record<string, string>>({});
const [errorGeneral, setErrorGeneral] = useState<string | null>(null);
const [folio, setFolio] = useState<string | null>(null);
const [atiende, setAtiende] = useState<string | null>(null);
const [clases, setClases] = useState<ClaseFuego[]>(clasesPreseleccionadas);
const atribucion = useRef<Atribucion>({});
const regionEstado = useRef<HTMLDivElement>(null);
useEffect(() => {
atribucion.current = capturarAtribucion();
}, []);
// llevar el foco al aviso de resultado, para lector de pantalla
useEffect(() => {
if (estado === "hecho" || estado === "error") regionEstado.current?.focus();
}, [estado]);
async function enviar(evento: React.FormEvent<HTMLFormElement>) {
evento.preventDefault();
if (estado === "enviando") return;
const datos = new FormData(evento.currentTarget);
setEstado("enviando");
setErrores({});
setErrorGeneral(null);
const cuerpo = {
nombre: String(datos.get("nombre") ?? ""),
email: String(datos.get("email") ?? ""),
telefono: String(datos.get("telefono") ?? ""),
empresa: String(datos.get("empresa") ?? "") || undefined,
puesto: String(datos.get("puesto") ?? "") || undefined,
ciudad: String(datos.get("ciudad") ?? "") || undefined,
industria: String(datos.get("industria") ?? "") || undefined,
superficie: String(datos.get("superficie") ?? "") || undefined,
urgencia: String(datos.get("urgencia") ?? "") || undefined,
mensaje: String(datos.get("mensaje") ?? "") || undefined,
clases: clases.length ? clases : undefined,
origen: "FORMULARIO" as const,
canal,
productoSlug: producto?.slug,
productoNombre: producto?.nombre,
categoriaSlug: producto?.categoriaSlug,
productoUrl: typeof window !== "undefined" ? window.location.href.slice(0, 400) : undefined,
privacidad: datos.get("privacidad") === "on",
_sitio: String(datos.get("_sitio") ?? ""),
...atribucion.current,
};
try {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cuerpo),
});
const json = (await res.json()) as RespuestaLead;
if (json.ok) {
setFolio(json.folio);
setAtiende(json.atiende ?? null);
setEstado("hecho");
return;
}
setErrores(json.campos ?? {});
setErrorGeneral(json.error);
setEstado("error");
} catch {
setErrorGeneral("No pudimos enviar la solicitud. Revisa tu conexión e inténtalo otra vez.");
setEstado("error");
}
}
// ---------- acuse de recibo ----------
if (estado === "hecho") {
return (
<div
ref={regionEstado}
tabIndex={-1}
role="status"
className={[
claro ? "panel-lite" : "panel",
"p-7 outline-none sm:p-9",
className,
].join(" ")}
>
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ok/15">
<svg width="13" height="10" viewBox="0 0 13 10" aria-hidden="true">
<path
d="M1 5l3.6 3.6L12 1.2"
stroke="var(--color-ok)"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
/>
</svg>
</span>
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ok">
Solicitud registrada
</p>
</div>
<h3
className={[
"mt-5 font-display text-2xl font-extrabold",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
Recibimos tu solicitud
</h3>
<p className={["mt-3 text-sm leading-relaxed", claro ? "text-deep-muted" : "text-muted"].join(" ")}>
{atiende
? `${atiende} tiene tu caso asignado y da seguimiento.`
: "Un especialista tiene tu caso asignado y da seguimiento."}{" "}
Si tu situación no puede esperar, marca directo al{" "}
<a href="tel:+525540348395" className="link">
55 4034 8395
</a>
.
</p>
<dl className={["spec mt-6", claro ? "" : ""].join(" ")} style={{ gridTemplateColumns: "1fr 1fr" }}>
<div className="spec-cell">
<dt className="spec-key">Folio</dt>
<dd className="spec-val">{folio}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Registrado</dt>
<dd className="spec-val">
{new Date().toLocaleString("es-MX", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</dd>
</div>
</dl>
</div>
);
}
// ---------- formulario ----------
const claseCampo = claro ? "field field-lite" : "field";
const claseEtiqueta = claro ? "field-label !text-deep-muted" : "field-label";
return (
<div className={[claro ? "panel-lite" : "panel", "p-6 sm:p-8", className].join(" ")}>
<div className="mb-6">
<p className="eyebrow mb-3">{producto ? "Cotizar este equipo" : "Diagnóstico sin costo"}</p>
<h3
className={[
"font-display text-2xl font-extrabold leading-tight sm:text-[1.75rem]",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{titulo}
</h3>
<p className={["mt-2.5 text-sm leading-relaxed", claro ? "text-deep-muted" : "text-muted"].join(" ")}>
{descripcion}
</p>
{producto ? (
<p className="mt-4 inline-flex items-center gap-2 rounded-[3px] border border-signal/40 bg-signal/10 px-2.5 py-1.5">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.12em] text-signal-hi">
Interés
</span>
<span className={["font-mono text-xs", claro ? "text-deep" : "text-bright"].join(" ")}>
{producto.nombre}
</span>
</p>
) : null}
</div>
<form onSubmit={enviar} noValidate>
{/* señuelo antispam: invisible y fuera del orden de tabulación */}
<div aria-hidden="true" className="absolute h-0 w-0 overflow-hidden opacity-0">
<label htmlFor={`sitio-${canal}`}>No llenar</label>
<input id={`sitio-${canal}`} name="_sitio" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor={`nombre-${canal}`} className={claseEtiqueta}>
Nombre
</label>
<input
id={`nombre-${canal}`}
name="nombre"
type="text"
required
autoComplete="name"
placeholder="Nombre y apellido"
className={claseCampo}
aria-invalid={Boolean(errores.nombre)}
aria-describedby={errores.nombre ? `err-nombre-${canal}` : undefined}
/>
{errores.nombre ? (
<span id={`err-nombre-${canal}`} className="field-error">
{errores.nombre}
</span>
) : null}
</div>
<div>
<label htmlFor={`email-${canal}`} className={claseEtiqueta}>
Correo
</label>
<input
id={`email-${canal}`}
name="email"
type="email"
required
autoComplete="email"
inputMode="email"
placeholder="[email protected]"
className={claseCampo}
aria-invalid={Boolean(errores.email)}
aria-describedby={errores.email ? `err-email-${canal}` : undefined}
/>
{errores.email ? (
<span id={`err-email-${canal}`} className="field-error">
{errores.email}
</span>
) : null}
</div>
<div>
<label htmlFor={`telefono-${canal}`} className={claseEtiqueta}>
Teléfono
</label>
<input
id={`telefono-${canal}`}
name="telefono"
type="tel"
required
autoComplete="tel"
inputMode="tel"
placeholder="55 1234 5678"
className={claseCampo}
aria-invalid={Boolean(errores.telefono)}
aria-describedby={errores.telefono ? `err-telefono-${canal}` : undefined}
/>
{errores.telefono ? (
<span id={`err-telefono-${canal}`} className="field-error">
{errores.telefono}
</span>
) : null}
</div>
{!compacto ? (
<>
<div>
<label htmlFor={`empresa-${canal}`} className={claseEtiqueta}>
Empresa
</label>
<input
id={`empresa-${canal}`}
name="empresa"
type="text"
autoComplete="organization"
placeholder="Razón social o nombre comercial"
className={claseCampo}
/>
</div>
<div>
<label htmlFor={`ciudad-${canal}`} className={claseEtiqueta}>
Ciudad o estado
</label>
<input
id={`ciudad-${canal}`}
name="ciudad"
type="text"
autoComplete="address-level2"
placeholder="Dónde está la instalación"
className={claseCampo}
/>
</div>
<div>
<label htmlFor={`industria-${canal}`} className={claseEtiqueta}>
Tipo de operación
</label>
<select
id={`industria-${canal}`}
name="industria"
defaultValue={industriaPreseleccionada ?? ""}
className={`${claseCampo} appearance-none [-webkit-appearance:none]`}
>
<option value="">Selecciona una</option>
{OPCIONES_INDUSTRIA.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor={`urgencia-${canal}`} className={claseEtiqueta}>
Cuándo lo necesitas
</label>
<select
id={`urgencia-${canal}`}
name="urgencia"
defaultValue=""
className={`${claseCampo} appearance-none [-webkit-appearance:none]`}
>
<option value="">Selecciona una</option>
{URGENCIAS.map((u) => (
<option key={u.value} value={u.value}>
{u.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor={`superficie-${canal}`} className={claseEtiqueta}>
Superficie a proteger
</label>
<input
id={`superficie-${canal}`}
name="superficie"
type="text"
placeholder="Ej. 4 500 m² en dos naves"
className={claseCampo}
/>
</div>
<div>
<label htmlFor={`puesto-${canal}`} className={claseEtiqueta}>
Tu puesto
</label>
<input
id={`puesto-${canal}`}
name="puesto"
type="text"
autoComplete="organization-title"
placeholder="Ej. Jefe de seguridad industrial"
className={claseCampo}
/>
</div>
{/* el riel: el visitante declara su propio riesgo */}
<div className="sm:col-span-2">
<span className={claseEtiqueta}>Clases de fuego presentes</span>
<div
className={[
"flex flex-wrap items-center gap-4 rounded-[3px] border px-3.5 py-3",
claro ? "border-rule-lite bg-paper-2/60" : "border-rule-2 bg-ink/60",
].join(" ")}
>
<RielClasesSelector valor={clases} onChange={setClases} tamano="md" />
<p
className={[
"font-mono text-[0.625rem] leading-relaxed tracking-[0.04em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{clases.length === 0
? "Si no las conoces, déjalo vacío y lo clasificamos nosotros"
: `Declaraste ${clases.length} ${clases.length === 1 ? "clase" : "clases"}`}
</p>
</div>
</div>
</>
) : null}
<div className="sm:col-span-2">
<label htmlFor={`mensaje-${canal}`} className={claseEtiqueta}>
Qué hay que proteger
</label>
<textarea
id={`mensaje-${canal}`}
name="mensaje"
rows={compacto ? 3 : 4}
placeholder="Describe la instalación, el material que se maneja y qué te pidieron cumplir."
className={`${claseCampo} resize-y`}
/>
</div>
</div>
<div className="mt-5">
<label className="flex cursor-pointer items-start gap-2.5">
<input
name="privacidad"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-[var(--color-signal)] [-webkit-appearance:checkbox]"
aria-invalid={Boolean(errores.privacidad)}
/>
<span
className={[
"text-xs leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
Acepto que FSPM use mis datos para responder esta solicitud, conforme al{" "}
<a href="/aviso-de-privacidad" className="link">
aviso de privacidad
</a>
.
</span>
</label>
{errores.privacidad ? <span className="field-error">{errores.privacidad}</span> : null}
</div>
{errorGeneral ? (
<div
ref={regionEstado}
tabIndex={-1}
role="alert"
className="mt-5 border-l-2 border-signal bg-signal/10 px-4 py-3 outline-none"
>
<p className="font-mono text-xs leading-relaxed text-signal-hi">{errorGeneral}</p>
</div>
) : null}
<button type="submit" disabled={estado === "enviando"} className="btn btn-signal mt-6 w-full">
{estado === "enviando" ? "Enviando…" : producto ? "Solicitar cotización" : "Solicitar diagnóstico"}
</button>
<p
className={[
"mt-3.5 text-center font-mono text-[0.625rem] leading-relaxed tracking-[0.06em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Respuesta en horario hábil. Sin costo y sin compromiso de compra.
</p>
</form>
</div>
);
}