Files
fspm-web/src/components/site/catalogo/TablaComparativa.tsx
T
Uriel JarethandClaude Opus 5 0e0331c1fc feat: catálogo como centro del embudo y captura de baja fricción
Reestructuración pedida por el cliente tras revisar la primera versión.

Conversión:
- Formulario de 14 campos a 4: nombre, correo, teléfono y mensaje. La
  clasificación de riesgo ya no se pregunta, se deduce del contexto de la
  página y viaja oculta. Menos fricción, misma riqueza para el análisis.
- El inicio deja de bifurcar y empuja al catálogo: 36 llamados desembocan
  ahí, y las rutas por industria y escenario pasan a ser la forma de entrar
  al catálogo ya filtrado, en lugar de competir como destino.
- Catálogo rehecho como tienda: barra lateral con filtros, búsqueda que
  acepta expresiones regulares, títulos que abren la ficha, un solo botón
  primario por tarjeta y el comparador detrás de un desplegable.
- Ficha de producto orientada al cierre: bloque en lenguaje llano sobre si
  el equipo corresponde al caso, y "Cotizar este equipo" con el formulario
  precargado. Se retiraron los enlaces que sacaban del embudo a leer normas.
- Todos los llamados se unifican en "Recibir cotización".
- Widget de WhatsApp: nombre, teléfono, correo y mensaje libre. El texto que
  escribe la persona es el que viaja a la conversación.

Doble audiencia:
- 13 escenarios con la iconografía original de FSPM traducen un lugar
  reconocible a clases de fuego y productos, para quien no domina la
  terminología. La capa técnica se conserva para búsqueda.
- Héroe con fotografía en todas las landings.

Correcciones:
- El contenedor moría al arrancar porque el CLI de Prisma no podía escribir
  sus motores; el chown ahora incluye /app/prisma-cli.
- Iconos repetidos entre escenarios. El set original no tiene icono de
  cocina, así que ese escenario usa un marcador tipográfico explícito.
- FIREMIKS no aparecía en ningún escenario.
- Los ids de campo del formulario colisionaban con dos instancias por página.
- Se documentó el despliegue, que faltaba por completo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:30:44 -06:00

296 lines
10 KiB
TypeScript

