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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0b466f33f3
commit
4c19244df9
+202
@@ -0,0 +1,202 @@
|
||||
// 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;
|
||||
});
|
||||
Reference in New Issue
Block a user