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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user