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]>
This commit is contained in:
Uriel Jareth
2026-08-28 01:30:44 -06:00
co-authored by Claude Opus 5
parent 3c6bdcfc06
commit 0e0331c1fc
580 changed files with 7239 additions and 2683 deletions
+16 -8
View File
@@ -29,7 +29,7 @@ test("formulario del inicio registra el lead y devuelve folio", async ({ page })
await forma.locator('input[name="email"]').fill(`inicio.${s}@pruebafspm.mx`);
await forma.locator('input[name="telefono"]').fill("5581000001");
await forma.locator('input[name="privacidad"]').check();
await forma.getByRole("button", { name: /solicitar/i }).click();
await forma.getByRole("button", { name: /recibir cotizaci/i }).click();
await expect(page.getByText(/solicitud registrada/i)).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/OPP-\d{4}-\d{4}/)).toBeVisible();
@@ -60,7 +60,7 @@ test("la atribución de primer toque sobrevive a la navegación", async ({ page
const peticion = page.waitForRequest(
(r) => r.url().includes("/api/leads") && r.method() === "POST",
);
await forma.getByRole("button", { name: /solicitar/i }).click();
await forma.getByRole("button", { name: /recibir cotizaci/i }).click();
const req = await peticion;
const cuerpo = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>;
@@ -91,7 +91,7 @@ test("un mismo contacto puede enviar varias veces y se acumula", async ({ page }
await forma.locator('input[name="email"]').fill(correo);
await forma.locator('input[name="telefono"]').fill("5581000003");
await forma.locator('input[name="privacidad"]').check();
await forma.getByRole("button", { name: /solicitar/i }).click();
await forma.getByRole("button", { name: /recibir cotizaci/i }).click();
const aviso = page.getByText(/OPP-\d{4}-\d{4}/);
await expect(aviso).toBeVisible({ timeout: 15_000 });
@@ -117,11 +117,13 @@ test("el widget de WhatsApp registra antes de abrir la conversación", async ({
await panel.locator('input[name="nombre"]').fill(`Prueba WhatsApp ${s}`);
await panel.locator('input[name="telefono"]').fill("5581000004");
await panel.locator('input[name="email"]').fill(`whats.${s}@pruebafspm.mx`);
await panel.locator('select[name="motivo"]').selectOption("urgencia");
await panel.locator('textarea[name="mensaje"]').fill("Tengo una verificación la próxima semana");
await panel.locator('input[name="privacidad"]').check();
const respuesta = page.waitForResponse((r) => r.url().includes("/api/leads") && r.request().method() === "POST");
await panel.getByRole("button", { name: /abrir whatsapp/i }).click();
const respuesta = page.waitForResponse(
(r) => r.url().includes("/api/leads") && r.request().method() === "POST",
);
await panel.getByRole("button", { name: /enviar mensaje/i }).click();
const res = await respuesta;
const json = (await res.json()) as { ok: boolean; folio?: string; whatsappUrl?: string };
@@ -133,7 +135,13 @@ test("el widget de WhatsApp registra antes de abrir la conversación", async ({
expect(url.hostname).toContain("whatsapp.com");
const mensaje = url.searchParams.get("text") ?? "";
expect(mensaje, "el mensaje debe llevar el prefijo de demostración").toContain("[DEMO]");
expect(mensaje, "el mensaje debe llevar el folio para rastrear la conversación").toContain(json.folio!);
expect(mensaje, "el mensaje debe llevar el folio para rastrear la conversación").toContain(
json.folio!,
);
expect(
mensaje,
"el mensaje que escribió la persona debe viajar a la conversación, no un motivo enlatado",
).toContain("verificación la próxima semana");
await expect(panel.getByText(/contacto registrado/i)).toBeVisible();
});
@@ -146,7 +154,7 @@ test("el formulario rechaza datos incompletos sin perder lo escrito", async ({ p
await forma.locator('input[name="email"]').fill("esto-no-es-un-correo");
await forma.locator('input[name="telefono"]').fill("123");
await forma.locator('input[name="privacidad"]').check();
await forma.getByRole("button", { name: /solicitar/i }).click();
await forma.getByRole("button", { name: /recibir cotizaci/i }).click();
// no debe declarar éxito
await expect(page.getByText(/solicitud registrada/i)).toHaveCount(0);