feat: sitio comercial FSPM con micro CRM interno
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
122e06a4bb
commit
01b817d098
@@ -0,0 +1,175 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { PRODUCTOS } from "../src/data/catalogo";
|
||||
|
||||
/**
|
||||
* El circuito comercial completo, que es la razón de ser del proyecto:
|
||||
*
|
||||
* llega por una campaña → navega → envía el formulario en una ficha
|
||||
* → queda registrado con la campaña del primer toque y el producto exacto
|
||||
*
|
||||
* Si la atribución se pierde entre la página de aterrizaje y la de conversión,
|
||||
* la promesa de "medir efectividad de campañas" no se sostiene. Eso es lo que
|
||||
* esta prueba vigila.
|
||||
*/
|
||||
|
||||
const PRODUCTO = PRODUCTOS[0]!;
|
||||
|
||||
function sufijo(): string {
|
||||
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
test("formulario del inicio registra el lead y devuelve folio", async ({ page }) => {
|
||||
const s = sufijo();
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
|
||||
await forma.scrollIntoViewIfNeeded();
|
||||
|
||||
await forma.locator('input[name="nombre"]').fill(`Prueba Inicio ${s}`);
|
||||
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 expect(page.getByText(/solicitud registrada/i)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(/OPP-\d{4}-\d{4}/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("la atribución de primer toque sobrevive a la navegación", async ({ page }) => {
|
||||
const s = sufijo();
|
||||
const campana = `e2e-primer-toque-${s}`;
|
||||
|
||||
// 1. entra por una campaña, no directo a la ficha
|
||||
await page.goto(`/?utm_source=google&utm_medium=cpc&utm_campaign=${campana}&gclid=gclid-${s}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
// 2. navega a una ficha de producto, ya sin parámetros en la URL
|
||||
await page.goto(`/producto/${PRODUCTO.slug}`, { waitUntil: "domcontentloaded" });
|
||||
expect(page.url()).not.toContain("utm_campaign");
|
||||
|
||||
// 3. convierte ahí
|
||||
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
|
||||
await forma.scrollIntoViewIfNeeded();
|
||||
await forma.locator('input[name="nombre"]').fill(`Prueba Atribución ${s}`);
|
||||
await forma.locator('input[name="email"]').fill(`atribucion.${s}@pruebafspm.mx`);
|
||||
await forma.locator('input[name="telefono"]').fill("5581000002");
|
||||
await forma.locator('input[name="privacidad"]').check();
|
||||
|
||||
// interceptamos el envío para leer lo que el navegador manda de verdad
|
||||
const peticion = page.waitForRequest(
|
||||
(r) => r.url().includes("/api/leads") && r.method() === "POST",
|
||||
);
|
||||
await forma.getByRole("button", { name: /solicitar/i }).click();
|
||||
const req = await peticion;
|
||||
|
||||
const cuerpo = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>;
|
||||
expect(cuerpo.utmCampaign, "la campaña del primer toque debe viajar").toBe(campana);
|
||||
expect(cuerpo.utmSource).toBe("google");
|
||||
expect(cuerpo.utmMedium).toBe("cpc");
|
||||
expect(cuerpo.gclid).toBe(`gclid-${s}`);
|
||||
expect(cuerpo.productoSlug, "la ficha debe declarar su producto").toBe(PRODUCTO.slug);
|
||||
expect(String(cuerpo.productoUrl), "debe guardarse la URL que vio el usuario").toContain(
|
||||
`/producto/${PRODUCTO.slug}`,
|
||||
);
|
||||
expect(cuerpo.canal).toBe("producto");
|
||||
expect(String(cuerpo.landingPath), "la ruta de aterrizaje debe ser la de la campaña").toContain("/");
|
||||
|
||||
await expect(page.getByText(/solicitud registrada/i)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("un mismo contacto puede enviar varias veces y se acumula", async ({ page }) => {
|
||||
const s = sufijo();
|
||||
const correo = `recurrente.${s}@pruebafspm.mx`;
|
||||
const folios: string[] = [];
|
||||
|
||||
for (const [i, ruta] of ["/", "/contacto"].entries()) {
|
||||
await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
||||
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
|
||||
await forma.scrollIntoViewIfNeeded();
|
||||
await forma.locator('input[name="nombre"]').fill(`Contacto Recurrente ${s}`);
|
||||
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();
|
||||
|
||||
const aviso = page.getByText(/OPP-\d{4}-\d{4}/);
|
||||
await expect(aviso).toBeVisible({ timeout: 15_000 });
|
||||
const texto = (await aviso.first().textContent()) ?? "";
|
||||
const folio = texto.match(/OPP-\d{4}-\d{4}/)?.[0];
|
||||
expect(folio, `envío ${i + 1} debe devolver folio`).toBeTruthy();
|
||||
folios.push(folio!);
|
||||
}
|
||||
|
||||
expect(folios[0]).not.toBe(folios[1]);
|
||||
// el mismo contacto, dos oportunidades: es el requisito explícito del cliente
|
||||
});
|
||||
|
||||
test("el widget de WhatsApp registra antes de abrir la conversación", async ({ page, context }) => {
|
||||
const s = sufijo();
|
||||
await context.grantPermissions([]);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: /contactar por whatsapp/i }).click();
|
||||
const panel = page.locator("#panel-whatsapp");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
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('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 res = await respuesta;
|
||||
|
||||
const json = (await res.json()) as { ok: boolean; folio?: string; whatsappUrl?: string };
|
||||
expect(json.ok, "el widget debe registrar el contacto en el CRM").toBe(true);
|
||||
expect(json.folio).toMatch(/^OPP-\d{4}-\d{4}$/);
|
||||
expect(json.whatsappUrl, "debe devolver el enlace a la conversación").toBeTruthy();
|
||||
|
||||
const url = new URL(json.whatsappUrl!);
|
||||
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!);
|
||||
|
||||
await expect(panel.getByText(/contacto registrado/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("el formulario rechaza datos incompletos sin perder lo escrito", async ({ page }) => {
|
||||
await page.goto("/contacto", { waitUntil: "domcontentloaded" });
|
||||
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
|
||||
|
||||
await forma.locator('input[name="nombre"]').fill("Ana");
|
||||
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();
|
||||
|
||||
// no debe declarar éxito
|
||||
await expect(page.getByText(/solicitud registrada/i)).toHaveCount(0);
|
||||
// y lo que el usuario escribió sigue ahí
|
||||
await expect(forma.locator('input[name="nombre"]')).toHaveValue("Ana");
|
||||
});
|
||||
|
||||
test("la trampa antispam descarta el envío sin escribir nada", async ({ request }) => {
|
||||
const s = sufijo();
|
||||
const res = await request.post("/api/leads", {
|
||||
data: {
|
||||
nombre: "Bot Automático",
|
||||
email: `bot.${s}@pruebafspm.mx`,
|
||||
telefono: "5581000009",
|
||||
origen: "FORMULARIO",
|
||||
canal: "home",
|
||||
privacidad: true,
|
||||
_sitio: "http://sitio-de-spam.example",
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const json = (await res.json()) as { ok: boolean; folio: string };
|
||||
// responde en falso positivo para no enseñarle al bot que fue detectado
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.folio).toBe("OPP-0000-0000");
|
||||
});
|
||||
Reference in New Issue
Block a user