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]>
203 lines
7.7 KiB
JavaScript
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;
|
|
});
|