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]>
310 lines
16 KiB
Markdown
310 lines
16 KiB
Markdown
# 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. |
|