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]>
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
/sirve la landing sin sesión; con sesión redirige al panel según rol./logines una ruta enlazable con acceso mágico de un clic por cuenta demo.npm run typechecklimpio.npm run audit:responsivesin hallazgos nuevos a 390 / 440 / 744 / 1180 px, con la landing incluida en la lista de páginas auditadas.- Cero scroll horizontal en cualquiera de esos anchos.
- Con
prefers-reduced-motion: reducela landing es completamente legible y estática. - El chunk de
framer-motionno 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-54 — 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 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:
- Agenda →
CalendarGridVisualcon una cita arrastrándose. «Tu día completo en una pantalla.» - Inteligencia →
RevenueLineVisualtrazá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) y devuelve las cuentas ordenadas admin → owner → empleados conemail,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
quickLoginya 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"]yinput[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(dvhconvhde fallback), nunca100vh. Una landing full-bleed es exactamente donde100vhfalla 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 breakpointssm:. - Todo
gridcongrid-cols-1explícito. Sin él la columna implícita se dimensiona amax-contenty 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: stickycontopcalculado sobredvh, no sobrevh.
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
- responsive-audit.mjs:264
- pwa-e2e.mjs:129 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 <span> de ancho tabular (font-variant-numeric: tabular-nums) para no relayoutear en cada frame. |