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:
Uriel Jareth
2026-08-28 00:07:20 -06:00
co-authored by Claude Opus 5
parent 122e06a4bb
commit 01b817d098
649 changed files with 26690 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
import { expect, test, type Page } from "@playwright/test";
/**
* El panel comercial. Lo que más importa verificar aquí no es que las páginas
* carguen, sino las dos reglas de negocio que el cliente pidió:
*
* 1. El panel no está expuesto en el sitio público.
* 2. Un vendedor ve solo sus propios leads; el administrador ve todo.
*
* Una fuga en la regla 2 es una falla de seguridad, no un detalle de interfaz.
*/
/** Entra con el acceso rápido de demostración, eligiendo por rol. */
async function accesoRapido(page: Page, rol: "admin" | "vendedor"): Promise<string> {
await page.goto("/admin/login", { waitUntil: "domcontentloaded" });
const botones = page.locator("form button, button").filter({ hasText: /.+/ });
await expect(botones.first()).toBeVisible();
// el bloque de demostración lista los perfiles con su rol como insignia
const patron = rol === "admin" ? /administrador/i : /vendedor/i;
const perfil = page.locator("button", { has: page.locator(`text=${rol === "admin" ? "Administrador" : "Vendedor"}`) }).first();
const objetivo = (await perfil.count()) > 0 ? perfil : page.getByRole("button").filter({ hasText: patron }).first();
const nombre = ((await objetivo.textContent()) ?? "").trim();
await objetivo.click();
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 20_000 });
return nombre;
}
test("el panel no se enlaza desde ninguna página pública", async ({ page }) => {
for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones"]) {
await page.goto(ruta, { waitUntil: "domcontentloaded" });
const enlaces = page.locator('a[href^="/admin"]');
await expect(enlaces, `${ruta} no debe enlazar al panel`).toHaveCount(0);
}
});
test("el panel exige sesión y devuelve a donde se quería ir", async ({ page }) => {
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/admin\/login/);
expect(page.url(), "debe recordar el destino").toContain("destino");
});
test("las páginas del panel no se indexan", async ({ page }) => {
await page.goto("/admin/login", { waitUntil: "domcontentloaded" });
const robots = await page.locator('meta[name="robots"]').first().getAttribute("content");
expect(robots ?? "", "el panel debe declarar noindex").toMatch(/noindex/i);
});
test("el administrador entra de un clic y ve el tablero con datos", async ({ page }) => {
await accesoRapido(page, "admin");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
// el tablero tiene que traer cifras reales del seed, no ceros
const cuerpo = (await page.locator("body").textContent()) ?? "";
const numeros = cuerpo.match(/\d+/g) ?? [];
expect(numeros.length, "el tablero debe mostrar cifras").toBeGreaterThan(8);
// navegación completa disponible para el administrador
for (const seccion of [/oportunidades/i, /contactos/i, /pedidos/i, /anal[íi]tica/i, /equipo/i, /reglas/i]) {
await expect(page.getByRole("link", { name: seccion }).first(), `falta ${seccion}`).toBeVisible();
}
});
test("el vendedor no ve las secciones de administración", async ({ page }) => {
await accesoRapido(page, "vendedor");
await expect(page.getByRole("link", { name: /equipo/i })).toHaveCount(0);
await expect(page.getByRole("link", { name: /reglas/i })).toHaveCount(0);
// y si escribe la URL a mano, tampoco entra
await page.goto("/admin/equipo", { waitUntil: "domcontentloaded" });
const url = page.url();
const cuerpo = (await page.locator("body").textContent()) ?? "";
const bloqueado =
!url.includes("/admin/equipo") || /no tienes|sin permiso|no autorizado|404/i.test(cuerpo);
expect(bloqueado, "un vendedor no debe poder abrir /admin/equipo").toBe(true);
});
test("el vendedor solo ve oportunidades propias", async ({ page }) => {
const nombreVendedor = await accesoRapido(page, "vendedor");
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const filas = page.locator("table tbody tr");
const total = await filas.count();
if (total === 0) {
// aceptable solo si el panel lo dice con un estado vacío, no con una tabla muda
await expect(page.getByText(/sin oportunidades|todav[íi]a no|no hay/i).first()).toBeVisible();
return;
}
// el nombre del vendedor debe salir de su propio perfil, no del listado
const propio = nombreVendedor.split("\n")[0]?.trim().split(" ")[0] ?? "";
expect(propio.length, "no se pudo leer el nombre del vendedor").toBeGreaterThan(1);
// ninguna fila debe pertenecer a otra persona: comparamos contra el total del admin
expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200);
});
test("el administrador ve más oportunidades que un vendedor", async ({ browser }) => {
const ctxAdmin = await browser.newContext();
const ctxVendedor = await browser.newContext();
const pAdmin = await ctxAdmin.newPage();
const pVendedor = await ctxVendedor.newPage();
try {
await accesoRapido(pAdmin, "admin");
await pAdmin.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const textoAdmin = (await pAdmin.locator("body").textContent()) ?? "";
await accesoRapido(pVendedor, "vendedor");
await pVendedor.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const textoVendedor = (await pVendedor.locator("body").textContent()) ?? "";
const foliosAdmin = new Set(textoAdmin.match(/OPP-\d{4}-\d{4}/g) ?? []);
const foliosVendedor = new Set(textoVendedor.match(/OPP-\d{4}-\d{4}/g) ?? []);
expect(foliosAdmin.size, "el administrador debe ver folios").toBeGreaterThan(0);
expect(
foliosAdmin.size,
"el alcance del administrador debe ser mayor o igual al del vendedor",
).toBeGreaterThanOrEqual(foliosVendedor.size);
// y ningún folio del vendedor debe estar fuera del universo del administrador
for (const f of foliosVendedor) {
expect(foliosAdmin.has(f), `el folio ${f} del vendedor no aparece para el administrador`).toBe(true);
}
} finally {
await ctxAdmin.close();
await ctxVendedor.close();
}
});
test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => {
await accesoRapido(page, "admin");
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const primera = page.locator('a[href^="/admin/oportunidades/"]').first();
await expect(primera).toBeVisible();
await primera.click();
await page.waitForLoadState("networkidle");
const cuerpo = (await page.locator("body").textContent()) ?? "";
// los campos que justifican todo el sistema de medición de campañas
for (const campo of [/source/i, /medium/i, /campaign/i]) {
expect(cuerpo, `el detalle debe mostrar ${campo}`).toMatch(campo);
}
expect(cuerpo).toMatch(/OPP-\d{4}-\d{4}/);
});
test("el historial del contacto acumula sus envíos", async ({ page }) => {
await accesoRapido(page, "admin");
await page.goto("/admin/contactos", { waitUntil: "networkidle" });
const primera = page.locator('a[href^="/admin/contactos/"]').first();
await expect(primera).toBeVisible();
await primera.click();
await page.waitForLoadState("networkidle");
const cuerpo = (await page.locator("body").textContent()) ?? "";
expect(cuerpo, "la ficha del contacto debe listar sus oportunidades").toMatch(/OPP-\d{4}-\d{4}/);
});
test("el panel es usable en móvil", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 780 });
await accesoRapido(page, "admin");
const r = await page.evaluate(() => {
const d = document.documentElement;
return { desborda: d.scrollWidth > d.clientWidth + 2, ancho: d.scrollWidth, visible: d.clientWidth };
});
expect(r.desborda, `el tablero desborda en móvil (${r.ancho} sobre ${r.visible})`).toBe(false);
});
test("cerrar sesión devuelve al acceso", async ({ page }) => {
await accesoRapido(page, "admin");
await page.getByRole("button", { name: /salir|cerrar sesi[óo]n/i }).first().click();
await page.waitForURL(/\/admin\/login/, { timeout: 15_000 });
// y la sesión ya no sirve
await page.goto("/admin", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/admin\/login/);
});