# Landing de funnel con enfoque Business Intelligence + login mágico **Fecha:** 2026-07-28 **Estado:** aprobado para plan **Alcance:** solo frontend (`src/`) + ajuste de tres audits Playwright. No toca servidor, esquema ni API. ## Problema AgendaMax no tiene página de aterrizaje. `/` sirve el formulario de login ([src/App.tsx:34](../../../src/App.tsx#L34) renderiza `` inline cuando no hay sesión), así que un visitante que llega al dominio se topa con una pared de credenciales sin haber leído nunca qué resuelve el producto ni por qué debería importarle. Falta la mitad de arriba del funnel: no hay nada que nombre el dolor, cuantifique su costo, muestre el mecanismo ni empuje a probar. Y el acceso a la demo — que es la conversión real en esta fase — está escondido al pie del login como una lista de cuentas sin jerarquía. ## Objetivo Una landing pública en `/` que venda el ángulo **business intelligence**: el dueño de negocio en LATAM opera a ciegas. El scroll es el vehículo narrativo, no un contenedor de secciones apiladas. Al final, un `/login` rediseñado donde entrar a la demo cuesta un clic. ### Criterios de éxito 1. `/` sirve la landing sin sesión; con sesión redirige al panel según rol. 2. `/login` es una ruta enlazable con acceso mágico de un clic por cuenta demo. 3. `npm run typecheck` limpio. 4. `npm run audit:responsive` sin hallazgos nuevos a 390 / 440 / 744 / 1180 px, con la landing incluida en la lista de páginas auditadas. 5. Cero scroll horizontal en cualquiera de esos anchos. 6. Con `prefers-reduced-motion: reduce` la landing es completamente legible y estática. 7. El chunk de `framer-motion` no se descarga al cargar el panel. ### Fuera de alcance - Pasarela de pago, formulario de registro o captura de leads. La conversión en fase demo es **entrar a la demo**, no registrarse. - Endurecer la autenticación. El token trivial y las contraseñas en claro siguen como están; ver «Seguridad» más abajo. - Modo oscuro para el panel. La landing usa fondos oscuros mediante un scope propio. - Internacionalización. Todo el copy es español de LATAM, como el resto del repo. ## Decisiones tomadas | Decisión | Elegido | Por qué | |---|---|---| | Dolor del hero | «No sabes qué pasa en tu negocio» | Es el ángulo BI puro y es lo que la app ya demuestra: dashboard de ingresos, top empleados, top clientes. | | Librería de animación | `framer-motion` | Orquestación declarativa y física de resorte que no vale la pena reimplementar. Se aísla en su propio chunk para que el panel no lo pague. | | Ruteo | Landing en `/`, login en `/login` | Es lo que espera un visitante. No rompe `/b/:slug` ni las rutas del panel. | | Acceso mágico | Solo en `/login` | Mantiene la landing enfocada en persuadir y concentra toda la mecánica de acceso en un lugar. | ## Arquitectura ### Ruteo ``` / → LandingPage público; con sesión → redirige al panel /login → LoginPage formulario + acceso mágico; con sesión → redirige al panel /b/:slug → BookingPage sin cambios, sigue fuera de AuthProvider /dashboard… → panel protegido sin sesión → ``` `src/App.tsx` se reestructura: `Protected` deja de renderizar `` como fallback y en su lugar el árbol de rutas distingue tres zonas — pública (`/`, `/login`), pública sin auth (`/b/:slug`) y protegida. El splash de carga (`loading === true`) se conserva tal cual. Detalle: el destino de la redirección con sesión es el mismo que hoy calcula [src/App.tsx:43-54](../../../src/App.tsx#L43-L54) — `admin` → `/admin`, `owner` → `/dashboard`, `employee` → `/calendar`. Se extrae a un helper `homePathFor(user)` para no duplicar la regla en tres sitios. ### Code splitting `LandingPage` se monta con `React.lazy` + `Suspense`. Dos razones que van en direcciones opuestas y por eso importan las dos: el usuario con sesión nunca ve la landing y no debe descargarla, y el visitante en la landing no debe descargar FullCalendar ni recharts. `framer-motion` se añade a `manualChunks` en [vite.config.ts:33](../../../vite.config.ts#L33) como chunk `motion`. Sin esto Rollup lo mete en el chunk compartido y los ~50 KB gzip se cobran en cada carga del panel. ### Archivos ``` src/pages/LandingPage.tsx orquestador; solo compone secciones, ~40 líneas src/components/landing/ LandingNav.tsx nav flotante que se condensa al scrollear HeroSection.tsx CostSection.tsx «el costo de no saber» — 4 fugas con contadores ShiftSection.tsx cuaderno → tablero, scrub por scroll ProductSection.tsx 3 actos con panel sticky ProofSection.tsx mock del dashboard real ObjectionsSection.tsx ClosingSection.tsx CTA final + footer visuals/ CalendarGridVisual.tsx retícula que se puebla de citas RevenueLineVisual.tsx línea de ingresos que se traza TeamLoadVisual.tsx carga por especialista NotebookVisual.tsx cuaderno con tachones (lado «antes») DashboardMockVisual.tsx composición del panel a escala src/lib/motion.ts easings y variants compartidos src/lib/useReducedMotion.ts wrapper sobre prefers-reduced-motion ``` Una sección por archivo: cada una recibe cero props, no comparte estado con sus hermanas y se puede leer o rehacer sin abrir las demás. Los visuales viven aparte de las secciones porque son la parte que más va a iterar y no deberían obligar a releer el copy para tocarlos. `src/lib/motion.ts` exporta el contrato de movimiento que todas las secciones consumen: ```ts export const EASE_EXPO = [0.16, 1, 0.3, 1] as const; // ease-out-expo // Estado inicial → visible. `reduced` colapsa el initial al estado final. export const revealUp = (reduced: boolean) => ({ hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }, show: { opacity: 1, y: 0, transition: { duration: 0.7, ease: EASE_EXPO } }, }); // Contenedor que escalona a sus hijos; 0 cuando hay movimiento reducido. export const revealStagger = (reduced: boolean) => ({ hidden: {}, show: { transition: { staggerChildren: reduced ? 0 : 0.08 } }, }); ``` Un solo easing en todo el sitio. Es lo que produce la sensación de que los elementos «pesan algo y frenan solos», y mezclar curvas es lo que hace que una landing se sienta improvisada. ## Criterio visual Cinco reglas, no una lista de efectos: | Regla | Aplicación | |---|---| | Una idea por pantalla | Cada sección ocupa el viewport y afirma *una* cosa. Prohibido el grid de 6 features. | | Tipografía protagonista | Titulares `clamp(2.75rem, 7vw, 6rem)`, `tracking-tight`, peso 700-800. El texto es la imagen. | | Silencio | 120-200px entre secciones. El aire es lo que hace que se lea caro. | | Color contenido | Base `#08080a` y `#fafafa` alternándose a sangre. `brand-500` solo en CTAs y datos. Sin gradientes de relleno. | | El producto es la foto | Cero stock photos. Los visuales son el producto dibujándose: agenda que se llena, línea que se traza, KPI que cuenta. | ### Tokens de la landing El `body` del panel es `#f6f7fb` con `color-scheme: light` ([src/index.css:5-42](../../../src/index.css#L5-L42)). La landing necesita fondos oscuros a sangre sin alterar eso, así que se declara un scope propio en `src/index.css`: ```css @layer components { .landing { --ld-ink: #08080a; /* casi-negro, no negro puro: el negro puro aplana */ --ld-paper: #fafafa; --ld-muted: #6b6b73; /* texto secundario sobre papel */ --ld-muted-dark: #8a8a94; /* texto secundario sobre tinta */ --ld-hairline: rgba(255, 255, 255, 0.09); } .landing-dark { background: var(--ld-ink); color: var(--ld-paper); } .landing-light { background: var(--ld-paper); color: var(--ld-ink); } } ``` Va **dentro** de `@layer components`, que es el default del repo: así cualquier utilidad de Tailwind aplicada en el JSX gana por especificidad de capa y las secciones pueden sobrescribir puntualmente. Solo las reglas que deben ganar a las utilidades salen de la capa, y aquí ninguna lo necesita ([src/index.css:415](../../../src/index.css#L415)). ## Estructura del funnel Orden: *problema → costo → mecanismo → prueba → objeción → cierre*. **1 · Hero** (oscuro) > **Tu negocio te habla todos los días. Nadie está escuchando.** > 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 ↓]` Movimiento: titular por líneas con stagger de 80 ms. Detrás, `CalendarGridVisual` poblándose sola, desenfocada al 20% de opacidad. Indicador de scroll con respiración lenta. **2 · El costo de no saber** (claro) — cuatro fugas, no cuatro features. Contadores que se animan al entrar en viewport: - El 34% de tus horas disponibles se van vacías. - El cliente que no volvió hace cinco meses sigue en tu lista. - Un servicio te está costando más de lo que cobra. - Tu mejor empleado lo sabes por corazonada, no por dato. **3 · El cambio** (oscuro) — sección sticky con scrub por progreso de scroll. `NotebookVisual` con tachones se desvanece mientras el mismo día se reconstruye como tablero. > **No es que trabajes poco. Es que trabajas sin instrumentos.** **4 · El producto en tres actos** (claro) — panel sticky a la derecha, texto scrolleando a la izquierda, visual que cambia por acto: - **Agenda** → `CalendarGridVisual` con una cita arrastrándose. *«Tu día completo en una pantalla.»* - **Inteligencia** → `RevenueLineVisual` trazándose y top clientes ordenándose. *«Los números que tu contador te da en marzo, hoy a las 3 de la tarde.»* - **Equipo** → `TeamLoadVisual`. *«Deja de repartir el trabajo por intuición.»* **5 · Prueba** (claro) — `DashboardMockVisual` a escala, animándose. Sello: *«Estos son datos reales de la cuenta demo. Puedes entrar y moverlos.»* **6 · Objeciones** (claro) — las tres reales de un dueño en LATAM: - *«No soy de tecnología.»* → Si sabes usar WhatsApp, sabes usar esto. - *«Mi equipo no lo va a adoptar.»* → Cada empleado ve solo su día. Nada que aprender. - *«Ya tengo mi cuaderno y me funciona.»* → Tu cuaderno no te dice qué servicio te está costando dinero. **7 · Cierre** (oscuro) — cierra el círculo del hero: > **Deja de adivinar.** > `[Entrar a la demo →]` · Sin registro. Sin tarjeta. Cuentas ya cargadas. **8 · Footer** — logo, «Demo · AgendaMax», enlace a `/login`. ## Login mágico `src/pages/LoginPage.tsx` se rediseña con el lenguaje visual de la landing. El cambio de fondo es que las tarjetas de cuenta pasan a ser el camino **principal**, no un apéndice al pie. - Consume `GET /api/auth/demo-users`, que **ya existe** ([server/routes/auth.ts:24](../../../server/routes/auth.ts#L24)) y devuelve las cuentas ordenadas admin → owner → empleados con `email`, `name`, `role`, `avatar_color`. **No se toca el servidor.** - Se agrupan por rol, rotulando lo que cada uno desbloquea: **Dueña** (tablero completo), **Empleado** (solo su agenda), **Plataforma** (consola multi-negocio). - Un clic entra, reutilizando el `quickLogin` ya presente en [src/pages/LoginPage.tsx:45](../../../src/pages/LoginPage.tsx#L45). - El formulario manual se conserva **visible** bajo un separador, no colapsado tras un toggle. Es la ruta que usan los tres audits Playwright, y esconderlo tras un clic obligaría a añadir un paso de expansión a cada uno. Los selectores `input[type="email"]` y `input[type="password"]` deben existir en el DOM en la carga inicial de `/login`, sin interacción previa. Se conserva la bandera `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`) tal como está hoy. Es una constante de build, así que la contraseña demo se elimina del bundle de producción por dead-code elimination. En local `npm run dev` la activa sola. Cuando `DEMO` es falso, `/login` degrada al formulario manual expandido y sin tarjetas. ## Invariantes de responsividad Ya documentadas en `CLAUDE.md`; romperlas reintroduce bugs conocidos. - Alturas con `.min-h-screen-safe` / `.h-screen-safe` (`dvh` con `vh` de fallback), **nunca** `100vh`. Una landing full-bleed es exactamente donde `100vh` falla en iOS por la barra de URL dinámica. - Campos de formulario a 16px en `pointer: coarse` — ya lo garantiza la regla global de [src/index.css:444](../../../src/index.css#L444); el login no debe declarar tamaños menores. - CTAs con 44px de alto mínimo; los decide `pointer: coarse`, no breakpoints `sm:`. - Todo `grid` con `grid-cols-1` explícito. Sin él la columna implícita se dimensiona a `max-content` y desborda — es la causa exacta del comentario en [src/pages/LoginPage.tsx:63-66](../../../src/pages/LoginPage.tsx#L63-L66). - Visuales anchos scrollean dentro de su contenedor con `overflow-x: auto`; la página nunca. - Secciones sticky: `position: sticky` con `top` calculado sobre `dvh`, no sobre `vh`. ## Movimiento reducido `@media (prefers-reduced-motion: reduce)` y el hook `useReducedMotion` deben dejar la landing estática **y completa**. El riesgo concreto es un elemento con `initial={{ opacity: 0 }}` que nunca anima y queda invisible: el contenido desaparecería para quien tiene la preferencia activada. La regla es que con movimiento reducido los `initial` se resuelven al estado final, no que las transiciones se acorten. Las secciones con scrub por scroll (3 y 4) degradan a su estado final estático, con los visuales apilados en vez de intercambiados. ## Verificación | Comprobación | Comando | |---|---| | Gate de calidad del repo | `npm run typecheck` | | Responsividad | `npm run audit:responsive` (requiere `npm run dev`) | | Capturas | `npm run audit:visual` | | API sin regresión | `npm run test:e2e`, `npm run test:admin`, `npm run test:booking` | ### Audits que se rompen y hay que arreglar Tres scripts hacen `goto('/')` y a continuación `fill('input[type="email"]')`. Con `/` sirviendo la landing ese `fill` falla. Deben apuntar a `/login`: - [visual-audit.mjs:44](../../../visual-audit.mjs#L44) - [responsive-audit.mjs:264](../../../responsive-audit.mjs#L264) - [pwa-e2e.mjs:129](../../../pwa-e2e.mjs#L129) junto con los `goto(baseUrl)` de las líneas 187-288, que ahora aterrizan en la landing en lugar del login. El arreglo es un cambio de URL, no de flujo: el formulario manual sigue visible sin interacción previa en `/login`, así que los `fill` existentes funcionan tal cual. Es exactamente la razón por la que el spec prohíbe colapsarlo. Caso aparte en `pwa-e2e.mjs`: el prompt de instalación PWA se probaba sobre `/`. Ahora `/` es la landing, que no monta `InstallAppPrompt` (vive en el login y dentro del panel). Esos casos apuntan a `/login`. Además, la landing se añade a la lista de páginas de `responsive-audit.mjs` y `visual-audit.mjs` como página pública, sin paso de login. Los tests de API (`test:e2e`, `test:admin`, `test:booking`, `test:unit`) no se tocan: golpean `/api` directo y no dependen del ruteo del cliente. ## Seguridad La landing no cambia el modelo de seguridad, pero conviene dejarlo escrito porque el cambio lo hace más visible: `/login` expone contraseñas de cuentas **existentes** en el cliente. Está autorizado explícitamente para esta fase demo y ya es el comportamiento actual del repo. La bandera `DEMO` es lo único que separa eso de una fuga de credenciales si el proyecto se publica en un dominio real. Antes de cualquier despliegue no-demo hace falta lo que ya lista `CLAUDE.md`: hashing, JWT firmados, validación estricta y rate-limiting. ## Riesgos | Riesgo | Mitigación | |---|---| | `framer-motion` se filtra al bundle del panel | Chunk manual `motion` + `React.lazy` en la landing. Verificar en la salida de `npm run build` que el chunk existe y que el panel no lo importa. | | Las secciones sticky descuadran en iPad portrait (1024px) | El repo ya tiene el conflicto documentado entre el corte tablet/desktop y el `lg:` de Tailwind. La landing usa un layout de una columna por debajo de 1024px, evitando el borde. | | `prefers-reduced-motion` deja contenido invisible | Los `initial` resuelven al estado final; se verifica emulando la preferencia en el audit. | | Los contadores animados disparan reflow en móvil | Se animan con `transform`/`opacity` y el número se interpola en un `` de ancho tabular (`font-variant-numeric: tabular-nums`) para no relayoutear en cada frame. |