import Link from "next/link";
import { RielClases } from "@/components/marca/RielClases";
import { CATEGORIAS, type Producto } from "@/data/catalogo";
/**
* HOJA DE COMPARACIÓN — recomendación 10.2 de la auditoría de marca.
*
* Sirve al comprador técnico, que compara antes de pedir precio. Se conserva,
* pero con tres correcciones:
*
* · respeta los filtros activos: recibe la selección ya hecha, no el catálogo
* completo;
* · cada fila cierra con su propio botón de cotizar, para que comparar y
* pedir cotización sean el mismo movimiento;
* · en móvil no hay tabla. Una tabla de siete columnas en 320 px no se lee,
* así que abajo de `md` se apilan fichas.
*
* El desplazamiento horizontal vive dentro del contenedor. El cuerpo de la
* página nunca se desplaza en horizontal.
*/
const NOMBRE_CATEGORIA = new Map(CATEGORIAS.map((c) => [c.slug, c.nombre]));
const COLUMNAS = [
"Producto",
"Categoría",
"Clases",
"Capacidad",
"Agente",
"Alcance declarado",
"Cotizar",
] as const;
type Props = {
productos: Producto[];
/** Sobre superficie clara. `true` por omisión: la evidencia va en papel. */
claro?: boolean;
};
function SinDato({
texto = "Sin dato en la fuente",
claro = true,
}: {
texto?: string;
claro?: boolean;
}) {
return (
<span
className={[
"font-mono text-[0.625rem] uppercase tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{texto}
</span>
);
}
export function TablaComparativa({ productos, claro = true }: Props) {
if (productos.length === 0) {
return (
<div
className={[
"rounded-[6px] border border-dashed p-6 text-sm leading-relaxed",
claro ? "border-rule-lite bg-white text-deep-muted" : "border-rule bg-ink-2 text-muted",
].join(" ")}
>
Ningún equipo coincide con el filtro actual, así que la hoja de comparación está vacía.
Quita un filtro o pide el diagnóstico y comparamos con el escenario a la vista.
</div>
);
}
return (
<>
{/* ---------- móvil: fichas apiladas, no tabla ---------- */}
<ul className="grid gap-3 md:hidden">
{productos.map((producto) => {
const datos: Array<[string, string | null]> = [
["Categoría", NOMBRE_CATEGORIA.get(producto.categoria) ?? producto.categoria],
["Capacidad", producto.capacidad],
["Agente", producto.agente],
["Alcance declarado", producto.alcance],
];
return (
<li
key={producto.slug}
className={[
"min-w-0 rounded-[6px] border p-4",
claro ? "border-rule-lite bg-white" : "border-rule bg-ink-2",
].join(" ")}
>
<p
className={[
"font-display text-base font-extrabold leading-tight",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{producto.nombre}
</p>
{producto.modelo ? (
<p
className={[
"mt-1 font-mono text-[0.625rem] uppercase tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{producto.modelo}
</p>
) : null}
<div className="mt-3">
{producto.clases.length > 0 ? (
<RielClases activas={producto.clases} tamano="sm" claro={claro} etiqueta="Clases" />
) : (
<SinDato texto="Clase no declarada" claro={claro} />
)}
</div>
<dl className="mt-3 grid gap-2">
{datos.map(([clave, valor]) => (
<div key={clave} className="flex min-w-0 gap-3">
<dt
className={[
"w-28 shrink-0 font-mono text-[0.625rem] uppercase tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{clave}
</dt>
<dd
className={[
"min-w-0 break-words font-mono text-xs",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{valor ?? <SinDato claro={claro} />}
</dd>
</div>
))}
</dl>
<Link
href={`/producto/${producto.slug}#cotizar`}
className="btn btn-signal btn-sm mt-4 w-full"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Cotizar este equipo
</Link>
</li>
);
})}
</ul>
{/* ---------- md en adelante: la tabla, con scroll propio ---------- */}
<div
className={[
"hidden overflow-x-auto rounded-[6px] border md:block",
claro ? "border-rule-lite bg-white" : "border-rule bg-ink-2",
].join(" ")}
style={{ WebkitOverflowScrolling: "touch" }}
tabIndex={0}
role="region"
aria-label="Hoja de comparación de equipos, desplazable en horizontal"
>
<table className="w-full min-w-[60rem] border-collapse text-left">
<caption className="sr-only">
Comparación de la selección activa por categoría, clases de fuego, capacidad, agente y
alcance declarado. Cada fila cierra con el enlace para cotizar ese equipo.
</caption>
<thead>
<tr
className={[
"border-b",
claro ? "border-rule-lite bg-paper-2" : "border-rule bg-ink-3",
].join(" ")}
>
{COLUMNAS.map((encabezado) => (
<th
key={encabezado}
scope="col"
className={[
"whitespace-nowrap px-4 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em]",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{encabezado}
</th>
))}
</tr>
</thead>
<tbody>
{productos.map((producto) => (
<tr
key={producto.slug}
className={[
"border-b last:border-b-0",
claro ? "border-rule-lite" : "border-rule",
].join(" ")}
>
<th scope="row" className="px-4 py-4 text-left align-top">
<span
className={[
"block font-sans text-sm font-semibold",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{producto.nombre}
</span>
{producto.modelo ? (
<span
className={[
"mt-1 block font-mono text-[0.625rem] uppercase tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{producto.modelo}
</span>
) : null}
<Link
href={`/producto/${producto.slug}`}
className={[
"mt-1.5 inline-block font-mono text-[0.625rem] uppercase tracking-[0.12em] underline decoration-transparent underline-offset-4",
claro
? "text-deep-muted hover:decoration-rule-lite"
: "text-faint hover:text-muted hover:decoration-rule-2",
].join(" ")}
style={{ WebkitTapHighlightColor: "transparent" }}
>
Ficha completa
</Link>
</th>
<td
className={[
"px-4 py-4 align-top font-mono text-xs",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{NOMBRE_CATEGORIA.get(producto.categoria) ?? producto.categoria}
</td>
<td className="px-4 py-4 align-top">
{producto.clases.length > 0 ? (
<RielClases activas={producto.clases} tamano="sm" claro={claro} />
) : (
<SinDato texto="No declarada" claro={claro} />
)}
</td>
<td
className={[
"px-4 py-4 align-top font-mono text-xs",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{producto.capacidad ?? <SinDato claro={claro} />}
</td>
<td
className={[
"max-w-[14rem] px-4 py-4 align-top font-mono text-xs leading-relaxed",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{producto.agente ?? <SinDato texto="No aplica" claro={claro} />}
</td>
<td
className={[
"max-w-[16rem] px-4 py-4 align-top font-mono text-xs leading-relaxed",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{producto.alcance ?? <SinDato claro={claro} />}
</td>
<td className="px-4 py-4 align-top">
<Link
href={`/producto/${producto.slug}#cotizar`}
className="btn btn-signal btn-sm whitespace-nowrap"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Cotizar
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}