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]>
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
"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>
|
|
);
|
|
}
|