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
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type Page } from "@playwright/test";
import { ANCHOS, RUTAS_MUESTRA } from "./rutas";
/**
* Responsividad. Corre en los cuatro proyectos de la configuración, incluidos
* los dos de WebKit: Safari e iOS son donde de verdad se rompe el diseño, y el
* cliente lo pidió por nombre.
*
* La falla que perseguimos es el desbordamiento horizontal del documento. Una
* tabla que se desplaza dentro de su contenedor está bien; el cuerpo de la
* página desplazándose en horizontal no lo está nunca.
*/
async function desbordaEnHorizontal(page: Page): Promise<{ desborda: boolean; ancho: number; visible: number }> {
return page.evaluate(() => {
const doc = document.documentElement;
return {
// 2px de tolerancia por redondeo de subpíxel
desborda: doc.scrollWidth > doc.clientWidth + 2,
ancho: doc.scrollWidth,
visible: doc.clientWidth,
};
});
}
/** Devuelve los elementos que sobresalen del ancho de la ventana, para poder señalarlos. */
async function culpablesDeDesborde(page: Page): Promise<string[]> {
return page.evaluate(() => {
const limite = document.documentElement.clientWidth;
const malos: string[] = [];
for (const el of Array.from(document.querySelectorAll<HTMLElement>("body *"))) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > limite + 2 || r.left < -2) {
// ignoramos lo que desborda a propósito dentro de su propio contenedor con scroll
let padre: HTMLElement | null = el.parentElement;
let contenido = false;
while (padre && padre !== document.body) {
const estilo = getComputedStyle(padre);
if (estilo.overflowX === "auto" || estilo.overflowX === "scroll" || estilo.overflowX === "hidden") {
contenido = true;
break;
}
padre = padre.parentElement;
}
if (contenido) continue;
const clases = typeof el.className === "string" ? el.className.slice(0, 60) : "";
malos.push(`${el.tagName.toLowerCase()}${clases ? "." + clases.trim().split(/\s+/).join(".") : ""}`);
if (malos.length >= 6) break;
}
}
return malos;
});
}
for (const ruta of RUTAS_MUESTRA) {
test(`sin desborde horizontal en ${ruta}`, async ({ page }) => {
await page.goto(ruta, { waitUntil: "networkidle" });
for (const ancho of ANCHOS) {
await page.setViewportSize({ width: ancho, height: 900 });
// dar chance al reflow y a las imágenes
await page.waitForTimeout(220);
const r = await desbordaEnHorizontal(page);
if (r.desborda) {
const culpables = await culpablesDeDesborde(page);
expect(
r.desborda,
`${ruta} desborda a ${ancho}px (documento ${r.ancho}px sobre ventana ${r.visible}px). Sospechosos: ${culpables.join(", ") || "no identificados"}`,
).toBe(false);
}
}
});
}
test("el menú móvil abre, es navegable y cierra", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 780 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const disparador = page.getByRole("button", { name: /abrir men[úu]/i });
await expect(disparador).toBeVisible();
await disparador.click();
const menu = page.locator("#menu-movil");
await expect(menu).toBeVisible();
await expect(menu.getByRole("link").first()).toBeVisible();
await page.getByRole("button", { name: /cerrar men[úu]/i }).click();
await expect(menu).toBeHidden();
});
test("el widget de WhatsApp no tapa contenido ni desborda en móvil", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
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();
const caja = await panel.boundingBox();
expect(caja, "el panel debe tener caja").not.toBeNull();
expect(caja!.x, "el panel se sale por la izquierda").toBeGreaterThanOrEqual(-1);
expect(caja!.x + caja!.width, "el panel se sale por la derecha").toBeLessThanOrEqual(321);
const r = await desbordaEnHorizontal(page);
expect(r.desborda, "el widget abierto no debe provocar desborde del documento").toBe(false);
});
test("las tablas anchas se desplazan dentro de su contenedor", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 780 });
await page.goto("/catalogo", { waitUntil: "networkidle" });
const r = await desbordaEnHorizontal(page);
expect(r.desborda, "el comparador no debe desplazar el cuerpo de la página").toBe(false);
});