Files
AgendaPro/landing-e2e.mjs
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

203 lines
7.7 KiB
JavaScript

// landing-e2e.mjs — aceptación de la landing pública y del acceso mágico.
//
// Cubre lo que ningún test existente cubre: que `/` sirva la landing en lugar del
// login, que `/login` siga siendo alcanzable con su formulario sin interacción
// previa (de eso dependen los otros tres audits), que el acceso mágico entre de un
// clic, y que la landing no desborde en horizontal ni esconda contenido cuando el
// sistema pide menos movimiento.
//
// Uso: npm run test:landing (requiere `npm run dev` corriendo)
import assert from "node:assert/strict";
const BASE = process.env.LANDING_BASE_URL || "http://localhost:5173";
const T = 20000;
async function pickBrowser() {
const pw = await import("playwright");
for (const name of ["webkit", "chromium"]) {
try {
return { browser: await pw[name].launch(), engine: name };
} catch {
/* siguiente motor */
}
}
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
}
const passed = [];
async function check(name, fn) {
await fn();
passed.push(name);
console.log(` ok ${name}`);
}
async function main() {
const { browser, engine } = await pickBrowser();
console.log(`Motor: ${engine}\nBase: ${BASE}\n`);
try {
// ---- 1. `/` sirve la landing, no el login ----
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
ctx.setDefaultTimeout(T);
ctx.setDefaultNavigationTimeout(T);
const page = await ctx.newPage();
const pageErrors = [];
page.on("pageerror", (e) => pageErrors.push(e.message));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await check("`/` monta la landing", async () => {
await page.waitForSelector("[data-ld-hero-title]", { state: "visible" });
});
await check("`/` no expone el formulario de login", async () => {
assert.equal(await page.locator('input[type="email"]').count(), 0);
});
await check("la landing tiene las 7 secciones del funnel", async () => {
assert.equal(await page.locator("[data-ld-section]").count(), 7);
});
await check("la landing no lanza errores de runtime", async () => {
assert.deepEqual(pageErrors, []);
});
// ---- 2. `/login` conserva el formulario sin interacción previa ----
// Es un requisito duro: visual-audit, responsive-audit y pwa-e2e rellenan
// estos campos directo tras el goto. Colapsarlos rompería los tres.
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await check("`/login` muestra el formulario manual sin abrir nada", async () => {
await page.waitForSelector('input[type="email"]', { state: "visible" });
await page.waitForSelector('input[type="password"]', { state: "visible" });
await page.waitForSelector('button[type="submit"]', { state: "visible" });
});
await check("`/login` ofrece al menos 3 cuentas de acceso mágico", async () => {
await page.waitForSelector("[data-magic-login]", { state: "visible" });
const n = await page.locator("[data-magic-login]").count();
assert.ok(n >= 3, `esperaba >= 3 tarjetas, encontré ${n}`);
});
// ---- 3. El acceso mágico entra de un clic ----
await check("un clic en la tarjeta de dueña entra al panel", async () => {
await page.locator('[data-magic-login][data-role="owner"]').first().click();
await page.waitForURL(/\/dashboard/, { timeout: T });
});
// ---- 4. Con sesión, `/` redirige al panel ----
await check("`/` redirige al panel cuando hay sesión", async () => {
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForURL(/\/dashboard/, { timeout: T });
});
await ctx.close();
// ---- 5. Sin sesión, una ruta protegida manda al login ----
const anon = await browser.newContext({ viewport: { width: 1280, height: 900 } });
anon.setDefaultTimeout(T);
anon.setDefaultNavigationTimeout(T);
const anonPage = await anon.newPage();
await check("`/dashboard` sin sesión redirige a `/login`", async () => {
await anonPage.goto(`${BASE}/dashboard`, { waitUntil: "networkidle" });
await anonPage.waitForURL(/\/login/, { timeout: T });
});
await anon.close();
// ---- 6. Sin desborde horizontal en el teléfono más estrecho del objetivo ----
const phone = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
hasTouch: true,
});
phone.setDefaultTimeout(T);
phone.setDefaultNavigationTimeout(T);
const phonePage = await phone.newPage();
await phonePage.goto(`${BASE}/`, { waitUntil: "networkidle" });
await phonePage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
await check("la landing no desborda en horizontal a 390px", async () => {
// Se recorre toda la página: las secciones sticky y los visuales anchos solo
// desbordan una vez que entran en viewport y arrancan su animación.
const overflow = await phonePage.evaluate(async () => {
const doc = document.documentElement;
let worst = 0;
const steps = Math.ceil(doc.scrollHeight / window.innerHeight) + 1;
for (let i = 0; i < steps; i += 1) {
window.scrollTo(0, i * window.innerHeight);
await new Promise((r) => setTimeout(r, 350));
worst = Math.max(
worst,
Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth
);
}
return worst;
});
assert.ok(overflow <= 1, `desborde de ${overflow}px`);
});
await phone.close();
// ---- 7. Movimiento reducido no esconde contenido ----
// El fallo que se busca: un initial={{opacity:0}} cuyo whileInView nunca corre
// deja el bloque invisible de forma permanente.
const calm = await browser.newContext({
viewport: { width: 1280, height: 900 },
reducedMotion: "reduce",
});
calm.setDefaultTimeout(T);
calm.setDefaultNavigationTimeout(T);
const calmPage = await calm.newPage();
await calmPage.goto(`${BASE}/`, { waitUntil: "networkidle" });
await calmPage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
await check("con movimiento reducido el hero es visible", async () => {
const o = await calmPage
.locator("[data-ld-hero-title]")
.evaluate((el) => Number(getComputedStyle(el).opacity));
assert.ok(o >= 0.99, `opacity del hero = ${o}`);
});
await check("con movimiento reducido el CTA de cierre es visible", async () => {
const cta = calmPage.locator("[data-ld-closing-cta]");
await cta.scrollIntoViewIfNeeded();
const o = await cta.evaluate((el) => Number(getComputedStyle(el).opacity));
assert.ok(o >= 0.99, `opacity del CTA de cierre = ${o}`);
});
await check("con movimiento reducido todas las secciones son visibles", async () => {
const hidden = await calmPage.evaluate(async () => {
const out = [];
const nodes = [...document.querySelectorAll("[data-ld-section]")];
for (const [i, el] of nodes.entries()) {
el.scrollIntoView();
await new Promise((r) => setTimeout(r, 250));
const invisibles = [...el.querySelectorAll("*")].filter(
(n) => Number(getComputedStyle(n).opacity) < 0.05 && n.textContent?.trim()
);
if (invisibles.length)
out.push({
section: i,
count: invisibles.length,
sample: invisibles[0].textContent.trim().slice(0, 50),
});
}
return out;
});
assert.deepEqual(hidden, []);
});
await calm.close();
console.log(`\n${passed.length} comprobaciones OK`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(`\nlanding-e2e falló: ${error.message}`);
process.exitCode = 1;
});