Files
AgendaPro/docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

16 KiB

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 renderiza <LoginPage/> 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 → <Navigate to="/login" replace/>

src/App.tsx se reestructura: Protected deja de renderizar <LoginPage/> 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-54admin/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 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:

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). La landing necesita fondos oscuros a sangre sin alterar eso, así que se declara un scope propio en src/index.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).

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:

  • AgendaCalendarGridVisual con una cita arrastrándose. «Tu día completo en una pantalla.»
  • InteligenciaRevenueLineVisual trazándose y top clientes ordenándose. «Los números que tu contador te da en marzo, hoy a las 3 de la tarde.»
  • EquipoTeamLoadVisual. «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) 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.
  • 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; 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.
  • 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:

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 <span> de ancho tabular (font-variant-numeric: tabular-nums) para no relayoutear en cada frame.