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
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
import { RUTAS_MUESTRA, TODAS_LAS_RUTAS } from "./rutas";
/**
* Humo: cada ruta pública responde, tiene una sola cabecera principal, título
* propio y los metadatos que la auditoría de marca señaló como ausentes en el
* sitio original.
*/
test.describe("rutas públicas responden", () => {
for (const ruta of TODAS_LAS_RUTAS) {
test(`200 y contenido en ${ruta}`, async ({ page }) => {
const res = await page.goto(ruta, { waitUntil: "domcontentloaded" });
expect(res?.status(), `${ruta} debe responder 200`).toBe(200);
// exactamente un h1: si hay dos, la jerarquía del documento está mal
const h1 = page.locator("h1");
await expect(h1, `${ruta} debe tener un solo h1`).toHaveCount(1);
await expect(h1).not.toBeEmpty();
const titulo = await page.title();
expect(titulo.length, `${ruta} necesita un <title> con sustancia`).toBeGreaterThan(12);
});
}
});
test.describe("metadatos que faltaban en el sitio original", () => {
for (const ruta of RUTAS_MUESTRA) {
test(`description y open graph en ${ruta}`, async ({ page }) => {
await page.goto(ruta, { waitUntil: "domcontentloaded" });
const desc = await page.locator('meta[name="description"]').getAttribute("content");
expect(desc, `${ruta} sin meta description`).toBeTruthy();
expect(desc!.length, `${ruta}: description demasiado corta`).toBeGreaterThan(50);
expect(desc!.length, `${ruta}: description demasiado larga para el snippet`).toBeLessThan(300);
const ogTitulo = await page.locator('meta[property="og:title"]').getAttribute("content");
expect(ogTitulo, `${ruta} sin og:title`).toBeTruthy();
const ogImagen = await page.locator('meta[property="og:image"]').first().getAttribute("content");
expect(ogImagen, `${ruta} sin og:image`).toBeTruthy();
const canonica = await page.locator('link[rel="canonical"]').getAttribute("href");
expect(canonica, `${ruta} sin canónica`).toBeTruthy();
});
}
});
test("idioma declarado en español de México", async ({ page }) => {
await page.goto("/");
await expect(page.locator("html")).toHaveAttribute("lang", /^es/);
});
test("sitemap lista rutas reales y excluye el panel", async ({ page }) => {
const res = await page.goto("/sitemap.xml");
expect(res?.status()).toBe(200);
const xml = await page.content();
expect(xml).toContain("/catalogo");
expect(xml).toContain("/soluciones/");
expect(xml, "el panel no debe aparecer en el sitemap").not.toContain("/admin");
});
test("robots prohíbe el panel y la API", async ({ request }) => {
const res = await request.get("/robots.txt");
expect(res.status()).toBe(200);
const txt = await res.text();
expect(txt).toMatch(/Disallow:\s*\/admin/i);
expect(txt).toMatch(/Sitemap:/i);
});
test("iconos y tarjeta social se generan", async ({ request }) => {
for (const ruta of ["/icon", "/opengraph-image"]) {
const res = await request.get(ruta);
expect(res.status(), `${ruta} debe responder`).toBeLessThan(400);
expect(res.headers()["content-type"], `${ruta} debe ser imagen`).toContain("image");
}
});
test("sonda de salud responde", async ({ request }) => {
const res = await request.get("/api/salud");
expect(res.status()).toBe(200);
const json = (await res.json()) as { ok: boolean };
expect(json.ok).toBe(true);
});
test("una ruta inexistente devuelve 404 con salidas útiles", async ({ page }) => {
const res = await page.goto("/producto/esto-no-existe-jamas");
expect(res?.status()).toBe(404);
// un 404 sin camino de regreso es un callejón
await expect(page.getByRole("link", { name: /cat[áa]logo|soluciones|inicio|contacto/i }).first()).toBeVisible();
});