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]>
This commit is contained in:
AgendaPro Dev
2026-07-28 14:25:58 -06:00
co-authored by Claude Opus 5
parent 0b466f33f3
commit 4c19244df9
53 changed files with 7310 additions and 525 deletions
@@ -0,0 +1,309 @@
# 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 `<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-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 `<span>` de ancho tabular (`font-variant-numeric: tabular-nums`) para no relayoutear en cada frame. |