# 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](../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`, nunca `npx tsx` directo. - **Windows/PowerShell:** usa `npm.cmd` si `npm.ps1` está bloqueado por execution policy. - **Instalar con `npm run setup`** (`npm ci`, caché en `.cache/npm`). La única excepción de este plan es `npm install framer-motion@^11` en la Tarea 1, que sí debe mutar `package.json` y el lockfile. - **NO HAGAS COMMITS.** Política del repo registrada en `.superpowers/sdd/progress.md` y en `CLAUDE.md`. La verificación por tarea es `npm run typecheck` + tests, nunca un commit. Los pasos de este plan terminan en verificación, no en `git commit`. - **`npm run lint` está roto** (ESLint 9 sin `eslint.config.js`). No lo uses como señal. El gate real es `npm 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 declaran `vh` como fallback y `dvh` encima. `100vh` en 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)` en `src/index.css`; no la anules con utilidades `text-sm` en inputs. - **Objetivos táctiles por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja fuera a las tablets, que también se tocan. - **Todo `grid` con `grid-cols-1` explícito.** Sin él la columna implícita se dimensiona a `max-content` y desborda en horizontal. - **`prefers-reduced-motion: reduce` resuelve los `initial` al estado final**, no acorta transiciones. Un `initial={{opacity:0}}` que nunca anima deja el contenido invisible. - **Easing único en toda la landing:** `cubic-bezier(0.16, 1, 0.3, 1)`, exportado como `EASE_EXPO`. - **Puertos:** `dev` usa `:3000` (API) y `:5173` (Vite, con proxy `/api`). Los audits apuntan a `:5173` excepto `pwa-e2e.mjs`, que apunta a `:3000` sobre 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 + script `test: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 components` existente y una regla fuera de capa) **Interfaces:** - Consumes: nada. - Produces: - `EASE_EXPO: readonly [number, number, number, number]` - `baseTransition: Transition` - `revealUp(reduced: boolean, distance?: number): Variants` - `revealStagger(reduced: boolean, stagger?: number): Variants` - `inView: { 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. ```bash npm.cmd install framer-motion@^11 ``` - [ ] **Step 2: Verificar que quedó en `dependencies`, no en `devDependencies`** Run: `npm.cmd ls framer-motion --depth=0` Expected: imprime `framer-motion@11.x.x` 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 `motion` en `vite.config.ts`** En el objeto `manualChunks` (líneas 33-45), añade la entrada `motion` después de `icons`: ```ts 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:landing` en `package.json`** En `scripts`, justo después de `"test:booking"`: ```json "test:landing": "node landing-e2e.mjs", ``` - [ ] **Step 5: Crear `src/lib/motion.ts`** ```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`** ```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: ```css /* ---- 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`: ```css /* 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:landing` de `package.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]` — el `h1` del 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** ```js // 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 = "owner@agendamax.demo"; 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: ```bash 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_EXPO` de `src/lib/motion.ts`; `useReducedMotion` de `src/lib/useReducedMotion.ts`. - Produces: - `homePathFor(user: User): string` exportado desde `src/App.tsx`. - `LandingPage` como **export default** de `src/pages/LandingPage.tsx`. - `HeroSection` (named export), cero props. - `CalendarGridVisual` (named export) con props `{ className?: string; density?: number }`. - [ ] **Step 1: Reescribir `src/App.tsx`** ```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 (
Cargando AgendaMax…
); } function AppRoutes() { const { user, loading } = useAuth(); if (loading) return ; const home = user ? homePathFor(user) : "/login"; const isAdmin = user?.role === "admin"; return ( {/* ---- Públicas ---- */} ) : ( }> ) } /> : } /> {/* Sin sesión, cualquier otra ruta manda al login. */} {!user && } />} {/* ---- Admin de plataforma ---- */} {user && isAdmin && ( }> } /> } /> } /> } /> )} {/* ---- Negocio ---- */} {user && !isAdmin && ( }> {user.role === "owner" ? ( } /> ) : ( } /> )} } /> } /> } /> } /> {user.role === "owner" && } />} {user.role === "owner" && } />} {user.role === "owner" && } />} {user.role === "owner" && } />} {user.role === "owner" && } />} {user.role === "owner" && } />} {/* La autorización real vive en los middlewares del servidor; esto es UX. */} } /> } /> )} ); } export default function App() { return ( {/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */} } /> } /> ); } ``` - [ ] **Step 2: Crear `src/components/landing/visuals/CalendarGridVisual.tsx`** ```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 ( ); } ``` - [ ] **Step 3: Crear `src/components/landing/HeroSection.tsx`** ```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 (
{/* 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. */}
Business intelligence para tu negocio

{LINEAS.map((linea, i) => ( {linea} ))}

Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los convierte en decisiones que te dejan dinero. Entrar a la demo Ver cómo funciona Sin registro. Sin tarjeta. Cuentas ya cargadas.
{/* Indicador de scroll con respiración lenta. Se apaga con movimiento reducido. */} {!reduced && ( )}
); } ``` - [ ] **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. ```tsx 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 (
); } ``` - [ ] **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`, `inView` de `src/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`** ```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 ( {/* Rejilla base: cuatro guías, muy tenues. Dan escala sin competir. */} {[0.25, 0.5, 0.75].map((f) => ( ))} {/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */} ); } ``` - [ ] **Step 2: Crear `TeamLoadVisual.tsx`** ```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 (
{EQUIPO.map((p, i) => (
{p.nombre} {p.citas} citas
))}
); } ``` 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`** ```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 (