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]>
99 KiB
Landing de funnel BI + login mágico — Plan de implementación
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Convertir / en una landing pública de funnel con enfoque business intelligence y estética Apple, moviendo el login a /login con acceso mágico de un clic a las cuentas demo.
Architecture: La landing es una ruta pública nueva, cargada con React.lazy, compuesta por ocho secciones independientes de cero props. framer-motion entra como dependencia aislada en su propio chunk de Rollup para que el panel no la pague. El login se rediseña reutilizando el endpoint /api/auth/demo-users que ya existe — no se toca el servidor.
Tech Stack: React 18, TypeScript, Vite 5, Tailwind 3, react-router-dom 6, framer-motion 11, Playwright (audits).
Spec: docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md
Global Constraints
- Node.js >= 22.5. Todo script de servidor pasa por
scripts/run-tsx.mjs, nuncanpx tsxdirecto. - Windows/PowerShell: usa
npm.cmdsinpm.ps1está bloqueado por execution policy. - Instalar con
npm run setup(npm ci, caché en.cache/npm). La única excepción de este plan esnpm install framer-motion@^11en la Tarea 1, que sí debe mutarpackage.jsony el lockfile. - NO HAGAS COMMITS. Política del repo registrada en
.superpowers/sdd/progress.mdy enCLAUDE.md. La verificación por tarea esnpm run typecheck+ tests, nunca un commit. Los pasos de este plan terminan en verificación, no engit commit. npm run lintestá roto (ESLint 9 sineslint.config.js). No lo uses como señal. El gate real esnpm run typecheck.- Idioma: todo string visible al usuario en español de LATAM. Los comentarios de código, en español.
- Nunca
100vh. Usa.min-h-screen-safe/.h-screen-safe/.max-h-screen-safe, que declaranvhcomo fallback ydvhencima.100vhen iOS no descuenta la barra de URL dinámica. - Ningún campo de formulario por debajo de 16px en táctil. Lo garantiza la regla global de
@media (pointer: coarse)ensrc/index.css; no la anules con utilidadestext-smen inputs. - Objetivos táctiles por
pointer: coarse, no por breakpointssm:. Unsm:deja fuera a las tablets, que también se tocan. - Todo
gridcongrid-cols-1explícito. Sin él la columna implícita se dimensiona amax-contenty desborda en horizontal. prefers-reduced-motion: reduceresuelve losinitialal estado final, no acorta transiciones. Uninitial={{opacity:0}}que nunca anima deja el contenido invisible.- Easing único en toda la landing:
cubic-bezier(0.16, 1, 0.3, 1), exportado comoEASE_EXPO. - Puertos:
devusa:3000(API) y:5173(Vite, con proxy/api). Los audits apuntan a:5173exceptopwa-e2e.mjs, que apunta a:3000sobre el build.
Mapa de archivos
| Archivo | Responsabilidad |
|---|---|
src/lib/motion.ts |
Crear. Easing y variants compartidos. Único lugar donde se define la curva. |
src/lib/useReducedMotion.ts |
Crear. Hook booleano sobre prefers-reduced-motion, reactivo a cambios en vivo. |
src/index.css |
Modificar. Añade el scope .landing con sus tokens y las utilidades tipográficas .ld-*. |
vite.config.ts |
Modificar. Chunk manual motion. |
src/App.tsx |
Modificar. Rutas públicas / y /login, helper homePathFor, React.lazy de la landing. |
src/pages/LandingPage.tsx |
Crear. Orquestador. Solo compone secciones. Export default (lo exige lazy). |
src/components/landing/LandingNav.tsx |
Crear. Nav flotante que se condensa al scrollear. |
src/components/landing/HeroSection.tsx |
Crear. Sección 1. |
src/components/landing/CostSection.tsx |
Crear. Sección 2, con contadores animados. |
src/components/landing/ShiftSection.tsx |
Crear. Sección 3, sticky con scrub por scroll. |
src/components/landing/ProductSection.tsx |
Crear. Sección 4, tres actos con panel sticky. |
src/components/landing/ProofSection.tsx |
Crear. Sección 5. |
src/components/landing/ObjectionsSection.tsx |
Crear. Sección 6. |
src/components/landing/ClosingSection.tsx |
Crear. Sección 7 + footer. |
src/components/landing/visuals/CalendarGridVisual.tsx |
Crear. Retícula que se puebla de citas. |
src/components/landing/visuals/RevenueLineVisual.tsx |
Crear. Línea de ingresos que se traza. |
src/components/landing/visuals/TeamLoadVisual.tsx |
Crear. Barras de carga por especialista. |
src/components/landing/visuals/NotebookVisual.tsx |
Crear. Cuaderno con tachones. |
src/components/landing/visuals/DashboardMockVisual.tsx |
Crear. Composición del panel a escala. |
src/pages/LoginPage.tsx |
Modificar. Rediseño con acceso mágico como camino principal. |
landing-e2e.mjs |
Crear. Test de aceptación Playwright. |
package.json |
Modificar. framer-motion + script test:landing. |
visual-audit.mjs |
Modificar. / → /login; añade la landing a PAGES. |
responsive-audit.mjs |
Modificar. / → /login; añade la landing a PAGES. |
pwa-e2e.mjs |
Modificar. Los siete goto(baseUrl) que esperan el login pasan a url("/login"). |
No se toca: server/, shared/types.ts, el esquema, las migraciones, public/sw.js, ni los tests de API (e2e-test.mjs, admin-test.mjs, server/scripts/booking-e2e.mjs, server/lib/*.test.ts).
Task 1: Fundamentos de movimiento y tokens de la landing
Files:
- Modify:
package.json(dependencias + scripttest:landing) - Modify:
vite.config.ts:33-45(manualChunks) - Create:
src/lib/motion.ts - Create:
src/lib/useReducedMotion.ts - Modify:
src/index.css(añadir al final del@layer componentsexistente y una regla fuera de capa)
Interfaces:
-
Consumes: nada.
-
Produces:
EASE_EXPO: readonly [number, number, number, number]baseTransition: TransitionrevealUp(reduced: boolean, distance?: number): VariantsrevealStagger(reduced: boolean, stagger?: number): VariantsinView: { once: true; margin: string }useReducedMotion(): boolean- Clases CSS:
.landing,.landing-dark,.landing-light,.ld-display,.ld-lead,.ld-eyebrow,.ld-section,.ld-wrap,.ld-num
-
Step 1: Instalar framer-motion
Este es el único paso del plan que muta package.json y el lockfile a propósito.
npm.cmd install framer-motion@^11
- Step 2: Verificar que quedó en
dependencies, no endevDependencies
Run: npm.cmd ls framer-motion --depth=0
Expected: imprime [email protected] sin la marca (dev). Si cayó en devDependencies, muévelo a mano en package.json y repite npm.cmd install.
- Step 3: Añadir el chunk manual
motionenvite.config.ts
En el objeto manualChunks (líneas 33-45), añade la entrada motion después de icons:
manualChunks: {
react: ["react", "react-dom", "react-router-dom"],
calendar: [
"@fullcalendar/core",
"@fullcalendar/react",
"@fullcalendar/daygrid",
"@fullcalendar/timegrid",
"@fullcalendar/interaction",
],
charts: ["recharts"],
query: ["@tanstack/react-query"],
icons: ["lucide-react"],
// La landing es la única que anima. Sin este chunk, Rollup mete
// framer-motion en el bundle compartido y sus ~50 KB gzip se cobran en
// cada carga del panel, que nunca lo usa.
motion: ["framer-motion"],
},
- Step 4: Añadir el script
test:landingenpackage.json
En scripts, justo después de "test:booking":
"test:landing": "node landing-e2e.mjs",
- Step 5: Crear
src/lib/motion.ts
import type { Transition, Variants } from "framer-motion";
/**
* ease-out-expo. Un solo easing en toda la landing: es lo que produce la
* sensación de que los elementos "pesan algo y frenan solos", y mezclar curvas
* es exactamente lo que hace que una landing se sienta improvisada.
*/
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const;
export const baseTransition: Transition = { duration: 0.7, ease: EASE_EXPO };
/**
* Reveal vertical. `reduced` colapsa el estado inicial al final: NO acorta la
* transición, la elimina. Si `hidden` dejara `opacity: 0` cuando el usuario pide
* menos movimiento, el contenido quedaría invisible para siempre porque la
* animación que lo revela nunca se dispara.
*/
export function revealUp(reduced: boolean, distance = 24): Variants {
return {
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: distance },
show: { opacity: 1, y: 0, transition: baseTransition },
};
}
/** Contenedor que escalona a sus hijos. Con movimiento reducido, stagger 0. */
export function revealStagger(reduced: boolean, stagger = 0.08): Variants {
return {
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : stagger } },
};
}
/**
* Props de viewport compartidas para `whileInView`. Una sola pasada (`once`), con
* margen negativo para que el reveal arranque cuando la sección ya entró de
* verdad y no en el instante en que su borde roza la pantalla.
*/
export const inView = { once: true, margin: "-12% 0px -12% 0px" } as const;
- Step 6: Crear
src/lib/useReducedMotion.ts
import { useEffect, useState } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
/**
* `true` cuando el sistema pide menos movimiento. Se suscribe a los cambios: en
* iOS la preferencia se activa desde Ajustes sin recargar la pestaña, así que
* leerla una sola vez al montar dejaría la landing animando de todas formas.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(() => window.matchMedia(QUERY).matches);
useEffect(() => {
const mq = window.matchMedia(QUERY);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
// Resincroniza: la preferencia pudo cambiar entre el render inicial y el efecto.
setReduced(mq.matches);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
- Step 7: Añadir los tokens de la landing en
src/index.css
Dentro del @layer components que ya existe (empieza en la línea 415), después del bloque .input, .select, .textarea, añade:
/* ---- Landing pública ----
Scope propio porque el body del panel es #f6f7fb con color-scheme: light y la
landing necesita secciones oscuras a sangre sin alterar el panel. Va DENTRO de
la capa (el default del repo) para que cualquier utilidad de Tailwind aplicada
en el JSX gane por especificidad de capa. */
.landing {
--ld-ink: #08080a; /* casi-negro: el negro puro aplana y delata el gradiente */
--ld-paper: #fafafa;
--ld-muted: #6b6b73; /* secundario sobre papel */
--ld-muted-dark: #8a8a94; /* secundario sobre tinta */
--ld-hairline: rgba(255, 255, 255, 0.09);
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
}
.landing-dark {
background: var(--ld-ink);
color: var(--ld-paper);
}
.landing-light {
background: var(--ld-paper);
color: var(--ld-ink);
}
/* Escala fluida sin media queries: clamp() con vw evita los saltos entre
breakpoints que delatan una landing armada con utilidades sueltas. */
.ld-display {
font-size: clamp(2.5rem, 7.5vw, 5.75rem);
line-height: 0.98;
letter-spacing: -0.035em;
font-weight: 800;
}
.ld-title {
font-size: clamp(1.875rem, 4.5vw, 3.25rem);
line-height: 1.05;
letter-spacing: -0.025em;
font-weight: 800;
}
.ld-lead {
font-size: clamp(1.0625rem, 2.1vw, 1.375rem);
line-height: 1.5;
}
.ld-eyebrow {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* El padding vertical ES el "silencio" del criterio Apple: sin él, ninguna
cantidad de tipografía grande hace que se lea caro. */
.ld-section {
padding-block: clamp(5rem, 12vh, 11rem);
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
.ld-wrap {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}
/* Números que se interpolan frame a frame. Sin tabular-nums cada dígito cambia
de ancho y la línea entera relayoutea en cada frame. */
.ld-num {
font-variant-numeric: tabular-nums;
}
- Step 8: Permitir que la landing crezca más allá de
#root
html, body, #root están fijados a height: 100% (líneas 26-30) para el shell de altura fija del panel. La landing es mucho más alta que el viewport. Añade fuera de @layer, justo después del bloque html, body, #root:
/* El shell del panel vive en un #root de altura fija; la landing, no. Se relaja
solo cuando la landing está montada, así el panel conserva su altura fija.
:has() está soportado en Safari 15.4+, dentro del rango de iOS objetivo. */
body:has(.landing) #root {
height: auto;
min-height: 100%;
}
- Step 9: Verificar que compila
Run: npm.cmd run typecheck
Expected: sin errores. motion.ts y useReducedMotion.ts aún no tienen consumidores, así que solo se valida que los tipos de framer-motion resuelven.
Task 2: Test de aceptación (debe fallar)
Escribe primero el test que define «hecho». Debe fallar ahora y quedar verde al final de la Tarea 9.
Files:
- Create:
landing-e2e.mjs
Interfaces:
-
Consumes:
test:landingdepackage.json(Tarea 1, paso 4). -
Produces: contrato de atributos de datos que las tareas 3-9 deben cumplir:
[data-ld-section]— uno por sección de la landing; deben ser 7.[data-ld-hero-title]— elh1del hero.[data-ld-closing-cta]— el CTA de la sección de cierre.[data-magic-login]— una por tarjeta de cuenta demo en/login.[data-magic-login][data-role="owner"]— la tarjeta que entra como dueña.
-
Step 1: Escribir el test
// 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 OWNER_EMAIL = "[email protected]";
const PASSWORD = "demo1234";
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 () => {
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 });
});
const authed = await ctx.storageState();
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 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 });
}
return out;
});
assert.deepEqual(hidden, []);
});
await calm.close();
console.log(`\n${passed.length} comprobaciones OK`);
void authed;
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(`\nlanding-e2e falló: ${error.message}`);
process.exitCode = 1;
});
- Step 2: Arrancar el entorno de desarrollo
En una terminal aparte:
npm.cmd run dev
Espera a ver SERVER escuchando en :3000 y WEB en :5173. Si un puerto está ocupado, el preflight aborta; usa $env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev y exporta LANDING_BASE_URL=http://localhost:5174.
- Step 3: Correr el test y confirmar que falla
Run: npm.cmd run test:landing
Expected: FALLA en la primera comprobación, con un timeout esperando [data-ld-hero-title]. / sigue sirviendo el login, así que ese selector no existe. Ese fallo es la señal correcta de partida.
Task 3: Ruteo público y landing mínima con hero
Al terminar esta tarea deben pasar las comprobaciones 1, 2 (parcial), 4, 5 y 7 del test. Las secciones aún no son 7, así que esa sigue roja.
Files:
- Modify:
src/App.tsx(reescritura completa del archivo) - Create:
src/pages/LandingPage.tsx - Create:
src/components/landing/HeroSection.tsx - Create:
src/components/landing/visuals/CalendarGridVisual.tsx
Interfaces:
-
Consumes:
revealUp,revealStagger,inView,EASE_EXPOdesrc/lib/motion.ts;useReducedMotiondesrc/lib/useReducedMotion.ts. -
Produces:
homePathFor(user: User): stringexportado desdesrc/App.tsx.LandingPagecomo export default desrc/pages/LandingPage.tsx.HeroSection(named export), cero props.CalendarGridVisual(named export) con props{ className?: string; density?: number }.
-
Step 1: Reescribir
src/App.tsx
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./lib/auth";
import type { User } from "../shared/types";
import { LoginPage } from "./pages/LoginPage";
import { AppShell } from "./components/AppShell";
import { AdminShell } from "./components/AdminShell";
import { DashboardPage } from "./pages/DashboardPage";
import { CalendarPage } from "./pages/CalendarPage";
import { EmployeesPage } from "./pages/EmployeesPage";
import { ServicesPage } from "./pages/ServicesPage";
import { ClientsPage } from "./pages/ClientsPage";
import { TicketsPage } from "./pages/TicketsPage";
import { ClientDetailPage } from "./pages/ClientDetailPage";
import { CashPage } from "./pages/CashPage";
import { NotificationsPage } from "./pages/NotificationsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { MyPerformancePage } from "./pages/MyPerformancePage";
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
import BookingPage from "./pages/public/BookingPage";
// La landing va aparte del bundle principal por dos razones que apuntan en
// direcciones opuestas, y por eso importan las dos: quien ya tiene sesión nunca la
// ve y no debe descargarla, y quien llega a la landing no debe descargar
// FullCalendar ni recharts para leer una página de venta.
const LandingPage = lazy(() => import("./pages/LandingPage"));
/** Destino del usuario según su rol. Única definición de la regla. */
export function homePathFor(user: User): string {
if (user.role === "admin") return "/admin";
return user.role === "owner" ? "/dashboard" : "/calendar";
}
function Splash() {
return (
<div className="flex h-screen-safe items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span className="text-sm font-medium">Cargando AgendaMax…</span>
</div>
</div>
);
}
function AppRoutes() {
const { user, loading } = useAuth();
if (loading) return <Splash />;
const home = user ? homePathFor(user) : "/login";
const isAdmin = user?.role === "admin";
return (
<Routes>
{/* ---- Públicas ---- */}
<Route
path="/"
element={
user ? (
<Navigate to={home} replace />
) : (
<Suspense fallback={<Splash />}>
<LandingPage />
</Suspense>
)
}
/>
<Route path="/login" element={user ? <Navigate to={home} replace /> : <LoginPage />} />
{/* Sin sesión, cualquier otra ruta manda al login. */}
{!user && <Route path="*" element={<Navigate to="/login" replace />} />}
{/* ---- Admin de plataforma ---- */}
{user && isAdmin && (
<Route element={<AdminShell />}>
<Route path="/admin" element={<AdminOverviewPage />} />
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
)}
{/* ---- Negocio ---- */}
{user && !isAdmin && (
<Route element={<AppShell />}>
{user.role === "owner" ? (
<Route path="/dashboard" element={<DashboardPage />} />
) : (
<Route path="/dashboard" element={<Navigate to="/calendar" replace />} />
)}
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:id" element={<ClientDetailPage />} />
<Route path="/me" element={<MyPerformancePage />} />
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
{/* La autorización real vive en los middlewares del servidor; esto es UX. */}
<Route path="/admin/*" element={<Navigate to={home} replace />} />
<Route path="*" element={<Navigate to={home} replace />} />
</Route>
)}
</Routes>
);
}
export default function App() {
return (
<Routes>
{/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */}
<Route path="/b/:slug" element={<BookingPage />} />
<Route
path="*"
element={
<AuthProvider>
<AppRoutes />
</AuthProvider>
}
/>
</Routes>
);
}
- Step 2: Crear
src/components/landing/visuals/CalendarGridVisual.tsx
import { motion } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const COLS = 7;
const ROWS = 9;
const DIAS = ["L", "M", "M", "J", "V", "S", "D"];
/** Colores de cita: se repiten para simular varios especialistas. */
const TINTES = ["#3b66ff", "#f17616", "#0ea5e9", "#8b5cf6", "#10b981"];
/**
* Retícula de agenda que se puebla sola. No es una captura: es la promesa del
* producto en movimiento — el día pasando de vacío a lleno.
*
* `density` es la fracción de celdas que se convierten en cita (0..1).
*/
export function CalendarGridVisual({
className = "",
density = 0.42,
}: {
className?: string;
density?: number;
}) {
const reduced = useReducedMotion();
const total = COLS * ROWS;
// Patrón determinista: un pseudo-aleatorio con semilla fija da la misma agenda
// en cada render y en cada corrida del audit visual, que si no capturaría
// pantallas distintas cada vez y reportaría diferencias falsas.
const celdas = Array.from({ length: total }, (_, i) => {
const h = ((i * 2654435761) % 1000) / 1000;
const ocupada = h < density;
return {
ocupada,
tinte: TINTES[i % TINTES.length],
alto: h < density * 0.35 ? 2 : 1,
};
});
return (
<div className={`select-none ${className}`} aria-hidden="true">
<div className="mb-2 grid grid-cols-7 gap-1.5">
{DIAS.map((d, i) => (
<div
key={`${d}-${i}`}
className="text-center text-[0.6rem] font-bold uppercase tracking-widest opacity-40"
>
{d}
</div>
))}
</div>
<motion.div
className="grid grid-cols-7 gap-1.5"
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={{
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.012 } },
}}
>
{celdas.map((c, i) => (
<motion.div
key={i}
className="h-5 rounded-[0.3rem] sm:h-6"
style={{
background: c.ocupada ? c.tinte : "currentColor",
opacity: c.ocupada ? 1 : 0.07,
}}
variants={{
hidden: reduced
? { scaleY: 1, opacity: c.ocupada ? 1 : 0.07 }
: { scaleY: 0.25, opacity: 0 },
show: {
scaleY: 1,
opacity: c.ocupada ? 1 : 0.07,
transition: { duration: 0.45, ease: EASE_EXPO },
},
}}
/>
))}
</motion.div>
</div>
);
}
- Step 3: Crear
src/components/landing/HeroSection.tsx
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight, ChevronDown } from "lucide-react";
import { EASE_EXPO, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
const LINEAS = ["Tu negocio te habla", "todos los días.", "Nadie está escuchando."];
export function HeroSection() {
const reduced = useReducedMotion();
return (
<section
data-ld-section
className="landing-dark relative isolate flex min-h-screen-safe flex-col justify-center overflow-hidden ld-section"
>
{/* Fondo: la agenda del producto, desenfocada. Es el sujeto de la foto, no
un adorno — por eso es la retícula real y no un gradiente. */}
<div className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center">
<div className="w-[150%] max-w-none opacity-[0.14] blur-[2px] sm:w-[110%]">
<CalendarGridVisual density={0.5} />
</div>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse at 50% 40%, rgba(8,8,10,0.25) 0%, rgba(8,8,10,0.92) 68%, #08080a 100%)",
}}
/>
</div>
<motion.div
className="ld-wrap"
initial="hidden"
animate="show"
variants={revealStagger(reduced, 0.08)}
>
<motion.p
className="ld-eyebrow mb-6"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced, 12)}
>
Business intelligence para tu negocio
</motion.p>
<h1 data-ld-hero-title className="ld-display max-w-4xl">
{LINEAS.map((linea, i) => (
<motion.span
key={linea}
className="block"
variants={revealUp(reduced, 28)}
style={i === 2 ? { color: "var(--ld-muted-dark)" } : undefined}
>
{linea}
</motion.span>
))}
</h1>
<motion.p
className="ld-lead mt-8 max-w-xl"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced)}
>
Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los
convierte en decisiones que te dejan dinero.
</motion.p>
<motion.div
className="mt-10 grid grid-cols-1 gap-3 sm:flex sm:items-center"
variants={revealUp(reduced)}
>
<Link
to="/login"
className="inline-flex min-h-[52px] items-center justify-center gap-2 rounded-full bg-white px-7 text-[0.95rem] font-bold text-[#08080a] transition-transform hover:scale-[1.02]"
>
Entrar a la demo
<ArrowRight className="h-4 w-4" />
</Link>
<a
href="#costo"
className="inline-flex min-h-[52px] items-center justify-center gap-2 rounded-full border px-7 text-[0.95rem] font-semibold"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-paper)" }}
>
Ver cómo funciona
<ChevronDown className="h-4 w-4" />
</a>
</motion.div>
<motion.p
className="mt-6 text-xs"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced, 8)}
>
Sin registro. Sin tarjeta. Cuentas ya cargadas.
</motion.p>
</motion.div>
{/* Indicador de scroll con respiración lenta. Se apaga con movimiento reducido. */}
{!reduced && (
<motion.div
className="absolute bottom-8 left-1/2 -translate-x-1/2"
animate={{ y: [0, 8, 0], opacity: [0.35, 0.8, 0.35] }}
transition={{ duration: 2.4, repeat: Infinity, ease: EASE_EXPO }}
>
<ChevronDown className="h-5 w-5" />
</motion.div>
)}
</section>
);
}
- Step 4: Crear
src/pages/LandingPage.tsx
Por ahora solo el hero. Las tareas 5-7 van añadiendo secciones a este archivo, en este orden.
import { HeroSection } from "../components/landing/HeroSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*/
export default function LandingPage() {
return (
<div className="landing">
<HeroSection />
</div>
);
}
- Step 5: Verificar tipos
Run: npm.cmd run typecheck
Expected: sin errores.
- Step 6: Correr el test de aceptación
Run: npm.cmd run test:landing
Expected: pasan «/ monta la landing», «/ no expone el formulario de login», «la landing no lanza errores de runtime», y las de redirección y movimiento reducido. Falla «la landing tiene las 7 secciones del funnel» con 1 !== 7. Ese es el estado correcto: quedan seis secciones por construir.
Task 4: Visuales animados restantes
Cuatro componentes de presentación pura. Se construyen juntos porque comparten el mismo contrato (cero estado, className opcional, animan al entrar en viewport, se congelan con movimiento reducido) y ninguna sección posterior sirve sin ellos.
Files:
- Create:
src/components/landing/visuals/RevenueLineVisual.tsx - Create:
src/components/landing/visuals/TeamLoadVisual.tsx - Create:
src/components/landing/visuals/NotebookVisual.tsx - Create:
src/components/landing/visuals/DashboardMockVisual.tsx
Interfaces:
-
Consumes:
EASE_EXPO,inViewdesrc/lib/motion.ts;useReducedMotion;CalendarGridVisual(Tarea 3). -
Produces:
RevenueLineVisual({ className?: string })TeamLoadVisual({ className?: string })NotebookVisual({ className?: string })DashboardMockVisual({ className?: string })
-
Step 1: Crear
RevenueLineVisual.tsx
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a
// propósito, para que el audit visual capture siempre la misma gráfica.
const SERIE = [38, 44, 41, 52, 58, 54, 66, 71, 68, 79, 86, 94];
const W = 520;
const H = 200;
const PAD = 12;
function pathFrom(serie: number[]): string {
const max = Math.max(...serie);
const min = Math.min(...serie);
const dx = (W - PAD * 2) / (serie.length - 1);
return serie
.map((v, i) => {
const x = PAD + i * dx;
const y = H - PAD - ((v - min) / (max - min)) * (H - PAD * 2);
return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(" ");
}
/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */
export function RevenueLineVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const d = pathFrom(SERIE);
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`}
role="img"
aria-label="Ingresos mensuales en tendencia ascendente"
>
<defs>
<linearGradient id="ld-rev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b66ff" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3b66ff" stopOpacity="0" />
</linearGradient>
</defs>
{/* Rejilla base: cuatro guías, muy tenues. Dan escala sin competir. */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD}
x2={W - PAD}
y1={PAD + f * (H - PAD * 2)}
y2={PAD + f * (H - PAD * 2)}
stroke="currentColor"
strokeOpacity="0.08"
strokeWidth="1"
/>
))}
<motion.path
d={area}
fill="url(#ld-rev-fill)"
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.9, delay: reduced ? 0 : 0.5, ease: EASE_EXPO }}
/>
<motion.path
d={d}
fill="none"
stroke="#3b66ff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduced ? 1 : 0 }}
whileInView={{ pathLength: 1 }}
viewport={inView}
transition={{ duration: reduced ? 0 : 1.4, ease: EASE_EXPO }}
/>
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
<motion.circle
cx={W - PAD}
cy={
H -
PAD -
((SERIE[SERIE.length - 1] - Math.min(...SERIE)) /
(Math.max(...SERIE) - Math.min(...SERIE))) *
(H - PAD * 2)
}
r="5"
fill="#3b66ff"
initial={{ scale: reduced ? 1 : 0 }}
whileInView={{ scale: 1 }}
viewport={inView}
transition={{ duration: 0.5, delay: reduced ? 0 : 1.3, ease: EASE_EXPO }}
/>
</svg>
);
}
- Step 2: Crear
TeamLoadVisual.tsx
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Nombres del negocio demo sembrado (plantilla estetica-spa).
const EQUIPO = [
{ nombre: "Valentina", carga: 0.92, color: "#3b66ff", citas: 34 },
{ nombre: "Mateo", carga: 0.74, color: "#0ea5e9", citas: 27 },
{ nombre: "Camila", carga: 0.61, color: "#8b5cf6", citas: 22 },
{ nombre: "Diego", carga: 0.38, color: "#f17616", citas: 14 },
{ nombre: "Renata", carga: 0.22, color: "#10b981", citas: 8 },
];
/** Carga real por especialista. El punto: la diferencia se ve de un golpe. */
export function TeamLoadVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`grid grid-cols-1 gap-3.5 ${className}`}>
{EQUIPO.map((p, i) => (
<div key={p.nombre} className="grid grid-cols-1 gap-1.5">
<div className="flex items-baseline justify-between text-sm">
<span className="font-bold">{p.nombre}</span>
<span className="ld-num text-xs opacity-55">{p.citas} citas</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-current/10">
<motion.div
className="h-full rounded-full"
style={{ background: p.color, originX: 0 }}
initial={{ scaleX: reduced ? p.carga : 0 }}
whileInView={{ scaleX: p.carga }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.9,
delay: reduced ? 0 : i * 0.09,
ease: EASE_EXPO,
}}
/>
</div>
</div>
))}
</div>
);
}
Nota: bg-current/10 requiere que el contenedor herede el color del texto de la sección. Si Tailwind 3.4 no resuelve la opacidad sobre currentColor en tu versión, sustitúyelo por style={{ background: "currentColor", opacity: 0.1 }} en un div envolvente.
- Step 3: Crear
NotebookVisual.tsx
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const APUNTES = [
{ texto: "10:00 Sra. Ortiz — tinte", tachado: true },
{ texto: "11:30 ¿Camila o Mateo?", tachado: false },
{ texto: "12:00 confirmar x WhatsApp", tachado: true },
{ texto: "1:00 —— cancelado", tachado: true },
{ texto: "4:00 pendiente $$$", tachado: false },
{ texto: "cobrar a la Sra. del jueves", tachado: false },
];
/**
* El "antes": el cuaderno. Se dibuja con tipografía manuscrita del sistema y
* tachones que se trazan, porque un icono de libreta no transmite el desorden.
*/
export function NotebookVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div
className={`relative overflow-hidden rounded-2xl bg-[#faf7ef] p-6 text-[#3b3a35] shadow-[0_24px_60px_-24px_rgba(0,0,0,0.55)] ${className}`}
aria-hidden="true"
>
{/* Renglones del cuaderno */}
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "repeating-linear-gradient(#faf7ef 0 30px, #ddd6c4 30px 31px)",
}}
/>
{/* Margen rojo */}
<div className="pointer-events-none absolute inset-y-0 left-10 w-px bg-[#d98b8b]" />
<div className="relative grid grid-cols-1 gap-[0.72rem] pl-8">
{APUNTES.map((a, i) => (
<div key={a.texto} className="relative w-fit max-w-full">
<span
className="text-[0.92rem] leading-[1.6]"
style={{ fontFamily: "'Bradley Hand', 'Segoe Script', cursive" }}
>
{a.texto}
</span>
{a.tachado && (
<motion.span
className="absolute left-0 top-1/2 h-[1.5px] w-full bg-[#3b3a35]"
style={{ originX: 0 }}
initial={{ scaleX: reduced ? 1 : 0 }}
whileInView={{ scaleX: 1 }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.4,
delay: reduced ? 0 : 0.3 + i * 0.14,
ease: EASE_EXPO,
}}
/>
)}
</div>
))}
</div>
</div>
);
}
- Step 4: Crear
DashboardMockVisual.tsx
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
import { RevenueLineVisual } from "./RevenueLineVisual";
const KPIS = [
{ etiqueta: "Ingresos del mes", valor: 184300, prefijo: "$", sufijo: "" },
{ etiqueta: "Citas atendidas", valor: 412, prefijo: "", sufijo: "" },
{ etiqueta: "Ocupación", valor: 78, prefijo: "", sufijo: "%" },
];
const TOP = [
{ nombre: "Corte + barba", monto: 42800 },
{ nombre: "Tinte completo", monto: 38150 },
{ nombre: "Manicura gel", monto: 24600 },
];
/** Contador que interpola de 0 al valor. Se salta la animación si hay reduced motion. */
function Contador({ valor, prefijo, sufijo }: { valor: number; prefijo: string; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-10% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1400;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
// ease-out cúbico: llega rápido y frena, igual que EASE_EXPO en CSS.
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num">
{prefijo}
{n.toLocaleString("es-MX")}
{sufijo}
</span>
);
}
/** El panel real, a escala. Es la prueba: datos de la cuenta demo. */
export function DashboardMockVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<motion.div
className={`grid grid-cols-1 gap-4 rounded-3xl border border-black/[0.06] bg-white p-5 text-[#0f172a] shadow-[0_40px_100px_-40px_rgba(15,23,42,0.45)] sm:p-7 ${className}`}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.8, ease: EASE_EXPO }}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{KPIS.map((k) => (
<div key={k.etiqueta} className="rounded-2xl bg-[#f6f7fb] p-4">
<div className="text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
{k.etiqueta}
</div>
<div className="mt-1.5 text-2xl font-extrabold tracking-tight sm:text-[1.75rem]">
<Contador valor={k.valor} prefijo={k.prefijo} sufijo={k.sufijo} />
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.6fr_1fr]">
<div className="rounded-2xl bg-[#f6f7fb] p-4">
<div className="mb-2 text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
Ingresos · últimos 12 meses
</div>
<RevenueLineVisual />
</div>
<div className="rounded-2xl bg-[#f6f7fb] p-4">
<div className="mb-3 text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
Servicios que más dejan
</div>
<div className="grid grid-cols-1 gap-2.5">
{TOP.map((s, i) => (
<motion.div
key={s.nombre}
className="flex items-baseline justify-between gap-3"
initial={{ opacity: reduced ? 1 : 0, x: reduced ? 0 : -12 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={inView}
transition={{
duration: 0.5,
delay: reduced ? 0 : 0.4 + i * 0.1,
ease: EASE_EXPO,
}}
>
<span className="truncate text-sm font-semibold">{s.nombre}</span>
<span className="ld-num shrink-0 text-sm font-bold text-brand-600">
${s.monto.toLocaleString("es-MX")}
</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
);
}
- Step 5: Verificar tipos
Run: npm.cmd run typecheck
Expected: sin errores. Los cuatro visuales todavía no tienen consumidores; esto solo valida sus tipos.
Task 5: Secciones 2 y 3 — el costo y el cambio
Files:
- Create:
src/components/landing/CostSection.tsx - Create:
src/components/landing/ShiftSection.tsx - Modify:
src/pages/LandingPage.tsx
Interfaces:
-
Consumes:
revealUp,revealStagger,inView,EASE_EXPO,useReducedMotion,NotebookVisual,CalendarGridVisual. -
Produces:
CostSection,ShiftSection(named exports, cero props). -
Step 1: Crear
CostSection.tsx
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Cuatro fugas, no cuatro features. El copy nombra el costo de no saber; el
* producto no se menciona todavía. Vender antes de haber dolido no funciona.
*/
const FUGAS = [
{
cifra: 34,
sufijo: "%",
titulo: "de tus horas se van vacías",
detalle:
"Huecos de 40 minutos entre citas que nadie llena porque nadie los ve. Son sueldo pagado sin ingreso.",
},
{
cifra: 5,
sufijo: " meses",
titulo: "lleva sin volver tu cliente frecuente",
detalle:
"Sigue en tu lista. Nadie te avisó. Recuperarlo cuesta una fracción de conseguir uno nuevo.",
},
{
cifra: 1,
sufijo: " de 6",
titulo: "servicios te cuesta más de lo que cobra",
detalle:
"Entre tiempo de silla, insumos y comisión, hay uno que trabaja gratis. Sin desglose no aparece.",
},
{
cifra: 0,
sufijo: "",
titulo: "datos tienes de quién sostiene el negocio",
detalle:
"Sabes quién es tu mejor especialista por corazonada. Cuando se va, te enteras del tamaño real.",
},
];
function Cifra({ valor, sufijo }: { valor: number; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-15% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible || valor === 0) {
if (valor === 0) setN(0);
return;
}
const DUR = 1100;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num">
{n}
{sufijo}
</span>
);
}
export function CostSection() {
const reduced = useReducedMotion();
return (
<section id="costo" data-ld-section className="landing-light ld-section">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 12)}
>
El costo de no saber
</motion.p>
<motion.h2 className="ld-title max-w-3xl" variants={revealUp(reduced, 24)}>
Lo que no mides no te avisa. Te cobra.
</motion.h2>
<motion.p
className="ld-lead mt-6 max-w-xl"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Ninguna de estas cuatro fugas aparece en tu cuenta de banco con nombre y apellido. Todas
salen de ahí.
</motion.p>
</motion.div>
<div className="mt-16 grid grid-cols-1 gap-x-10 gap-y-12 sm:grid-cols-2">
{FUGAS.map((f, i) => (
<motion.div
key={f.titulo}
className="grid grid-cols-1 gap-3 border-t pt-7"
style={{ borderColor: "rgba(8,8,10,0.12)" }}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.7, delay: reduced ? 0 : i * 0.08 }}
>
<div className="text-5xl font-extrabold tracking-tight text-brand-600 sm:text-6xl">
<Cifra valor={f.cifra} sufijo={f.sufijo} />
</div>
<h3 className="text-lg font-extrabold leading-snug">{f.titulo}</h3>
<p className="text-[0.95rem] leading-relaxed" style={{ color: "var(--ld-muted)" }}>
{f.detalle}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
- Step 2: Crear
ShiftSection.tsx
import { useRef } from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { NotebookVisual } from "./visuals/NotebookVisual";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
/**
* El cambio, contado con el scroll: el cuaderno se va y el tablero llega. Es la
* única sección con scrub (progreso ligado a la posición), que es lo que hace que
* el scroll se sienta como una película y no como una lista.
*
* El contenedor mide 260vh para que el panel sticky tenga recorrido; con
* movimiento reducido se colapsa a una pila estática de altura natural.
*/
export function ShiftSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end end"],
});
const notaOpacidad = useTransform(scrollYProgress, [0.05, 0.42], [1, 0]);
const notaEscala = useTransform(scrollYProgress, [0.05, 0.42], [1, 0.92]);
const notaGiro = useTransform(scrollYProgress, [0.05, 0.42], [-2, -7]);
const panelOpacidad = useTransform(scrollYProgress, [0.32, 0.62], [0, 1]);
const panelY = useTransform(scrollYProgress, [0.32, 0.62], [40, 0]);
if (reduced) {
return (
<section data-ld-section className="landing-dark ld-section">
<div className="ld-wrap grid grid-cols-1 gap-12">
<div>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-muted-dark)" }}>
El cambio
</p>
<h2 className="ld-title max-w-3xl">
No es que trabajes poco. Es que trabajas sin instrumentos.
</h2>
<p className="ld-lead mt-6 max-w-xl" style={{ color: "var(--ld-muted-dark)" }}>
El mismo día, los mismos clientes, el mismo equipo. La diferencia es que ahora lo ves.
</p>
</div>
<NotebookVisual className="max-w-md" />
<div className="rounded-2xl border p-6" style={{ borderColor: "var(--ld-hairline)" }}>
<CalendarGridVisual />
</div>
</div>
</section>
);
}
return (
<section data-ld-section className="landing-dark">
<div ref={ref} className="relative h-[260vh]">
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={revealUp(false)}
>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-muted-dark)" }}>
El cambio
</p>
<h2 className="ld-title">
No es que trabajes poco.
<br />
<span style={{ color: "var(--ld-muted-dark)" }}>
Es que trabajas sin instrumentos.
</span>
</h2>
<p className="ld-lead mt-6 max-w-md" style={{ color: "var(--ld-muted-dark)" }}>
El mismo día, los mismos clientes, el mismo equipo. La diferencia es que ahora lo
ves.
</p>
</motion.div>
{/* Escenario: las dos piezas ocupan la misma caja y se relevan. */}
<div className="relative min-h-[22rem] sm:min-h-[26rem]">
<motion.div
className="absolute inset-0"
style={{ opacity: notaOpacidad, scale: notaEscala, rotate: notaGiro }}
>
<NotebookVisual />
</motion.div>
<motion.div
className="absolute inset-0 rounded-2xl border p-5 sm:p-6"
style={{
opacity: panelOpacidad,
y: panelY,
borderColor: "var(--ld-hairline)",
background: "rgba(255,255,255,0.03)",
}}
>
<div className="mb-3 text-[0.7rem] font-bold uppercase tracking-wider opacity-50">
Tu semana, completa
</div>
<CalendarGridVisual />
</motion.div>
</div>
</div>
</div>
</div>
</section>
);
}
- Step 3: Montar las dos secciones en
LandingPage.tsx
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*/
export default function LandingPage() {
return (
<div className="landing">
<HeroSection />
<CostSection />
<ShiftSection />
</div>
);
}
- Step 4: Verificar tipos
Run: npm.cmd run typecheck
Expected: sin errores.
- Step 5: Correr el test
Run: npm.cmd run test:landing
Expected: sigue fallando «7 secciones» con 3 !== 7. Deben pasar las tres comprobaciones de movimiento reducido — si «con movimiento reducido todas las secciones son visibles» falla aquí, hay un initial sin rama reduced en CostSection o ShiftSection. Arréglalo antes de continuar; es el bug que este test existe para atrapar.
Task 6: Secciones 4 y 5 — el producto y la prueba
Files:
- Create:
src/components/landing/ProductSection.tsx - Create:
src/components/landing/ProofSection.tsx - Modify:
src/pages/LandingPage.tsx
Interfaces:
-
Consumes:
EASE_EXPO(soloProductSection),inView+revealStagger+revealUp(soloProofSection),useReducedMotion,CalendarGridVisual,RevenueLineVisual,TeamLoadVisual,DashboardMockVisual. -
Produces:
ProductSection,ProofSection(named exports, cero props). -
Step 1: Crear
ProductSection.tsx
import { useRef, useState } from "react";
import { motion, useMotionValueEvent, useScroll } from "framer-motion";
import { CalendarDays, LineChart, Users } from "lucide-react";
import { EASE_EXPO } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
import { RevenueLineVisual } from "./visuals/RevenueLineVisual";
import { TeamLoadVisual } from "./visuals/TeamLoadVisual";
const ACTOS = [
{
icono: CalendarDays,
rotulo: "Agenda",
titulo: "Tu día completo en una pantalla.",
copy: "Arrastra una cita y se reacomoda. El sistema no te deja agendar dos personas en el mismo lugar a la misma hora, ni asignar a quien no está en turno.",
visual: <CalendarGridVisual density={0.48} />,
},
{
icono: LineChart,
rotulo: "Inteligencia",
titulo: "Los números que tu contador te da en marzo, hoy a las 3 de la tarde.",
copy: "Ingresos por día, por servicio y por especialista. Qué creció, qué cayó y desde cuándo. Sin exportar nada, sin pedirle un reporte a nadie.",
visual: <RevenueLineVisual />,
},
{
icono: Users,
rotulo: "Equipo",
titulo: "Deja de repartir el trabajo por intuición.",
copy: "La carga real de cada especialista, lado a lado. Quién está saturado, quién tiene hueco y quién trae de vuelta a los clientes.",
visual: <TeamLoadVisual />,
},
];
export function ProductSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState(0);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
useMotionValueEvent(scrollYProgress, "change", (p) => {
// Tres tramos iguales. Se clampea al último para que el final del recorrido no
// devuelva un índice fuera de rango.
setActivo(Math.min(ACTOS.length - 1, Math.floor(p * ACTOS.length)));
});
// Con movimiento reducido no hay panel sticky: los tres actos se apilan enteros.
if (reduced) {
return (
<section data-ld-section className="landing-light ld-section">
<div className="ld-wrap grid grid-cols-1 gap-20">
{ACTOS.map((a) => (
<div key={a.rotulo} className="grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
<div>
<p className="ld-eyebrow mb-4 text-brand-600">{a.rotulo}</p>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</div>
<div className="rounded-2xl border border-black/[0.07] bg-white p-6">{a.visual}</div>
</div>
))}
</div>
</section>
);
}
return (
<section data-ld-section className="landing-light">
<div ref={ref} className="relative" style={{ height: `${ACTOS.length * 100}vh` }}>
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
{/* Texto: los tres actos ocupan la misma caja y se relevan. */}
<div className="relative min-h-[19rem]">
{ACTOS.map((a, i) => {
const Icono = a.icono;
return (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{
opacity: activo === i ? 1 : 0,
y: activo === i ? 0 : 18,
}}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<div className="mb-4 flex items-center gap-2.5 text-brand-600">
<Icono className="h-4 w-4" />
<span className="ld-eyebrow">{a.rotulo}</span>
</div>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5 max-w-lg" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</motion.div>
);
})}
</div>
{/* Panel: cambia de visual con el acto. */}
<div className="relative min-h-[20rem] rounded-3xl border border-black/[0.07] bg-white p-5 shadow-[0_30px_80px_-40px_rgba(15,23,42,0.35)] sm:min-h-[24rem] sm:p-7">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-5 grid grid-cols-1 content-center sm:inset-7"
animate={{
opacity: activo === i ? 1 : 0,
scale: activo === i ? 1 : 0.97,
}}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
{a.visual}
</motion.div>
))}
</div>
</div>
</div>
</div>
{/* Indicador de progreso entre actos. */}
<div className="ld-wrap flex items-center justify-center gap-2 pb-6">
{ACTOS.map((a, i) => (
<motion.span
key={a.rotulo}
className="h-1 rounded-full bg-current"
animate={{ width: activo === i ? 28 : 10, opacity: activo === i ? 0.8 : 0.2 }}
transition={{ duration: 0.4, ease: EASE_EXPO }}
/>
))}
</div>
</section>
);
}
Nota: esta sección no usa inView ni revealUp a propósito — el scrub por scroll reemplaza al reveal
por viewport. EASE_EXPO es lo único que necesita de motion.ts.
- Step 2: Crear
ProofSection.tsx
import { motion } from "framer-motion";
import { ShieldCheck } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { DashboardMockVisual } from "./visuals/DashboardMockVisual";
export function ProofSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-light ld-section pt-0">
<div className="ld-wrap">
<motion.div
className="mx-auto max-w-2xl text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5 text-brand-600"
variants={revealUp(reduced, 12)}
>
La prueba
</motion.p>
<motion.h2 className="ld-title" variants={revealUp(reduced, 24)}>
Esto no es una maqueta.
</motion.h2>
<motion.p
className="ld-lead mt-6"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Es el tablero de la cuenta demo, con datos reales de un negocio sembrado. Puedes entrar,
mover una cita y ver cómo cambian los números.
</motion.p>
</motion.div>
<DashboardMockVisual className="mt-14" />
<motion.div
className="mx-auto mt-8 flex max-w-xl items-start gap-3 text-sm"
style={{ color: "var(--ld-muted)" }}
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.6, delay: reduced ? 0 : 0.3 }}
>
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-600" />
<span>
Entorno de demostración. Los datos se pueden reiniciar en cualquier momento, así que
muévelos sin miedo.
</span>
</motion.div>
</div>
</section>
);
}
- Step 3: Montar en
LandingPage.tsx
Añade los dos imports y las dos etiquetas después de <ShiftSection />:
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
import { ProductSection } from "../components/landing/ProductSection";
import { ProofSection } from "../components/landing/ProofSection";
export default function LandingPage() {
return (
<div className="landing">
<HeroSection />
<CostSection />
<ShiftSection />
<ProductSection />
<ProofSection />
</div>
);
}
- Step 4: Verificar tipos
Run: npm.cmd run typecheck
Expected: sin errores.
- Step 5: Correr el test
Run: npm.cmd run test:landing
Expected: «7 secciones» falla con 5 !== 7. Todo lo demás en verde.
Task 7: Secciones 6 y 7 — objeciones, cierre y nav
Files:
- Create:
src/components/landing/ObjectionsSection.tsx - Create:
src/components/landing/ClosingSection.tsx - Create:
src/components/landing/LandingNav.tsx - Modify:
src/pages/LandingPage.tsx
Interfaces:
-
Consumes:
inView,revealStagger,revealUp,EASE_EXPO,useReducedMotion. -
Produces:
ObjectionsSection,ClosingSection,LandingNav(named exports, cero props).ClosingSectiondebe rendir el atributodata-ld-closing-ctaen su CTA. -
Step 1: Crear
ObjectionsSection.tsx
import { motion } from "framer-motion";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/** Las tres objeciones reales de un dueño de negocio en LATAM, sin endulzar. */
const OBJECIONES = [
{
dice: "No soy de tecnología.",
responde:
"Si sabes usar WhatsApp, sabes usar esto. Se arrastra con el dedo y no hay nada que configurar antes de empezar a usarlo.",
},
{
dice: "Mi equipo no lo va a adoptar.",
responde:
"Cada empleado ve solo su día y sus propios números. No administra nada, no configura nada. Es menos trabajo que preguntarte a ti a qué hora entra.",
},
{
dice: "Ya tengo mi cuaderno y me funciona.",
responde:
"Tu cuaderno te dice a qué hora es la cita. No te dice qué servicio te está costando dinero, ni quién de tus clientes dejó de venir hace cuatro meses.",
},
];
export function ObjectionsSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-light ld-section">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 12)}
>
Lo que estás pensando
</motion.p>
<motion.h2 className="ld-title max-w-2xl" variants={revealUp(reduced, 24)}>
Tres razones para no hacerlo. Y por qué ninguna aguanta.
</motion.h2>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-px overflow-hidden rounded-2xl bg-black/[0.09]">
{OBJECIONES.map((o, i) => (
<motion.div
key={o.dice}
className="grid grid-cols-1 gap-3 bg-[var(--ld-paper)] p-7 sm:grid-cols-[1fr_1.5fr] sm:gap-10 sm:p-9"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.65, delay: reduced ? 0 : i * 0.1 }}
>
<p className="text-xl font-extrabold leading-snug tracking-tight sm:text-2xl">
«{o.dice}»
</p>
<p className="text-[0.98rem] leading-relaxed" style={{ color: "var(--ld-muted)" }}>
{o.responde}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
- Step 2: Crear
ClosingSection.tsx
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight, Sparkles } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
export function ClosingSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-dark relative overflow-hidden">
{/* Cierra el círculo del hero: la misma retícula, ahora llena. */}
<div className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center">
<div className="w-[150%] max-w-none opacity-[0.1] blur-[2px] sm:w-[110%]">
<CalendarGridVisual density={0.72} />
</div>
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse at 50% 50%, rgba(8,8,10,0.35) 0%, rgba(8,8,10,0.94) 70%, #08080a 100%)",
}}
/>
</div>
<motion.div
className="ld-wrap ld-section text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.h2 className="ld-display mx-auto max-w-3xl" variants={revealUp(reduced, 28)}>
Deja de adivinar.
</motion.h2>
<motion.p
className="ld-lead mx-auto mt-7 max-w-lg"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced)}
>
Entra a la demo con una cuenta ya cargada y mira tu negocio como se ve cuando tienes los
números enfrente.
</motion.p>
<motion.div className="mt-11" variants={revealUp(reduced)}>
<Link
data-ld-closing-cta
to="/login"
className="inline-flex min-h-[56px] items-center justify-center gap-2 rounded-full bg-white px-9 text-base font-bold text-[#08080a] transition-transform hover:scale-[1.02]"
>
Entrar a la demo
<ArrowRight className="h-4 w-4" />
</Link>
</motion.div>
<motion.p
className="mt-6 text-xs"
style={{ color: "var(--ld-muted-dark)" }}
variants={revealUp(reduced, 8)}
>
Sin registro. Sin tarjeta. Cuentas ya cargadas.
</motion.p>
</motion.div>
<footer
className="ld-wrap flex flex-col items-center justify-between gap-4 border-t px-5 py-8 text-xs sm:flex-row"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-muted-dark)" }}
>
<div className="flex items-center gap-2 font-bold">
<Sparkles className="h-4 w-4" />
AgendaMax
</div>
<span>© {new Date().getFullYear()} AgendaMax · Entorno de demostración</span>
<Link to="/login" className="font-semibold underline-offset-4 hover:underline">
Iniciar sesión
</Link>
</footer>
</section>
);
}
- Step 3: Crear
LandingNav.tsx
import { Link } from "react-router-dom";
import { motion, useScroll, useTransform } from "framer-motion";
import { Sparkles } from "lucide-react";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Nav flotante. Arranca transparente sobre el hero y se condensa al scrollear:
* fondo y desenfoque suben juntos, que es lo que evita el salto brusco de un
* `position: fixed` con fondo sólido desde el primer píxel.
*/
export function LandingNav() {
const reduced = useReducedMotion();
const { scrollY } = useScroll();
const fondo = useTransform(scrollY, [0, 140], ["rgba(8,8,10,0)", "rgba(8,8,10,0.72)"]);
const desenfoque = useTransform(scrollY, [0, 140], ["blur(0px)", "blur(14px)"]);
const borde = useTransform(scrollY, [0, 140], ["rgba(255,255,255,0)", "rgba(255,255,255,0.09)"]);
return (
<motion.header
className="safe-top fixed inset-x-0 top-0 z-50 border-b"
style={
reduced
? { background: "rgba(8,8,10,0.82)", borderColor: "rgba(255,255,255,0.09)" }
: { background: fondo, backdropFilter: desenfoque, borderColor: borde }
}
>
<nav className="ld-wrap flex items-center justify-between gap-4 px-5 py-3.5">
<Link
to="/"
className="flex items-center gap-2 text-[0.95rem] font-extrabold tracking-tight text-white"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/12">
<Sparkles className="h-4 w-4" />
</span>
AgendaMax
</Link>
<Link
to="/login"
className="tap-target inline-flex items-center rounded-full bg-white px-5 text-sm font-bold text-[#08080a]"
>
Entrar a la demo
</Link>
</nav>
</motion.header>
);
}
- Step 4: Completar
LandingPage.tsx
import { LandingNav } from "../components/landing/LandingNav";
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
import { ProductSection } from "../components/landing/ProductSection";
import { ProofSection } from "../components/landing/ProofSection";
import { ObjectionsSection } from "../components/landing/ObjectionsSection";
import { ClosingSection } from "../components/landing/ClosingSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*
* Orden del funnel: problema → costo → mecanismo → prueba → objeción → cierre.
* `LandingNav` es chrome fijo, no cuenta como sección.
*/
export default function LandingPage() {
return (
<div className="landing">
<LandingNav />
<HeroSection />
<CostSection />
<ShiftSection />
<ProductSection />
<ProofSection />
<ObjectionsSection />
<ClosingSection />
</div>
);
}
- Step 5: Verificar tipos
Run: npm.cmd run typecheck
Expected: sin errores.
- Step 6: Correr el test
Run: npm.cmd run test:landing
Expected: «la landing tiene las 7 secciones del funnel» ahora pasa. Falla «/login ofrece al menos 3 cuentas de acceso mágico» y «un clic en la tarjeta de dueña entra al panel», porque LoginPage todavía no tiene [data-magic-login]. Verifica también «la landing no desborda en horizontal a 390px»: si falla, el culpable más probable es un w-[150%] de fondo sin overflow-hidden en su sección.
Task 8: Login mágico
Files:
- Modify:
src/pages/LoginPage.tsx(reescritura completa)
Interfaces:
-
Consumes:
useAuthdesrc/lib/auth;api.auth.demoUsers()desrc/lib/api;Spinnerdesrc/components/ui;InstallAppPrompt;EASE_EXPO,revealStagger,revealUpdesrc/lib/motion;useReducedMotion. -
Produces:
LoginPage(named export, como hoy). Contrato de DOM que los cuatro audits dependen:input[type="email"],input[type="password"],button[type="submit"]visibles en la carga inicial, sin interacción.[data-magic-login]por tarjeta;[data-magic-login][data-role="owner"]para la dueña.
-
Step 1: Reescribir
src/pages/LoginPage.tsx
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AlertCircle, ArrowLeft, ArrowRight, Lock, Mail, Sparkles, Wand2 } from "lucide-react";
import { motion } from "framer-motion";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
import { InstallAppPrompt } from "../components/InstallAppPrompt";
import { revealStagger, revealUp } from "../lib/motion";
import { useReducedMotion } from "../lib/useReducedMotion";
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
type DemoUser = { email: string; name: string; role: string; avatar_color: string };
/** Qué desbloquea cada rol. Es la información que convierte una lista de correos
* en una decisión: el visitante elige qué producto quiere ver. */
const GRUPOS = [
{
role: "owner",
titulo: "Dueña del negocio",
detalle: "Tablero completo: ingresos, equipo, clientes y caja.",
limite: 1,
},
{
role: "employee",
titulo: "Especialista",
detalle: "Solo su agenda del día y su propio desempeño.",
limite: 2,
},
{
role: "admin",
titulo: "Plataforma",
detalle: "Consola multi-negocio: alta, reset y métricas de todos los tenants.",
limite: 1,
},
] as const;
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const reduced = useReducedMotion();
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
const [password, setPassword] = useState(DEMO ? "demo1234" : "");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pendiente, setPendiente] = useState<string | null>(null);
const [demoUsers, setDemoUsers] = useState<DemoUser[]>([]);
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
useEffect(() => {
if (!DEMO) return;
api.auth
.demoUsers()
.then((r) => setDemoUsers(r.users))
.catch(() => {});
}, []);
const grupos = useMemo(
() =>
GRUPOS.map((g) => ({
...g,
cuentas: demoUsers.filter((u) => u.role === g.role).slice(0, g.limite),
})).filter((g) => g.cuentas.length > 0),
[demoUsers]
);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(email, password);
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
}
};
// Acceso mágico, solo demo. `DEMO` es una constante de build, así que esta rama
// entera —y con ella la contraseña— se elimina del bundle de producción por
// dead-code elimination. Por eso la contraseña vive aquí dentro y no arriba.
const quickLogin = DEMO
? async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setPendiente(mail);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
setPendiente(null);
}
}
: undefined;
return (
<div className="landing landing-dark relative min-h-screen-safe overflow-hidden">
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse at 20% 0%, rgba(59,102,255,0.22) 0%, rgba(8,8,10,0) 55%)",
}}
/>
<div className="safe-top safe-x relative mx-auto flex w-full max-w-5xl flex-col px-5 pb-12 pt-6">
<Link
to="/"
className="tap-target inline-flex w-fit items-center gap-2 text-sm font-semibold"
style={{ color: "var(--ld-muted-dark)" }}
>
<ArrowLeft className="h-4 w-4" />
Volver al inicio
</Link>
<motion.div
className="mt-10 grid grid-cols-1 items-start gap-10 lg:mt-14 lg:grid-cols-[1.05fr_1fr] lg:gap-14"
initial="hidden"
animate="show"
variants={revealStagger(reduced)}
>
{/* ---- Columna izquierda: acceso mágico ---- */}
<motion.div variants={revealUp(reduced)}>
<div className="flex items-center gap-2.5">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/12">
<Sparkles className="h-5 w-5" />
</span>
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
</div>
<h1 className="ld-title mt-8 text-[clamp(1.75rem,4vw,2.5rem)]">
Entra sin registrarte.
</h1>
<p className="ld-lead mt-4 max-w-md" style={{ color: "var(--ld-muted-dark)" }}>
Elige con qué ojos quieres ver el producto. Cada cuenta abre una vista distinta del
mismo negocio.
</p>
{DEMO && (
<div className="mt-9 grid grid-cols-1 gap-7">
{grupos.map((g) => (
<div key={g.role} className="grid grid-cols-1 gap-3">
<div>
<div className="text-sm font-extrabold">{g.titulo}</div>
<div className="text-xs" style={{ color: "var(--ld-muted-dark)" }}>
{g.detalle}
</div>
</div>
<div className="grid grid-cols-1 gap-2">
{g.cuentas.map((u) => (
<button
key={u.email}
type="button"
data-magic-login
data-role={u.role}
onClick={() => quickLogin?.(u.email)}
disabled={loading}
className="group flex min-h-[56px] w-full items-center gap-3 rounded-2xl border px-4 py-2.5 text-left transition-colors hover:bg-white/[0.06] disabled:opacity-60"
style={{ borderColor: "var(--ld-hairline)" }}
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name
.split(" ")
.map((p) => p[0])
.slice(0, 2)
.join("")}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold">{u.name}</span>
<span
className="block truncate text-[11px]"
style={{ color: "var(--ld-muted-dark)" }}
>
{u.email}
</span>
</span>
{pendiente === u.email ? (
<Spinner />
) : (
<Wand2
className="h-4 w-4 shrink-0 opacity-40 transition-opacity group-hover:opacity-100"
/>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</motion.div>
{/* ---- Columna derecha: formulario manual ----
Visible sin interacción a propósito: visual-audit, responsive-audit y
pwa-e2e rellenan estos campos justo después del goto. Colapsarlo tras
un toggle rompería los tres. */}
<motion.div
className="w-full rounded-3xl border p-6 sm:p-7"
style={{ borderColor: "var(--ld-hairline)", background: "rgba(255,255,255,0.04)" }}
variants={revealUp(reduced)}
>
<h2 className="text-lg font-extrabold">Entrar con correo</h2>
<p className="mt-1 text-sm" style={{ color: "var(--ld-muted-dark)" }}>
Si ya tienes una cuenta del negocio.
</p>
<form onSubmit={submit} className="mt-6 grid grid-cols-1 gap-4">
<div>
<label className="label" style={{ color: "var(--ld-muted-dark)" }}>
Correo
</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
autoComplete="email"
required
/>
</div>
</div>
<div>
<label className="label" style={{ color: "var(--ld-muted-dark)" }}>
Contraseña
</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="password"
className="input pl-9"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-rose-500/15 px-3 py-2 text-sm font-medium text-rose-200">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
{loading && !pendiente ? (
<Spinner />
) : (
<>
Entrar <ArrowRight className="h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-5">
<InstallAppPrompt />
</div>
{DEMO && (
<p className="mt-5 text-center text-xs" style={{ color: "var(--ld-muted-dark)" }}>
Demo · cualquier cuenta usa la contraseña{" "}
<code className="font-mono font-semibold text-white">demo1234</code>
</p>
)}
</motion.div>
</motion.div>
</div>
</div>
);
}
- Step 2: Verificar tipos
Run: npm.cmd run typecheck
Expected: sin errores.
- Step 3: Correr el test de aceptación completo
Run: npm.cmd run test:landing
Expected: todas las comprobaciones en verde, con 13 comprobaciones OK al final.
- Step 4: Revisar el login a 390px con el ojo
Run: npm.cmd run audit:responsive
Expected: este comando va a fallar en la página login porque PAGES sigue apuntando a /, que ahora es la landing. Eso se arregla en la Tarea 9. Lo que se busca aquí es que el resto de páginas no haya regresionado. Anota los hallazgos de login y sigue.
Task 9: Arreglar los audits existentes
Los tres scripts hacen goto('/') y a continuación fill('input[type="email"]'). Con la landing en / ese fill falla. El arreglo es un cambio de URL: el formulario sigue visible sin interacción previa.
Files:
- Modify:
visual-audit.mjs:16y:44 - Modify:
responsive-audit.mjs:53y:264 - Modify:
pwa-e2e.mjs:187, 200, 230, 250, 262, 280, 288
Interfaces:
-
Consumes: el contrato de DOM de
/loginque produjo la Tarea 8. -
Produces: nada que consuman tareas posteriores.
-
Step 1:
visual-audit.mjs— apuntar el login a/loginy añadir la landing
En PAGES (línea 16), sustituye la entrada login y añade landing al frente:
const PAGES = [
{ name: "landing", path: "/", login: false },
{ name: "login", path: "/login", login: false },
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
En el helper login() (línea 44):
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
- Step 2:
responsive-audit.mjs— lo mismo
En PAGES (línea 53):
const PAGES = [
// La landing es pública y no lleva sesión; se audita como cualquier otra página.
{ name: "landing", path: "/", role: null },
{ name: "login", path: "/login", role: null },
{ name: "calendar", path: "/calendar", role: "owner", waitFor: ".fc" },
En el helper login() (línea 264):
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
- Step 3:
pwa-e2e.mjs— los sietegotoque esperan el login
InstallAppPrompt vive en el login y dentro del panel, no en la landing. Los siete contextos que
prueban el prompt deben aterrizar en /login. Ya existe el helper url(pathname) en la línea 12;
úsalo. Añade una constante junto a baseUrl (línea 5):
const loginUrl = new URL("login", BASE.endsWith("/") ? BASE : `${BASE}/`).href;
Y sustituye baseUrl por loginUrl en estos siete goto:
| Línea | Contexto | Por qué |
|---|---|---|
| 187 | chromiumPage |
Espera el botón de instalar y luego assertInstallAction(…, "login"). |
| 200 | dismissalPage |
Prueba el descarte del prompt. |
| 230 | businessPage |
Hace login(businessPage, OWNER_EMAIL, …) justo después. |
| 250 | iosPage |
Verifica las instrucciones de iOS del prompt. |
| 262 | adminPage |
Hace login(adminPage, ADMIN_EMAIL, …) justo después. |
| 280 | standalonePage |
Asegura que el prompt no aparece en standalone. Sobre la landing pasaría por vacío y no probaría nada. |
| 288 | unsupportedPage |
Igual: la ausencia solo significa algo donde el prompt debería estar. |
Ejemplo del cambio en la línea 187:
await chromiumPage.goto(loginUrl, { waitUntil: "networkidle" });
- Step 4: Verificar el audit responsive completo
Requiere npm run dev corriendo.
Run: npm.cmd run audit:responsive
Expected: sin hallazgos nuevos. Presta atención a dos filas concretas: landing en iphone-12 (390px) no debe reportar overflow, y login en cualquier dispositivo táctil no debe reportar ios-zoom — si lo hace, alguna utilidad text-sm está pisando la regla de 16px en un input.
- Step 5: Verificar el audit visual
Run: npm.cmd run audit:visual
Expected: termina sin errores y deja capturas nuevas en screenshots/, incluida landing-* en los cuatro viewports. Ábrelas: es la única verificación real de que la estética quedó como se diseñó.
- Step 6: Confirmar que los tests de API no regresionaron
Run: npm.cmd run test:unit
Then: npm.cmd run test:e2e
Then: npm.cmd run test:admin
Then: npm.cmd run test:booking
Expected: los cuatro en verde. Ninguno depende del ruteo del cliente, así que un fallo aquí significa que se tocó algo del servidor por error.
Task 10: Verificación de build y del aislamiento del chunk
La promesa de que el panel no paga framer-motion solo se puede comprobar sobre el build.
Files:
- Ninguno. Solo verificación.
Interfaces:
-
Consumes: todo lo anterior.
-
Produces: nada.
-
Step 1: Build de producción
Detén npm run dev antes (el preflight aborta si :3000 está ocupado).
Run: npm.cmd run build
Expected: tsc -b limpio y vite build exitoso. En la tabla de salida debe aparecer un chunk motion-*.js propio. Si no aparece, el manualChunks de la Tarea 1 no se aplicó.
- Step 2: Confirmar que la landing es su propio chunk y que el panel no importa motion
node -e "const fs=require('fs');const d='dist/assets';const f=fs.readdirSync(d);console.log('chunks:',f.filter(n=>n.endsWith('.js')).join('\n '));"
Expected: la lista incluye un motion-*.js y un chunk separado para la landing (Vite lo nombra por el módulo dinámico, típicamente LandingPage-*.js). Que existan como archivos distintos del entry es lo que prueba el split.
- Step 3: Verificar en el navegador que el panel no descarga el chunk de motion
Run: npm.cmd start
Luego, en el navegador con la pestaña de red abierta:
- Ve a
http://localhost:3000/loginy entra con la tarjeta de la dueña. - Filtra las peticiones por
motion.
Expected: cero peticiones a motion-*.js durante la sesión del panel. /login sí lo carga (usa revealUp), lo cual es correcto y esperado: es una página pública. Lo que no debe ocurrir es que /dashboard o /calendar lo pidan.
- Step 4: Correr el test de PWA sobre el build
Con npm.cmd start corriendo en :3000:
Run: npm.cmd run test:pwa
Expected: PWA checks passed. Si falla en assertInstallAction, algún goto de la Tarea 9 quedó apuntando a baseUrl en lugar de loginUrl.
- Step 5: Repasar la landing a mano en móvil real o emulado
Abre http://localhost:3000/ en un iPhone (o en el emulador de dispositivo de Safari/Chrome a 390px) y recorre la landing entera de arriba abajo.
Lista de comprobación:
-
El hero no arranca desplazado ni con zoom.
-
Nada se puede arrastrar en horizontal.
-
Las secciones sticky (3 y 4) no se solapan con la siguiente.
-
El nav no tapa el titular del hero.
-
Los dos CTA llevan a
/login. -
Step 6: Verificar con movimiento reducido activado
En macOS: Ajustes → Accesibilidad → Pantalla → Reducir movimiento. En Windows: Configuración → Accesibilidad → Efectos visuales → Efectos de animación (desactivar).
Recarga / y recorre la landing.
Expected: todo el contenido visible y legible, sin animaciones. Las secciones 3 y 4 aparecen como pilas estáticas con todos sus visuales presentes, no como paneles vacíos.
- Step 7: Cierre — dejar registro
Añade a .superpowers/sdd/progress.md una entrada con: qué se implementó, el resultado de cada comando de verificación, y cualquier follow-up diferido. No hagas commit.
Self-Review
Cobertura del spec:
| Requisito del spec | Tarea |
|---|---|
/ sirve la landing; con sesión redirige |
3 (App.tsx), verificado en 2 |
/login enlazable con acceso mágico de un clic |
8, verificado en 2 |
npm run typecheck limpio |
pasos finales de 1, 3, 4, 5, 6, 7, 8; build en 10 |
audit:responsive sin hallazgos nuevos, landing incluida |
9 pasos 2 y 4 |
| Cero scroll horizontal | test de la Tarea 2, comprobación 6; audit en 9 |
prefers-reduced-motion legible y estático |
Tarea 2 comprobaciones 7; ramas reduced en 3-8; manual en 10 paso 6 |
| Chunk de motion no se descarga en el panel | 1 paso 3; verificado en 10 pasos 1-3 |
Landing con React.lazy |
3 paso 1 |
| Ocho piezas de sección, una por archivo | 3, 5, 6, 7 |
| Cinco visuales animados | 3 paso 2 (calendario), 4 (los otros cuatro) |
Tokens .landing en @layer components |
1 paso 7 |
Los siete goto de pwa-e2e |
9 paso 3 |
| Formulario manual visible sin interacción | 8 paso 1; test de la Tarea 2 |
| No commits | Global Constraints; Tarea 10 paso 7 |
Consistencia de nombres verificada: homePathFor, revealUp, revealStagger, inView, EASE_EXPO, baseTransition, useReducedMotion, CalendarGridVisual, RevenueLineVisual, TeamLoadVisual, NotebookVisual, DashboardMockVisual, LandingNav, HeroSection, CostSection, ShiftSection, ProductSection, ProofSection, ObjectionsSection, ClosingSection. Todos se definen en una tarea antes de consumirse.
Atributos de datos — definidos en la Tarea 2 y producidos donde corresponde: data-ld-section (7 secciones: Hero, Cost, Shift, Product, Proof, Objections, Closing), data-ld-hero-title (Tarea 3), data-ld-closing-cta (Tarea 7), data-magic-login + data-role (Tarea 8).
Deuda conocida que este plan deja intacta a propósito: npm run lint sigue roto (ESLint 9 sin config, hueco preexistente), y el modelo de autenticación demo sigue como está. Ninguna de las dos cae en el alcance.