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]>
296 lines
10 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|