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:
co-authored by
Claude Opus 5
parent
3c6bdcfc06
commit
0e0331c1fc
+13
-1
@@ -7,9 +7,21 @@ import { RUTAS_MUESTRA, TODAS_LAS_RUTAS } from "./rutas";
|
||||
* sitio original.
|
||||
*/
|
||||
|
||||
/**
|
||||
* El barrido de las 45 rutas corre en un solo proyecto. Que una ruta responda
|
||||
* 200 y traiga un `<h1>` lo decide el servidor, no el motor del navegador, así
|
||||
* que repetirlo cuatro veces solo alargaba la suite. Lo que sí es sensible al
|
||||
* motor —responsividad, interacción, foco— vive en las otras suites y ahí sí
|
||||
* corre en WebKit.
|
||||
*/
|
||||
test.describe("rutas públicas responden", () => {
|
||||
for (const ruta of TODAS_LAS_RUTAS) {
|
||||
test(`200 y contenido en ${ruta}`, async ({ page }) => {
|
||||
test(`200 y contenido en ${ruta}`, async ({ page }, info) => {
|
||||
test.skip(
|
||||
info.project.name !== "escritorio-chromium",
|
||||
"El barrido completo de rutas corre en un solo proyecto; ver la nota de arriba.",
|
||||
);
|
||||
|
||||
const res = await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
||||
expect(res?.status(), `${ruta} debe responder 200`).toBe(200);
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ import { ANCHOS, RUTAS_MUESTRA } from "./rutas";
|
||||
* página desplazándose en horizontal no lo está nunca.
|
||||
*/
|
||||
|
||||
/**
|
||||
* El barrido de anchos corre en Chromium y en WebKit de escritorio, no en los
|
||||
* cuatro proyectos. El desborde horizontal lo produce el CSS, no el motor, así
|
||||
* que repetir 5 anchos × 10 rutas en cuatro navegadores multiplicaba el tiempo
|
||||
* de la suite sin agregar información. WebKit sigue incluido porque es donde
|
||||
* aparecen las diferencias reales de `dvh`, `env()` y flexbox, y los proyectos
|
||||
* móviles conservan las pruebas de interacción de más abajo.
|
||||
*/
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
async function desbordaEnHorizontal(page: Page): Promise<{ desborda: boolean; ancho: number; visible: number }> {
|
||||
return page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
@@ -55,7 +65,12 @@ async function culpablesDeDesborde(page: Page): Promise<string[]> {
|
||||
}
|
||||
|
||||
for (const ruta of RUTAS_MUESTRA) {
|
||||
test(`sin desborde horizontal en ${ruta}`, async ({ page }) => {
|
||||
test(`sin desborde horizontal en ${ruta}`, async ({ page }, info) => {
|
||||
test.skip(
|
||||
!["escritorio-chromium", "escritorio-webkit"].includes(info.project.name),
|
||||
"El barrido de anchos corre solo en los dos proyectos de escritorio; ver la nota de arriba.",
|
||||
);
|
||||
|
||||
await page.goto(ruta, { waitUntil: "networkidle" });
|
||||
|
||||
for (const ancho of ANCHOS) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user