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]>
2621 lines
99 KiB
Markdown
2621 lines
99 KiB
Markdown
# Landing de funnel BI + login mágico — Plan de implementación
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Convertir `/` en una landing pública de funnel con enfoque business intelligence y estética Apple, moviendo el login a `/login` con acceso mágico de un clic a las cuentas demo.
|
|
|
|
**Architecture:** La landing es una ruta pública nueva, cargada con `React.lazy`, compuesta por ocho secciones independientes de cero props. `framer-motion` entra como dependencia aislada en su propio chunk de Rollup para que el panel no la pague. El login se rediseña reutilizando el endpoint `/api/auth/demo-users` que ya existe — no se toca el servidor.
|
|
|
|
**Tech Stack:** React 18, TypeScript, Vite 5, Tailwind 3, react-router-dom 6, framer-motion 11, Playwright (audits).
|
|
|
|
**Spec:** [docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md](../specs/2026-07-28-landing-funnel-bi-design.md)
|
|
|
|
## Global Constraints
|
|
|
|
- **Node.js >= 22.5.** Todo script de servidor pasa por `scripts/run-tsx.mjs`, nunca `npx tsx` directo.
|
|
- **Windows/PowerShell:** usa `npm.cmd` si `npm.ps1` está bloqueado por execution policy.
|
|
- **Instalar con `npm run setup`** (`npm ci`, caché en `.cache/npm`). La única excepción de este plan es `npm install framer-motion@^11` en la Tarea 1, que sí debe mutar `package.json` y el lockfile.
|
|
- **NO HAGAS COMMITS.** Política del repo registrada en `.superpowers/sdd/progress.md` y en `CLAUDE.md`. La verificación por tarea es `npm run typecheck` + tests, nunca un commit. Los pasos de este plan terminan en verificación, no en `git commit`.
|
|
- **`npm run lint` está roto** (ESLint 9 sin `eslint.config.js`). No lo uses como señal. El gate real es `npm run typecheck`.
|
|
- **Idioma:** todo string visible al usuario en español de LATAM. Los comentarios de código, en español.
|
|
- **Nunca `100vh`.** Usa `.min-h-screen-safe` / `.h-screen-safe` / `.max-h-screen-safe`, que declaran `vh` como fallback y `dvh` encima. `100vh` en iOS no descuenta la barra de URL dinámica.
|
|
- **Ningún campo de formulario por debajo de 16px en táctil.** Lo garantiza la regla global de `@media (pointer: coarse)` en `src/index.css`; no la anules con utilidades `text-sm` en inputs.
|
|
- **Objetivos táctiles por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja fuera a las tablets, que también se tocan.
|
|
- **Todo `grid` con `grid-cols-1` explícito.** Sin él la columna implícita se dimensiona a `max-content` y desborda en horizontal.
|
|
- **`prefers-reduced-motion: reduce` resuelve los `initial` al estado final**, no acorta transiciones. Un `initial={{opacity:0}}` que nunca anima deja el contenido invisible.
|
|
- **Easing único en toda la landing:** `cubic-bezier(0.16, 1, 0.3, 1)`, exportado como `EASE_EXPO`.
|
|
- **Puertos:** `dev` usa `:3000` (API) y `:5173` (Vite, con proxy `/api`). Los audits apuntan a `:5173` excepto `pwa-e2e.mjs`, que apunta a `:3000` sobre el build.
|
|
|
|
## Mapa de archivos
|
|
|
|
| Archivo | Responsabilidad |
|
|
|---|---|
|
|
| `src/lib/motion.ts` | **Crear.** Easing y variants compartidos. Único lugar donde se define la curva. |
|
|
| `src/lib/useReducedMotion.ts` | **Crear.** Hook booleano sobre `prefers-reduced-motion`, reactivo a cambios en vivo. |
|
|
| `src/index.css` | **Modificar.** Añade el scope `.landing` con sus tokens y las utilidades tipográficas `.ld-*`. |
|
|
| `vite.config.ts` | **Modificar.** Chunk manual `motion`. |
|
|
| `src/App.tsx` | **Modificar.** Rutas públicas `/` y `/login`, helper `homePathFor`, `React.lazy` de la landing. |
|
|
| `src/pages/LandingPage.tsx` | **Crear.** Orquestador. Solo compone secciones. Export **default** (lo exige `lazy`). |
|
|
| `src/components/landing/LandingNav.tsx` | **Crear.** Nav flotante que se condensa al scrollear. |
|
|
| `src/components/landing/HeroSection.tsx` | **Crear.** Sección 1. |
|
|
| `src/components/landing/CostSection.tsx` | **Crear.** Sección 2, con contadores animados. |
|
|
| `src/components/landing/ShiftSection.tsx` | **Crear.** Sección 3, sticky con scrub por scroll. |
|
|
| `src/components/landing/ProductSection.tsx` | **Crear.** Sección 4, tres actos con panel sticky. |
|
|
| `src/components/landing/ProofSection.tsx` | **Crear.** Sección 5. |
|
|
| `src/components/landing/ObjectionsSection.tsx` | **Crear.** Sección 6. |
|
|
| `src/components/landing/ClosingSection.tsx` | **Crear.** Sección 7 + footer. |
|
|
| `src/components/landing/visuals/CalendarGridVisual.tsx` | **Crear.** Retícula que se puebla de citas. |
|
|
| `src/components/landing/visuals/RevenueLineVisual.tsx` | **Crear.** Línea de ingresos que se traza. |
|
|
| `src/components/landing/visuals/TeamLoadVisual.tsx` | **Crear.** Barras de carga por especialista. |
|
|
| `src/components/landing/visuals/NotebookVisual.tsx` | **Crear.** Cuaderno con tachones. |
|
|
| `src/components/landing/visuals/DashboardMockVisual.tsx` | **Crear.** Composición del panel a escala. |
|
|
| `src/pages/LoginPage.tsx` | **Modificar.** Rediseño con acceso mágico como camino principal. |
|
|
| `landing-e2e.mjs` | **Crear.** Test de aceptación Playwright. |
|
|
| `package.json` | **Modificar.** `framer-motion` + script `test:landing`. |
|
|
| `visual-audit.mjs` | **Modificar.** `/` → `/login`; añade la landing a `PAGES`. |
|
|
| `responsive-audit.mjs` | **Modificar.** `/` → `/login`; añade la landing a `PAGES`. |
|
|
| `pwa-e2e.mjs` | **Modificar.** Los siete `goto(baseUrl)` que esperan el login pasan a `url("/login")`. |
|
|
|
|
**No se toca:** `server/`, `shared/types.ts`, el esquema, las migraciones, `public/sw.js`, ni los tests de API (`e2e-test.mjs`, `admin-test.mjs`, `server/scripts/booking-e2e.mjs`, `server/lib/*.test.ts`).
|
|
|
|
---
|
|
|
|
### Task 1: Fundamentos de movimiento y tokens de la landing
|
|
|
|
**Files:**
|
|
- Modify: `package.json` (dependencias + script `test:landing`)
|
|
- Modify: `vite.config.ts:33-45` (manualChunks)
|
|
- Create: `src/lib/motion.ts`
|
|
- Create: `src/lib/useReducedMotion.ts`
|
|
- Modify: `src/index.css` (añadir al final del `@layer components` existente y una regla fuera de capa)
|
|
|
|
**Interfaces:**
|
|
- Consumes: nada.
|
|
- Produces:
|
|
- `EASE_EXPO: readonly [number, number, number, number]`
|
|
- `baseTransition: Transition`
|
|
- `revealUp(reduced: boolean, distance?: number): Variants`
|
|
- `revealStagger(reduced: boolean, stagger?: number): Variants`
|
|
- `inView: { once: true; margin: string }`
|
|
- `useReducedMotion(): boolean`
|
|
- Clases CSS: `.landing`, `.landing-dark`, `.landing-light`, `.ld-display`, `.ld-lead`, `.ld-eyebrow`, `.ld-section`, `.ld-wrap`, `.ld-num`
|
|
|
|
- [ ] **Step 1: Instalar framer-motion**
|
|
|
|
Este es el único paso del plan que muta `package.json` y el lockfile a propósito.
|
|
|
|
```bash
|
|
npm.cmd install framer-motion@^11
|
|
```
|
|
|
|
- [ ] **Step 2: Verificar que quedó en `dependencies`, no en `devDependencies`**
|
|
|
|
Run: `npm.cmd ls framer-motion --depth=0`
|
|
Expected: imprime `[email protected]` sin la marca `(dev)`. Si cayó en devDependencies, muévelo a mano en `package.json` y repite `npm.cmd install`.
|
|
|
|
- [ ] **Step 3: Añadir el chunk manual `motion` en `vite.config.ts`**
|
|
|
|
En el objeto `manualChunks` (líneas 33-45), añade la entrada `motion` después de `icons`:
|
|
|
|
```ts
|
|
manualChunks: {
|
|
react: ["react", "react-dom", "react-router-dom"],
|
|
calendar: [
|
|
"@fullcalendar/core",
|
|
"@fullcalendar/react",
|
|
"@fullcalendar/daygrid",
|
|
"@fullcalendar/timegrid",
|
|
"@fullcalendar/interaction",
|
|
],
|
|
charts: ["recharts"],
|
|
query: ["@tanstack/react-query"],
|
|
icons: ["lucide-react"],
|
|
// La landing es la única que anima. Sin este chunk, Rollup mete
|
|
// framer-motion en el bundle compartido y sus ~50 KB gzip se cobran en
|
|
// cada carga del panel, que nunca lo usa.
|
|
motion: ["framer-motion"],
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 4: Añadir el script `test:landing` en `package.json`**
|
|
|
|
En `scripts`, justo después de `"test:booking"`:
|
|
|
|
```json
|
|
"test:landing": "node landing-e2e.mjs",
|
|
```
|
|
|
|
- [ ] **Step 5: Crear `src/lib/motion.ts`**
|
|
|
|
```ts
|
|
import type { Transition, Variants } from "framer-motion";
|
|
|
|
/**
|
|
* ease-out-expo. Un solo easing en toda la landing: es lo que produce la
|
|
* sensación de que los elementos "pesan algo y frenan solos", y mezclar curvas
|
|
* es exactamente lo que hace que una landing se sienta improvisada.
|
|
*/
|
|
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const;
|
|
|
|
export const baseTransition: Transition = { duration: 0.7, ease: EASE_EXPO };
|
|
|
|
/**
|
|
* Reveal vertical. `reduced` colapsa el estado inicial al final: NO acorta la
|
|
* transición, la elimina. Si `hidden` dejara `opacity: 0` cuando el usuario pide
|
|
* menos movimiento, el contenido quedaría invisible para siempre porque la
|
|
* animación que lo revela nunca se dispara.
|
|
*/
|
|
export function revealUp(reduced: boolean, distance = 24): Variants {
|
|
return {
|
|
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: distance },
|
|
show: { opacity: 1, y: 0, transition: baseTransition },
|
|
};
|
|
}
|
|
|
|
/** Contenedor que escalona a sus hijos. Con movimiento reducido, stagger 0. */
|
|
export function revealStagger(reduced: boolean, stagger = 0.08): Variants {
|
|
return {
|
|
hidden: {},
|
|
show: { transition: { staggerChildren: reduced ? 0 : stagger } },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Props de viewport compartidas para `whileInView`. Una sola pasada (`once`), con
|
|
* margen negativo para que el reveal arranque cuando la sección ya entró de
|
|
* verdad y no en el instante en que su borde roza la pantalla.
|
|
*/
|
|
export const inView = { once: true, margin: "-12% 0px -12% 0px" } as const;
|
|
```
|
|
|
|
- [ ] **Step 6: Crear `src/lib/useReducedMotion.ts`**
|
|
|
|
```ts
|
|
import { useEffect, useState } from "react";
|
|
|
|
const QUERY = "(prefers-reduced-motion: reduce)";
|
|
|
|
/**
|
|
* `true` cuando el sistema pide menos movimiento. Se suscribe a los cambios: en
|
|
* iOS la preferencia se activa desde Ajustes sin recargar la pestaña, así que
|
|
* leerla una sola vez al montar dejaría la landing animando de todas formas.
|
|
*/
|
|
export function useReducedMotion(): boolean {
|
|
const [reduced, setReduced] = useState(() => window.matchMedia(QUERY).matches);
|
|
|
|
useEffect(() => {
|
|
const mq = window.matchMedia(QUERY);
|
|
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
mq.addEventListener("change", onChange);
|
|
// Resincroniza: la preferencia pudo cambiar entre el render inicial y el efecto.
|
|
setReduced(mq.matches);
|
|
return () => mq.removeEventListener("change", onChange);
|
|
}, []);
|
|
|
|
return reduced;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Añadir los tokens de la landing en `src/index.css`**
|
|
|
|
Dentro del `@layer components` que ya existe (empieza en la línea 415), **después** del bloque `.input, .select, .textarea`, añade:
|
|
|
|
```css
|
|
/* ---- Landing pública ----
|
|
Scope propio porque el body del panel es #f6f7fb con color-scheme: light y la
|
|
landing necesita secciones oscuras a sangre sin alterar el panel. Va DENTRO de
|
|
la capa (el default del repo) para que cualquier utilidad de Tailwind aplicada
|
|
en el JSX gane por especificidad de capa. */
|
|
.landing {
|
|
--ld-ink: #08080a; /* casi-negro: el negro puro aplana y delata el gradiente */
|
|
--ld-paper: #fafafa;
|
|
--ld-muted: #6b6b73; /* secundario sobre papel */
|
|
--ld-muted-dark: #8a8a94; /* secundario sobre tinta */
|
|
--ld-hairline: rgba(255, 255, 255, 0.09);
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
overflow-x: clip;
|
|
}
|
|
.landing-dark {
|
|
background: var(--ld-ink);
|
|
color: var(--ld-paper);
|
|
}
|
|
.landing-light {
|
|
background: var(--ld-paper);
|
|
color: var(--ld-ink);
|
|
}
|
|
/* Escala fluida sin media queries: clamp() con vw evita los saltos entre
|
|
breakpoints que delatan una landing armada con utilidades sueltas. */
|
|
.ld-display {
|
|
font-size: clamp(2.5rem, 7.5vw, 5.75rem);
|
|
line-height: 0.98;
|
|
letter-spacing: -0.035em;
|
|
font-weight: 800;
|
|
}
|
|
.ld-title {
|
|
font-size: clamp(1.875rem, 4.5vw, 3.25rem);
|
|
line-height: 1.05;
|
|
letter-spacing: -0.025em;
|
|
font-weight: 800;
|
|
}
|
|
.ld-lead {
|
|
font-size: clamp(1.0625rem, 2.1vw, 1.375rem);
|
|
line-height: 1.5;
|
|
}
|
|
.ld-eyebrow {
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
/* El padding vertical ES el "silencio" del criterio Apple: sin él, ninguna
|
|
cantidad de tipografía grande hace que se lea caro. */
|
|
.ld-section {
|
|
padding-block: clamp(5rem, 12vh, 11rem);
|
|
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
|
|
}
|
|
.ld-wrap {
|
|
width: 100%;
|
|
max-width: 72rem;
|
|
margin-inline: auto;
|
|
}
|
|
/* Números que se interpolan frame a frame. Sin tabular-nums cada dígito cambia
|
|
de ancho y la línea entera relayoutea en cada frame. */
|
|
.ld-num {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Permitir que la landing crezca más allá de `#root`**
|
|
|
|
`html, body, #root` están fijados a `height: 100%` (líneas 26-30) para el shell de altura fija del panel. La landing es mucho más alta que el viewport. Añade **fuera** de `@layer`, justo después del bloque `html, body, #root`:
|
|
|
|
```css
|
|
/* El shell del panel vive en un #root de altura fija; la landing, no. Se relaja
|
|
solo cuando la landing está montada, así el panel conserva su altura fija.
|
|
:has() está soportado en Safari 15.4+, dentro del rango de iOS objetivo. */
|
|
body:has(.landing) #root {
|
|
height: auto;
|
|
min-height: 100%;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Verificar que compila**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores. `motion.ts` y `useReducedMotion.ts` aún no tienen consumidores, así que solo se valida que los tipos de `framer-motion` resuelven.
|
|
|
|
---
|
|
|
|
### Task 2: Test de aceptación (debe fallar)
|
|
|
|
Escribe primero el test que define «hecho». Debe fallar ahora y quedar verde al final de la Tarea 9.
|
|
|
|
**Files:**
|
|
- Create: `landing-e2e.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `test:landing` de `package.json` (Tarea 1, paso 4).
|
|
- Produces: contrato de atributos de datos que las tareas 3-9 **deben** cumplir:
|
|
- `[data-ld-section]` — uno por sección de la landing; deben ser 7.
|
|
- `[data-ld-hero-title]` — el `h1` del hero.
|
|
- `[data-ld-closing-cta]` — el CTA de la sección de cierre.
|
|
- `[data-magic-login]` — una por tarjeta de cuenta demo en `/login`.
|
|
- `[data-magic-login][data-role="owner"]` — la tarjeta que entra como dueña.
|
|
|
|
- [ ] **Step 1: Escribir el test**
|
|
|
|
```js
|
|
// landing-e2e.mjs — aceptación de la landing pública y del acceso mágico.
|
|
//
|
|
// Cubre lo que ningún test existente cubre: que `/` sirva la landing en lugar del
|
|
// login, que `/login` siga siendo alcanzable con su formulario sin interacción
|
|
// previa (de eso dependen los otros tres audits), que el acceso mágico entre de un
|
|
// clic, y que la landing no desborde en horizontal ni esconda contenido cuando el
|
|
// sistema pide menos movimiento.
|
|
//
|
|
// Uso: npm run test:landing (requiere `npm run dev` corriendo)
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
const BASE = process.env.LANDING_BASE_URL || "http://localhost:5173";
|
|
const OWNER_EMAIL = "[email protected]";
|
|
const PASSWORD = "demo1234";
|
|
const T = 20000;
|
|
|
|
async function pickBrowser() {
|
|
const pw = await import("playwright");
|
|
for (const name of ["webkit", "chromium"]) {
|
|
try {
|
|
return { browser: await pw[name].launch(), engine: name };
|
|
} catch {
|
|
/* siguiente motor */
|
|
}
|
|
}
|
|
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
|
|
}
|
|
|
|
const passed = [];
|
|
|
|
async function check(name, fn) {
|
|
await fn();
|
|
passed.push(name);
|
|
console.log(` ok ${name}`);
|
|
}
|
|
|
|
async function main() {
|
|
const { browser, engine } = await pickBrowser();
|
|
console.log(`Motor: ${engine}\nBase: ${BASE}\n`);
|
|
|
|
try {
|
|
// ---- 1. `/` sirve la landing, no el login ----
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
ctx.setDefaultTimeout(T);
|
|
ctx.setDefaultNavigationTimeout(T);
|
|
const page = await ctx.newPage();
|
|
const pageErrors = [];
|
|
page.on("pageerror", (e) => pageErrors.push(e.message));
|
|
|
|
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
|
|
|
await check("`/` monta la landing", async () => {
|
|
await page.waitForSelector("[data-ld-hero-title]", { state: "visible" });
|
|
});
|
|
|
|
await check("`/` no expone el formulario de login", async () => {
|
|
assert.equal(await page.locator('input[type="email"]').count(), 0);
|
|
});
|
|
|
|
await check("la landing tiene las 7 secciones del funnel", async () => {
|
|
assert.equal(await page.locator("[data-ld-section]").count(), 7);
|
|
});
|
|
|
|
await check("la landing no lanza errores de runtime", async () => {
|
|
assert.deepEqual(pageErrors, []);
|
|
});
|
|
|
|
// ---- 2. `/login` conserva el formulario sin interacción previa ----
|
|
// Es un requisito duro: visual-audit, responsive-audit y pwa-e2e rellenan
|
|
// estos campos directo tras el goto. Colapsarlos rompería los tres.
|
|
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
|
|
|
await check("`/login` muestra el formulario manual sin abrir nada", async () => {
|
|
await page.waitForSelector('input[type="email"]', { state: "visible" });
|
|
await page.waitForSelector('input[type="password"]', { state: "visible" });
|
|
await page.waitForSelector('button[type="submit"]', { state: "visible" });
|
|
});
|
|
|
|
await check("`/login` ofrece al menos 3 cuentas de acceso mágico", async () => {
|
|
const n = await page.locator("[data-magic-login]").count();
|
|
assert.ok(n >= 3, `esperaba >= 3 tarjetas, encontré ${n}`);
|
|
});
|
|
|
|
// ---- 3. El acceso mágico entra de un clic ----
|
|
await check("un clic en la tarjeta de dueña entra al panel", async () => {
|
|
await page.locator('[data-magic-login][data-role="owner"]').first().click();
|
|
await page.waitForURL(/\/dashboard/, { timeout: T });
|
|
});
|
|
|
|
// ---- 4. Con sesión, `/` redirige al panel ----
|
|
await check("`/` redirige al panel cuando hay sesión", async () => {
|
|
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
|
await page.waitForURL(/\/dashboard/, { timeout: T });
|
|
});
|
|
|
|
const authed = await ctx.storageState();
|
|
await ctx.close();
|
|
|
|
// ---- 5. Sin sesión, una ruta protegida manda al login ----
|
|
const anon = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
anon.setDefaultTimeout(T);
|
|
anon.setDefaultNavigationTimeout(T);
|
|
const anonPage = await anon.newPage();
|
|
await check("`/dashboard` sin sesión redirige a `/login`", async () => {
|
|
await anonPage.goto(`${BASE}/dashboard`, { waitUntil: "networkidle" });
|
|
await anonPage.waitForURL(/\/login/, { timeout: T });
|
|
});
|
|
await anon.close();
|
|
|
|
// ---- 6. Sin desborde horizontal en el teléfono más estrecho del objetivo ----
|
|
const phone = await browser.newContext({
|
|
viewport: { width: 390, height: 844 },
|
|
deviceScaleFactor: 3,
|
|
hasTouch: true,
|
|
});
|
|
phone.setDefaultTimeout(T);
|
|
phone.setDefaultNavigationTimeout(T);
|
|
const phonePage = await phone.newPage();
|
|
await phonePage.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
|
await phonePage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
|
|
|
|
await check("la landing no desborda en horizontal a 390px", async () => {
|
|
// Se recorre toda la página: las secciones sticky y los visuales anchos solo
|
|
// desbordan una vez que entran en viewport y arrancan su animación.
|
|
const overflow = await phonePage.evaluate(async () => {
|
|
const doc = document.documentElement;
|
|
let worst = 0;
|
|
const steps = Math.ceil(doc.scrollHeight / window.innerHeight) + 1;
|
|
for (let i = 0; i < steps; i += 1) {
|
|
window.scrollTo(0, i * window.innerHeight);
|
|
await new Promise((r) => setTimeout(r, 350));
|
|
worst = Math.max(worst, Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth);
|
|
}
|
|
return worst;
|
|
});
|
|
assert.ok(overflow <= 1, `desborde de ${overflow}px`);
|
|
});
|
|
await phone.close();
|
|
|
|
// ---- 7. Movimiento reducido no esconde contenido ----
|
|
// El fallo que se busca: un initial={{opacity:0}} cuyo whileInView nunca corre
|
|
// deja el bloque invisible de forma permanente.
|
|
const calm = await browser.newContext({
|
|
viewport: { width: 1280, height: 900 },
|
|
reducedMotion: "reduce",
|
|
});
|
|
calm.setDefaultTimeout(T);
|
|
calm.setDefaultNavigationTimeout(T);
|
|
const calmPage = await calm.newPage();
|
|
await calmPage.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
|
|
|
await check("con movimiento reducido el hero es visible", async () => {
|
|
const o = await calmPage
|
|
.locator("[data-ld-hero-title]")
|
|
.evaluate((el) => Number(getComputedStyle(el).opacity));
|
|
assert.ok(o >= 0.99, `opacity del hero = ${o}`);
|
|
});
|
|
|
|
await check("con movimiento reducido el CTA de cierre es visible", async () => {
|
|
const cta = calmPage.locator("[data-ld-closing-cta]");
|
|
await cta.scrollIntoViewIfNeeded();
|
|
const o = await cta.evaluate((el) => Number(getComputedStyle(el).opacity));
|
|
assert.ok(o >= 0.99, `opacity del CTA de cierre = ${o}`);
|
|
});
|
|
|
|
await check("con movimiento reducido todas las secciones son visibles", async () => {
|
|
const hidden = await calmPage.evaluate(async () => {
|
|
const out = [];
|
|
const nodes = [...document.querySelectorAll("[data-ld-section]")];
|
|
for (const [i, el] of nodes.entries()) {
|
|
el.scrollIntoView();
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
const invisibles = [...el.querySelectorAll("*")].filter(
|
|
(n) => Number(getComputedStyle(n).opacity) < 0.05 && n.textContent?.trim()
|
|
);
|
|
if (invisibles.length) out.push({ section: i, count: invisibles.length });
|
|
}
|
|
return out;
|
|
});
|
|
assert.deepEqual(hidden, []);
|
|
});
|
|
|
|
await calm.close();
|
|
|
|
console.log(`\n${passed.length} comprobaciones OK`);
|
|
void authed;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`\nlanding-e2e falló: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Arrancar el entorno de desarrollo**
|
|
|
|
En una terminal aparte:
|
|
|
|
```bash
|
|
npm.cmd run dev
|
|
```
|
|
|
|
Espera a ver `SERVER` escuchando en `:3000` y `WEB` en `:5173`. Si un puerto está ocupado, el preflight aborta; usa `$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev` y exporta `LANDING_BASE_URL=http://localhost:5174`.
|
|
|
|
- [ ] **Step 3: Correr el test y confirmar que falla**
|
|
|
|
Run: `npm.cmd run test:landing`
|
|
Expected: FALLA en la primera comprobación, con un timeout esperando `[data-ld-hero-title]`. `/` sigue sirviendo el login, así que ese selector no existe. Ese fallo es la señal correcta de partida.
|
|
|
|
---
|
|
|
|
### Task 3: Ruteo público y landing mínima con hero
|
|
|
|
Al terminar esta tarea deben pasar las comprobaciones 1, 2 (parcial), 4, 5 y 7 del test. Las secciones aún no son 7, así que esa sigue roja.
|
|
|
|
**Files:**
|
|
- Modify: `src/App.tsx` (reescritura completa del archivo)
|
|
- Create: `src/pages/LandingPage.tsx`
|
|
- Create: `src/components/landing/HeroSection.tsx`
|
|
- Create: `src/components/landing/visuals/CalendarGridVisual.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `revealUp`, `revealStagger`, `inView`, `EASE_EXPO` de `src/lib/motion.ts`; `useReducedMotion` de `src/lib/useReducedMotion.ts`.
|
|
- Produces:
|
|
- `homePathFor(user: User): string` exportado desde `src/App.tsx`.
|
|
- `LandingPage` como **export default** de `src/pages/LandingPage.tsx`.
|
|
- `HeroSection` (named export), cero props.
|
|
- `CalendarGridVisual` (named export) con props `{ className?: string; density?: number }`.
|
|
|
|
- [ ] **Step 1: Reescribir `src/App.tsx`**
|
|
|
|
```tsx
|
|
import { lazy, Suspense } from "react";
|
|
import { Navigate, Route, Routes } from "react-router-dom";
|
|
import { AuthProvider, useAuth } from "./lib/auth";
|
|
import type { User } from "../shared/types";
|
|
import { LoginPage } from "./pages/LoginPage";
|
|
import { AppShell } from "./components/AppShell";
|
|
import { AdminShell } from "./components/AdminShell";
|
|
import { DashboardPage } from "./pages/DashboardPage";
|
|
import { CalendarPage } from "./pages/CalendarPage";
|
|
import { EmployeesPage } from "./pages/EmployeesPage";
|
|
import { ServicesPage } from "./pages/ServicesPage";
|
|
import { ClientsPage } from "./pages/ClientsPage";
|
|
import { TicketsPage } from "./pages/TicketsPage";
|
|
import { ClientDetailPage } from "./pages/ClientDetailPage";
|
|
import { CashPage } from "./pages/CashPage";
|
|
import { NotificationsPage } from "./pages/NotificationsPage";
|
|
import { SettingsPage } from "./pages/SettingsPage";
|
|
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
|
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
|
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
|
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
|
import BookingPage from "./pages/public/BookingPage";
|
|
|
|
// La landing va aparte del bundle principal por dos razones que apuntan en
|
|
// direcciones opuestas, y por eso importan las dos: quien ya tiene sesión nunca la
|
|
// ve y no debe descargarla, y quien llega a la landing no debe descargar
|
|
// FullCalendar ni recharts para leer una página de venta.
|
|
const LandingPage = lazy(() => import("./pages/LandingPage"));
|
|
|
|
/** Destino del usuario según su rol. Única definición de la regla. */
|
|
export function homePathFor(user: User): string {
|
|
if (user.role === "admin") return "/admin";
|
|
return user.role === "owner" ? "/dashboard" : "/calendar";
|
|
}
|
|
|
|
function Splash() {
|
|
return (
|
|
<div className="flex h-screen-safe items-center justify-center bg-[#f6f7fb]">
|
|
<div className="flex flex-col items-center gap-3 text-slate-500">
|
|
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
|
<span className="text-sm font-medium">Cargando AgendaMax…</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AppRoutes() {
|
|
const { user, loading } = useAuth();
|
|
if (loading) return <Splash />;
|
|
|
|
const home = user ? homePathFor(user) : "/login";
|
|
const isAdmin = user?.role === "admin";
|
|
|
|
return (
|
|
<Routes>
|
|
{/* ---- Públicas ---- */}
|
|
<Route
|
|
path="/"
|
|
element={
|
|
user ? (
|
|
<Navigate to={home} replace />
|
|
) : (
|
|
<Suspense fallback={<Splash />}>
|
|
<LandingPage />
|
|
</Suspense>
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/login" element={user ? <Navigate to={home} replace /> : <LoginPage />} />
|
|
|
|
{/* Sin sesión, cualquier otra ruta manda al login. */}
|
|
{!user && <Route path="*" element={<Navigate to="/login" replace />} />}
|
|
|
|
{/* ---- Admin de plataforma ---- */}
|
|
{user && isAdmin && (
|
|
<Route element={<AdminShell />}>
|
|
<Route path="/admin" element={<AdminOverviewPage />} />
|
|
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
|
|
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
|
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
|
</Route>
|
|
)}
|
|
|
|
{/* ---- Negocio ---- */}
|
|
{user && !isAdmin && (
|
|
<Route element={<AppShell />}>
|
|
{user.role === "owner" ? (
|
|
<Route path="/dashboard" element={<DashboardPage />} />
|
|
) : (
|
|
<Route path="/dashboard" element={<Navigate to="/calendar" replace />} />
|
|
)}
|
|
<Route path="/calendar" element={<CalendarPage />} />
|
|
<Route path="/clients" element={<ClientsPage />} />
|
|
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
|
<Route path="/me" element={<MyPerformancePage />} />
|
|
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
|
|
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
|
|
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
|
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
|
|
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
|
|
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
|
|
{/* La autorización real vive en los middlewares del servidor; esto es UX. */}
|
|
<Route path="/admin/*" element={<Navigate to={home} replace />} />
|
|
<Route path="*" element={<Navigate to={home} replace />} />
|
|
</Route>
|
|
)}
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<Routes>
|
|
{/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */}
|
|
<Route path="/b/:slug" element={<BookingPage />} />
|
|
<Route
|
|
path="*"
|
|
element={
|
|
<AuthProvider>
|
|
<AppRoutes />
|
|
</AuthProvider>
|
|
}
|
|
/>
|
|
</Routes>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Crear `src/components/landing/visuals/CalendarGridVisual.tsx`**
|
|
|
|
```tsx
|
|
import { motion } from "framer-motion";
|
|
import { EASE_EXPO } from "../../../lib/motion";
|
|
import { useReducedMotion } from "../../../lib/useReducedMotion";
|
|
|
|
const COLS = 7;
|
|
const ROWS = 9;
|
|
const DIAS = ["L", "M", "M", "J", "V", "S", "D"];
|
|
|
|
/** Colores de cita: se repiten para simular varios especialistas. */
|
|
const TINTES = ["#3b66ff", "#f17616", "#0ea5e9", "#8b5cf6", "#10b981"];
|
|
|
|
/**
|
|
* Retícula de agenda que se puebla sola. No es una captura: es la promesa del
|
|
* producto en movimiento — el día pasando de vacío a lleno.
|
|
*
|
|
* `density` es la fracción de celdas que se convierten en cita (0..1).
|
|
*/
|
|
export function CalendarGridVisual({
|
|
className = "",
|
|
density = 0.42,
|
|
}: {
|
|
className?: string;
|
|
density?: number;
|
|
}) {
|
|
const reduced = useReducedMotion();
|
|
const total = COLS * ROWS;
|
|
|
|
// Patrón determinista: un pseudo-aleatorio con semilla fija da la misma agenda
|
|
// en cada render y en cada corrida del audit visual, que si no capturaría
|
|
// pantallas distintas cada vez y reportaría diferencias falsas.
|
|
const celdas = Array.from({ length: total }, (_, i) => {
|
|
const h = ((i * 2654435761) % 1000) / 1000;
|
|
const ocupada = h < density;
|
|
return {
|
|
ocupada,
|
|
tinte: TINTES[i % TINTES.length],
|
|
alto: h < density * 0.35 ? 2 : 1,
|
|
};
|
|
});
|
|
|
|
return (
|
|
<div className={`select-none ${className}`} aria-hidden="true">
|
|
<div className="mb-2 grid grid-cols-7 gap-1.5">
|
|
{DIAS.map((d, i) => (
|
|
<div
|
|
key={`${d}-${i}`}
|
|
className="text-center text-[0.6rem] font-bold uppercase tracking-widest opacity-40"
|
|
>
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<motion.div
|
|
className="grid grid-cols-7 gap-1.5"
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={{ once: true }}
|
|
variants={{
|
|
hidden: {},
|
|
show: { transition: { staggerChildren: reduced ? 0 : 0.012 } },
|
|
}}
|
|
>
|
|
{celdas.map((c, i) => (
|
|
<motion.div
|
|
key={i}
|
|
className="h-5 rounded-[0.3rem] sm:h-6"
|
|
style={{
|
|
background: c.ocupada ? c.tinte : "currentColor",
|
|
opacity: c.ocupada ? 1 : 0.07,
|
|
}}
|
|
variants={{
|
|
hidden: reduced
|
|
? { scaleY: 1, opacity: c.ocupada ? 1 : 0.07 }
|
|
: { scaleY: 0.25, opacity: 0 },
|
|
show: {
|
|
scaleY: 1,
|
|
opacity: c.ocupada ? 1 : 0.07,
|
|
transition: { duration: 0.45, ease: EASE_EXPO },
|
|
},
|
|
}}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Crear `src/components/landing/HeroSection.tsx`**
|
|
|
|
```tsx
|
|
import { Link } from "react-router-dom";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowRight, ChevronDown } from "lucide-react";
|
|
import { EASE_EXPO, revealStagger, revealUp } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
|
|
|
|
const LINEAS = ["Tu negocio te habla", "todos los días.", "Nadie está escuchando."];
|
|
|
|
export function HeroSection() {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<section
|
|
data-ld-section
|
|
className="landing-dark relative isolate flex min-h-screen-safe flex-col justify-center overflow-hidden ld-section"
|
|
>
|
|
{/* Fondo: la agenda del producto, desenfocada. Es el sujeto de la foto, no
|
|
un adorno — por eso es la retícula real y no un gradiente. */}
|
|
<div className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center">
|
|
<div className="w-[150%] max-w-none opacity-[0.14] blur-[2px] sm:w-[110%]">
|
|
<CalendarGridVisual density={0.5} />
|
|
</div>
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background:
|
|
"radial-gradient(ellipse at 50% 40%, rgba(8,8,10,0.25) 0%, rgba(8,8,10,0.92) 68%, #08080a 100%)",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="ld-wrap"
|
|
initial="hidden"
|
|
animate="show"
|
|
variants={revealStagger(reduced, 0.08)}
|
|
>
|
|
<motion.p
|
|
className="ld-eyebrow mb-6"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
variants={revealUp(reduced, 12)}
|
|
>
|
|
Business intelligence para tu negocio
|
|
</motion.p>
|
|
|
|
<h1 data-ld-hero-title className="ld-display max-w-4xl">
|
|
{LINEAS.map((linea, i) => (
|
|
<motion.span
|
|
key={linea}
|
|
className="block"
|
|
variants={revealUp(reduced, 28)}
|
|
style={i === 2 ? { color: "var(--ld-muted-dark)" } : undefined}
|
|
>
|
|
{linea}
|
|
</motion.span>
|
|
))}
|
|
</h1>
|
|
|
|
<motion.p
|
|
className="ld-lead mt-8 max-w-xl"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
variants={revealUp(reduced)}
|
|
>
|
|
Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los
|
|
convierte en decisiones que te dejan dinero.
|
|
</motion.p>
|
|
|
|
<motion.div
|
|
className="mt-10 grid grid-cols-1 gap-3 sm:flex sm:items-center"
|
|
variants={revealUp(reduced)}
|
|
>
|
|
<Link
|
|
to="/login"
|
|
className="inline-flex min-h-[52px] items-center justify-center gap-2 rounded-full bg-white px-7 text-[0.95rem] font-bold text-[#08080a] transition-transform hover:scale-[1.02]"
|
|
>
|
|
Entrar a la demo
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
<a
|
|
href="#costo"
|
|
className="inline-flex min-h-[52px] items-center justify-center gap-2 rounded-full border px-7 text-[0.95rem] font-semibold"
|
|
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-paper)" }}
|
|
>
|
|
Ver cómo funciona
|
|
<ChevronDown className="h-4 w-4" />
|
|
</a>
|
|
</motion.div>
|
|
|
|
<motion.p
|
|
className="mt-6 text-xs"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
variants={revealUp(reduced, 8)}
|
|
>
|
|
Sin registro. Sin tarjeta. Cuentas ya cargadas.
|
|
</motion.p>
|
|
</motion.div>
|
|
|
|
{/* Indicador de scroll con respiración lenta. Se apaga con movimiento reducido. */}
|
|
{!reduced && (
|
|
<motion.div
|
|
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
|
animate={{ y: [0, 8, 0], opacity: [0.35, 0.8, 0.35] }}
|
|
transition={{ duration: 2.4, repeat: Infinity, ease: EASE_EXPO }}
|
|
>
|
|
<ChevronDown className="h-5 w-5" />
|
|
</motion.div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Crear `src/pages/LandingPage.tsx`**
|
|
|
|
Por ahora solo el hero. Las tareas 5-7 van añadiendo secciones a este archivo, en este orden.
|
|
|
|
```tsx
|
|
import { HeroSection } from "../components/landing/HeroSection";
|
|
|
|
/**
|
|
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
|
|
* secciones, que no reciben props ni comparten estado. Export default porque
|
|
* `React.lazy` en App.tsx lo exige.
|
|
*/
|
|
export default function LandingPage() {
|
|
return (
|
|
<div className="landing">
|
|
<HeroSection />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verificar tipos**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores.
|
|
|
|
- [ ] **Step 6: Correr el test de aceptación**
|
|
|
|
Run: `npm.cmd run test:landing`
|
|
Expected: pasan «`/` monta la landing», «`/` no expone el formulario de login», «la landing no lanza errores de runtime», y las de redirección y movimiento reducido. **Falla** «la landing tiene las 7 secciones del funnel» con `1 !== 7`. Ese es el estado correcto: quedan seis secciones por construir.
|
|
|
|
---
|
|
|
|
### Task 4: Visuales animados restantes
|
|
|
|
Cuatro componentes de presentación pura. Se construyen juntos porque comparten el mismo contrato (cero estado, `className` opcional, animan al entrar en viewport, se congelan con movimiento reducido) y ninguna sección posterior sirve sin ellos.
|
|
|
|
**Files:**
|
|
- Create: `src/components/landing/visuals/RevenueLineVisual.tsx`
|
|
- Create: `src/components/landing/visuals/TeamLoadVisual.tsx`
|
|
- Create: `src/components/landing/visuals/NotebookVisual.tsx`
|
|
- Create: `src/components/landing/visuals/DashboardMockVisual.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `EASE_EXPO`, `inView` de `src/lib/motion.ts`; `useReducedMotion`; `CalendarGridVisual` (Tarea 3).
|
|
- Produces:
|
|
- `RevenueLineVisual({ className?: string })`
|
|
- `TeamLoadVisual({ className?: string })`
|
|
- `NotebookVisual({ className?: string })`
|
|
- `DashboardMockVisual({ className?: string })`
|
|
|
|
- [ ] **Step 1: Crear `RevenueLineVisual.tsx`**
|
|
|
|
```tsx
|
|
import { motion } from "framer-motion";
|
|
import { EASE_EXPO, inView } from "../../../lib/motion";
|
|
import { useReducedMotion } from "../../../lib/useReducedMotion";
|
|
|
|
// Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a
|
|
// propósito, para que el audit visual capture siempre la misma gráfica.
|
|
const SERIE = [38, 44, 41, 52, 58, 54, 66, 71, 68, 79, 86, 94];
|
|
const W = 520;
|
|
const H = 200;
|
|
const PAD = 12;
|
|
|
|
function pathFrom(serie: number[]): string {
|
|
const max = Math.max(...serie);
|
|
const min = Math.min(...serie);
|
|
const dx = (W - PAD * 2) / (serie.length - 1);
|
|
return serie
|
|
.map((v, i) => {
|
|
const x = PAD + i * dx;
|
|
const y = H - PAD - ((v - min) / (max - min)) * (H - PAD * 2);
|
|
return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */
|
|
export function RevenueLineVisual({ className = "" }: { className?: string }) {
|
|
const reduced = useReducedMotion();
|
|
const d = pathFrom(SERIE);
|
|
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
|
|
|
|
return (
|
|
<svg
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
className={`w-full ${className}`}
|
|
role="img"
|
|
aria-label="Ingresos mensuales en tendencia ascendente"
|
|
>
|
|
<defs>
|
|
<linearGradient id="ld-rev-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#3b66ff" stopOpacity="0.28" />
|
|
<stop offset="100%" stopColor="#3b66ff" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Rejilla base: cuatro guías, muy tenues. Dan escala sin competir. */}
|
|
{[0.25, 0.5, 0.75].map((f) => (
|
|
<line
|
|
key={f}
|
|
x1={PAD}
|
|
x2={W - PAD}
|
|
y1={PAD + f * (H - PAD * 2)}
|
|
y2={PAD + f * (H - PAD * 2)}
|
|
stroke="currentColor"
|
|
strokeOpacity="0.08"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
|
|
<motion.path
|
|
d={area}
|
|
fill="url(#ld-rev-fill)"
|
|
initial={{ opacity: reduced ? 1 : 0 }}
|
|
whileInView={{ opacity: 1 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.9, delay: reduced ? 0 : 0.5, ease: EASE_EXPO }}
|
|
/>
|
|
<motion.path
|
|
d={d}
|
|
fill="none"
|
|
stroke="#3b66ff"
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
initial={{ pathLength: reduced ? 1 : 0 }}
|
|
whileInView={{ pathLength: 1 }}
|
|
viewport={inView}
|
|
transition={{ duration: reduced ? 0 : 1.4, ease: EASE_EXPO }}
|
|
/>
|
|
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
|
|
<motion.circle
|
|
cx={W - PAD}
|
|
cy={
|
|
H -
|
|
PAD -
|
|
((SERIE[SERIE.length - 1] - Math.min(...SERIE)) /
|
|
(Math.max(...SERIE) - Math.min(...SERIE))) *
|
|
(H - PAD * 2)
|
|
}
|
|
r="5"
|
|
fill="#3b66ff"
|
|
initial={{ scale: reduced ? 1 : 0 }}
|
|
whileInView={{ scale: 1 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.5, delay: reduced ? 0 : 1.3, ease: EASE_EXPO }}
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Crear `TeamLoadVisual.tsx`**
|
|
|
|
```tsx
|
|
import { motion } from "framer-motion";
|
|
import { EASE_EXPO, inView } from "../../../lib/motion";
|
|
import { useReducedMotion } from "../../../lib/useReducedMotion";
|
|
|
|
// Nombres del negocio demo sembrado (plantilla estetica-spa).
|
|
const EQUIPO = [
|
|
{ nombre: "Valentina", carga: 0.92, color: "#3b66ff", citas: 34 },
|
|
{ nombre: "Mateo", carga: 0.74, color: "#0ea5e9", citas: 27 },
|
|
{ nombre: "Camila", carga: 0.61, color: "#8b5cf6", citas: 22 },
|
|
{ nombre: "Diego", carga: 0.38, color: "#f17616", citas: 14 },
|
|
{ nombre: "Renata", carga: 0.22, color: "#10b981", citas: 8 },
|
|
];
|
|
|
|
/** Carga real por especialista. El punto: la diferencia se ve de un golpe. */
|
|
export function TeamLoadVisual({ className = "" }: { className?: string }) {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<div className={`grid grid-cols-1 gap-3.5 ${className}`}>
|
|
{EQUIPO.map((p, i) => (
|
|
<div key={p.nombre} className="grid grid-cols-1 gap-1.5">
|
|
<div className="flex items-baseline justify-between text-sm">
|
|
<span className="font-bold">{p.nombre}</span>
|
|
<span className="ld-num text-xs opacity-55">{p.citas} citas</span>
|
|
</div>
|
|
<div className="h-2.5 overflow-hidden rounded-full bg-current/10">
|
|
<motion.div
|
|
className="h-full rounded-full"
|
|
style={{ background: p.color, originX: 0 }}
|
|
initial={{ scaleX: reduced ? p.carga : 0 }}
|
|
whileInView={{ scaleX: p.carga }}
|
|
viewport={inView}
|
|
transition={{
|
|
duration: reduced ? 0 : 0.9,
|
|
delay: reduced ? 0 : i * 0.09,
|
|
ease: EASE_EXPO,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Nota: `bg-current/10` requiere que el contenedor herede el color del texto de la sección. Si Tailwind 3.4 no resuelve la opacidad sobre `currentColor` en tu versión, sustitúyelo por `style={{ background: "currentColor", opacity: 0.1 }}` en un div envolvente.
|
|
|
|
- [ ] **Step 3: Crear `NotebookVisual.tsx`**
|
|
|
|
```tsx
|
|
import { motion } from "framer-motion";
|
|
import { EASE_EXPO, inView } from "../../../lib/motion";
|
|
import { useReducedMotion } from "../../../lib/useReducedMotion";
|
|
|
|
const APUNTES = [
|
|
{ texto: "10:00 Sra. Ortiz — tinte", tachado: true },
|
|
{ texto: "11:30 ¿Camila o Mateo?", tachado: false },
|
|
{ texto: "12:00 confirmar x WhatsApp", tachado: true },
|
|
{ texto: "1:00 —— cancelado", tachado: true },
|
|
{ texto: "4:00 pendiente $$$", tachado: false },
|
|
{ texto: "cobrar a la Sra. del jueves", tachado: false },
|
|
];
|
|
|
|
/**
|
|
* El "antes": el cuaderno. Se dibuja con tipografía manuscrita del sistema y
|
|
* tachones que se trazan, porque un icono de libreta no transmite el desorden.
|
|
*/
|
|
export function NotebookVisual({ className = "" }: { className?: string }) {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<div
|
|
className={`relative overflow-hidden rounded-2xl bg-[#faf7ef] p-6 text-[#3b3a35] shadow-[0_24px_60px_-24px_rgba(0,0,0,0.55)] ${className}`}
|
|
aria-hidden="true"
|
|
>
|
|
{/* Renglones del cuaderno */}
|
|
<div
|
|
className="pointer-events-none absolute inset-0"
|
|
style={{
|
|
backgroundImage: "repeating-linear-gradient(#faf7ef 0 30px, #ddd6c4 30px 31px)",
|
|
}}
|
|
/>
|
|
{/* Margen rojo */}
|
|
<div className="pointer-events-none absolute inset-y-0 left-10 w-px bg-[#d98b8b]" />
|
|
|
|
<div className="relative grid grid-cols-1 gap-[0.72rem] pl-8">
|
|
{APUNTES.map((a, i) => (
|
|
<div key={a.texto} className="relative w-fit max-w-full">
|
|
<span
|
|
className="text-[0.92rem] leading-[1.6]"
|
|
style={{ fontFamily: "'Bradley Hand', 'Segoe Script', cursive" }}
|
|
>
|
|
{a.texto}
|
|
</span>
|
|
{a.tachado && (
|
|
<motion.span
|
|
className="absolute left-0 top-1/2 h-[1.5px] w-full bg-[#3b3a35]"
|
|
style={{ originX: 0 }}
|
|
initial={{ scaleX: reduced ? 1 : 0 }}
|
|
whileInView={{ scaleX: 1 }}
|
|
viewport={inView}
|
|
transition={{
|
|
duration: reduced ? 0 : 0.4,
|
|
delay: reduced ? 0 : 0.3 + i * 0.14,
|
|
ease: EASE_EXPO,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Crear `DashboardMockVisual.tsx`**
|
|
|
|
```tsx
|
|
import { motion, useInView } from "framer-motion";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { EASE_EXPO, inView } from "../../../lib/motion";
|
|
import { useReducedMotion } from "../../../lib/useReducedMotion";
|
|
import { RevenueLineVisual } from "./RevenueLineVisual";
|
|
|
|
const KPIS = [
|
|
{ etiqueta: "Ingresos del mes", valor: 184300, prefijo: "$", sufijo: "" },
|
|
{ etiqueta: "Citas atendidas", valor: 412, prefijo: "", sufijo: "" },
|
|
{ etiqueta: "Ocupación", valor: 78, prefijo: "", sufijo: "%" },
|
|
];
|
|
|
|
const TOP = [
|
|
{ nombre: "Corte + barba", monto: 42800 },
|
|
{ nombre: "Tinte completo", monto: 38150 },
|
|
{ nombre: "Manicura gel", monto: 24600 },
|
|
];
|
|
|
|
/** Contador que interpola de 0 al valor. Se salta la animación si hay reduced motion. */
|
|
function Contador({ valor, prefijo, sufijo }: { valor: number; prefijo: string; sufijo: string }) {
|
|
const reduced = useReducedMotion();
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const visible = useInView(ref, { once: true, margin: "-10% 0px" });
|
|
const [n, setN] = useState(reduced ? valor : 0);
|
|
|
|
useEffect(() => {
|
|
if (reduced) {
|
|
setN(valor);
|
|
return;
|
|
}
|
|
if (!visible) return;
|
|
const DUR = 1400;
|
|
let raf = 0;
|
|
let inicio = 0;
|
|
const paso = (t: number) => {
|
|
if (!inicio) inicio = t;
|
|
const p = Math.min(1, (t - inicio) / DUR);
|
|
// ease-out cúbico: llega rápido y frena, igual que EASE_EXPO en CSS.
|
|
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
|
|
if (p < 1) raf = requestAnimationFrame(paso);
|
|
};
|
|
raf = requestAnimationFrame(paso);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [visible, valor, reduced]);
|
|
|
|
return (
|
|
<span ref={ref} className="ld-num">
|
|
{prefijo}
|
|
{n.toLocaleString("es-MX")}
|
|
{sufijo}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** El panel real, a escala. Es la prueba: datos de la cuenta demo. */
|
|
export function DashboardMockVisual({ className = "" }: { className?: string }) {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<motion.div
|
|
className={`grid grid-cols-1 gap-4 rounded-3xl border border-black/[0.06] bg-white p-5 text-[#0f172a] shadow-[0_40px_100px_-40px_rgba(15,23,42,0.45)] sm:p-7 ${className}`}
|
|
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 32 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.8, ease: EASE_EXPO }}
|
|
>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
{KPIS.map((k) => (
|
|
<div key={k.etiqueta} className="rounded-2xl bg-[#f6f7fb] p-4">
|
|
<div className="text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
|
|
{k.etiqueta}
|
|
</div>
|
|
<div className="mt-1.5 text-2xl font-extrabold tracking-tight sm:text-[1.75rem]">
|
|
<Contador valor={k.valor} prefijo={k.prefijo} sufijo={k.sufijo} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.6fr_1fr]">
|
|
<div className="rounded-2xl bg-[#f6f7fb] p-4">
|
|
<div className="mb-2 text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
|
|
Ingresos · últimos 12 meses
|
|
</div>
|
|
<RevenueLineVisual />
|
|
</div>
|
|
<div className="rounded-2xl bg-[#f6f7fb] p-4">
|
|
<div className="mb-3 text-[0.7rem] font-bold uppercase tracking-wider text-slate-500">
|
|
Servicios que más dejan
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2.5">
|
|
{TOP.map((s, i) => (
|
|
<motion.div
|
|
key={s.nombre}
|
|
className="flex items-baseline justify-between gap-3"
|
|
initial={{ opacity: reduced ? 1 : 0, x: reduced ? 0 : -12 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={inView}
|
|
transition={{
|
|
duration: 0.5,
|
|
delay: reduced ? 0 : 0.4 + i * 0.1,
|
|
ease: EASE_EXPO,
|
|
}}
|
|
>
|
|
<span className="truncate text-sm font-semibold">{s.nombre}</span>
|
|
<span className="ld-num shrink-0 text-sm font-bold text-brand-600">
|
|
${s.monto.toLocaleString("es-MX")}
|
|
</span>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verificar tipos**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores. Los cuatro visuales todavía no tienen consumidores; esto solo valida sus tipos.
|
|
|
|
---
|
|
|
|
### Task 5: Secciones 2 y 3 — el costo y el cambio
|
|
|
|
**Files:**
|
|
- Create: `src/components/landing/CostSection.tsx`
|
|
- Create: `src/components/landing/ShiftSection.tsx`
|
|
- Modify: `src/pages/LandingPage.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `revealUp`, `revealStagger`, `inView`, `EASE_EXPO`, `useReducedMotion`, `NotebookVisual`, `CalendarGridVisual`.
|
|
- Produces: `CostSection`, `ShiftSection` (named exports, cero props).
|
|
|
|
- [ ] **Step 1: Crear `CostSection.tsx`**
|
|
|
|
```tsx
|
|
import { motion, useInView } from "framer-motion";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { inView, revealStagger, revealUp } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
|
|
/**
|
|
* Cuatro fugas, no cuatro features. El copy nombra el costo de no saber; el
|
|
* producto no se menciona todavía. Vender antes de haber dolido no funciona.
|
|
*/
|
|
const FUGAS = [
|
|
{
|
|
cifra: 34,
|
|
sufijo: "%",
|
|
titulo: "de tus horas se van vacías",
|
|
detalle:
|
|
"Huecos de 40 minutos entre citas que nadie llena porque nadie los ve. Son sueldo pagado sin ingreso.",
|
|
},
|
|
{
|
|
cifra: 5,
|
|
sufijo: " meses",
|
|
titulo: "lleva sin volver tu cliente frecuente",
|
|
detalle:
|
|
"Sigue en tu lista. Nadie te avisó. Recuperarlo cuesta una fracción de conseguir uno nuevo.",
|
|
},
|
|
{
|
|
cifra: 1,
|
|
sufijo: " de 6",
|
|
titulo: "servicios te cuesta más de lo que cobra",
|
|
detalle:
|
|
"Entre tiempo de silla, insumos y comisión, hay uno que trabaja gratis. Sin desglose no aparece.",
|
|
},
|
|
{
|
|
cifra: 0,
|
|
sufijo: "",
|
|
titulo: "datos tienes de quién sostiene el negocio",
|
|
detalle:
|
|
"Sabes quién es tu mejor especialista por corazonada. Cuando se va, te enteras del tamaño real.",
|
|
},
|
|
];
|
|
|
|
function Cifra({ valor, sufijo }: { valor: number; sufijo: string }) {
|
|
const reduced = useReducedMotion();
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const visible = useInView(ref, { once: true, margin: "-15% 0px" });
|
|
const [n, setN] = useState(reduced ? valor : 0);
|
|
|
|
useEffect(() => {
|
|
if (reduced) {
|
|
setN(valor);
|
|
return;
|
|
}
|
|
if (!visible || valor === 0) {
|
|
if (valor === 0) setN(0);
|
|
return;
|
|
}
|
|
const DUR = 1100;
|
|
let raf = 0;
|
|
let inicio = 0;
|
|
const paso = (t: number) => {
|
|
if (!inicio) inicio = t;
|
|
const p = Math.min(1, (t - inicio) / DUR);
|
|
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
|
|
if (p < 1) raf = requestAnimationFrame(paso);
|
|
};
|
|
raf = requestAnimationFrame(paso);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [visible, valor, reduced]);
|
|
|
|
return (
|
|
<span ref={ref} className="ld-num">
|
|
{n}
|
|
{sufijo}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function CostSection() {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<section id="costo" data-ld-section className="landing-light ld-section">
|
|
<div className="ld-wrap">
|
|
<motion.div
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={inView}
|
|
variants={revealStagger(reduced)}
|
|
>
|
|
<motion.p
|
|
className="ld-eyebrow mb-5"
|
|
style={{ color: "var(--ld-muted)" }}
|
|
variants={revealUp(reduced, 12)}
|
|
>
|
|
El costo de no saber
|
|
</motion.p>
|
|
<motion.h2 className="ld-title max-w-3xl" variants={revealUp(reduced, 24)}>
|
|
Lo que no mides no te avisa. Te cobra.
|
|
</motion.h2>
|
|
<motion.p
|
|
className="ld-lead mt-6 max-w-xl"
|
|
style={{ color: "var(--ld-muted)" }}
|
|
variants={revealUp(reduced)}
|
|
>
|
|
Ninguna de estas cuatro fugas aparece en tu cuenta de banco con nombre y apellido. Todas
|
|
salen de ahí.
|
|
</motion.p>
|
|
</motion.div>
|
|
|
|
<div className="mt-16 grid grid-cols-1 gap-x-10 gap-y-12 sm:grid-cols-2">
|
|
{FUGAS.map((f, i) => (
|
|
<motion.div
|
|
key={f.titulo}
|
|
className="grid grid-cols-1 gap-3 border-t pt-7"
|
|
style={{ borderColor: "rgba(8,8,10,0.12)" }}
|
|
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 24 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.7, delay: reduced ? 0 : i * 0.08 }}
|
|
>
|
|
<div className="text-5xl font-extrabold tracking-tight text-brand-600 sm:text-6xl">
|
|
<Cifra valor={f.cifra} sufijo={f.sufijo} />
|
|
</div>
|
|
<h3 className="text-lg font-extrabold leading-snug">{f.titulo}</h3>
|
|
<p className="text-[0.95rem] leading-relaxed" style={{ color: "var(--ld-muted)" }}>
|
|
{f.detalle}
|
|
</p>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Crear `ShiftSection.tsx`**
|
|
|
|
```tsx
|
|
import { useRef } from "react";
|
|
import { motion, useScroll, useTransform } from "framer-motion";
|
|
import { revealUp } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
import { NotebookVisual } from "./visuals/NotebookVisual";
|
|
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
|
|
|
|
/**
|
|
* El cambio, contado con el scroll: el cuaderno se va y el tablero llega. Es la
|
|
* única sección con scrub (progreso ligado a la posición), que es lo que hace que
|
|
* el scroll se sienta como una película y no como una lista.
|
|
*
|
|
* El contenedor mide 260vh para que el panel sticky tenga recorrido; con
|
|
* movimiento reducido se colapsa a una pila estática de altura natural.
|
|
*/
|
|
export function ShiftSection() {
|
|
const reduced = useReducedMotion();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const { scrollYProgress } = useScroll({
|
|
target: ref,
|
|
offset: ["start start", "end end"],
|
|
});
|
|
|
|
const notaOpacidad = useTransform(scrollYProgress, [0.05, 0.42], [1, 0]);
|
|
const notaEscala = useTransform(scrollYProgress, [0.05, 0.42], [1, 0.92]);
|
|
const notaGiro = useTransform(scrollYProgress, [0.05, 0.42], [-2, -7]);
|
|
const panelOpacidad = useTransform(scrollYProgress, [0.32, 0.62], [0, 1]);
|
|
const panelY = useTransform(scrollYProgress, [0.32, 0.62], [40, 0]);
|
|
|
|
if (reduced) {
|
|
return (
|
|
<section data-ld-section className="landing-dark ld-section">
|
|
<div className="ld-wrap grid grid-cols-1 gap-12">
|
|
<div>
|
|
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-muted-dark)" }}>
|
|
El cambio
|
|
</p>
|
|
<h2 className="ld-title max-w-3xl">
|
|
No es que trabajes poco. Es que trabajas sin instrumentos.
|
|
</h2>
|
|
<p className="ld-lead mt-6 max-w-xl" style={{ color: "var(--ld-muted-dark)" }}>
|
|
El mismo día, los mismos clientes, el mismo equipo. La diferencia es que ahora lo ves.
|
|
</p>
|
|
</div>
|
|
<NotebookVisual className="max-w-md" />
|
|
<div className="rounded-2xl border p-6" style={{ borderColor: "var(--ld-hairline)" }}>
|
|
<CalendarGridVisual />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section data-ld-section className="landing-dark">
|
|
<div ref={ref} className="relative h-[260vh]">
|
|
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
|
|
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
|
<motion.div
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={{ once: true }}
|
|
variants={revealUp(false)}
|
|
>
|
|
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-muted-dark)" }}>
|
|
El cambio
|
|
</p>
|
|
<h2 className="ld-title">
|
|
No es que trabajes poco.
|
|
<br />
|
|
<span style={{ color: "var(--ld-muted-dark)" }}>
|
|
Es que trabajas sin instrumentos.
|
|
</span>
|
|
</h2>
|
|
<p className="ld-lead mt-6 max-w-md" style={{ color: "var(--ld-muted-dark)" }}>
|
|
El mismo día, los mismos clientes, el mismo equipo. La diferencia es que ahora lo
|
|
ves.
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Escenario: las dos piezas ocupan la misma caja y se relevan. */}
|
|
<div className="relative min-h-[22rem] sm:min-h-[26rem]">
|
|
<motion.div
|
|
className="absolute inset-0"
|
|
style={{ opacity: notaOpacidad, scale: notaEscala, rotate: notaGiro }}
|
|
>
|
|
<NotebookVisual />
|
|
</motion.div>
|
|
<motion.div
|
|
className="absolute inset-0 rounded-2xl border p-5 sm:p-6"
|
|
style={{
|
|
opacity: panelOpacidad,
|
|
y: panelY,
|
|
borderColor: "var(--ld-hairline)",
|
|
background: "rgba(255,255,255,0.03)",
|
|
}}
|
|
>
|
|
<div className="mb-3 text-[0.7rem] font-bold uppercase tracking-wider opacity-50">
|
|
Tu semana, completa
|
|
</div>
|
|
<CalendarGridVisual />
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Montar las dos secciones en `LandingPage.tsx`**
|
|
|
|
```tsx
|
|
import { HeroSection } from "../components/landing/HeroSection";
|
|
import { CostSection } from "../components/landing/CostSection";
|
|
import { ShiftSection } from "../components/landing/ShiftSection";
|
|
|
|
/**
|
|
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
|
|
* secciones, que no reciben props ni comparten estado. Export default porque
|
|
* `React.lazy` en App.tsx lo exige.
|
|
*/
|
|
export default function LandingPage() {
|
|
return (
|
|
<div className="landing">
|
|
<HeroSection />
|
|
<CostSection />
|
|
<ShiftSection />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verificar tipos**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores.
|
|
|
|
- [ ] **Step 5: Correr el test**
|
|
|
|
Run: `npm.cmd run test:landing`
|
|
Expected: sigue fallando «7 secciones» con `3 !== 7`. **Deben pasar** las tres comprobaciones de movimiento reducido — si «con movimiento reducido todas las secciones son visibles» falla aquí, hay un `initial` sin rama `reduced` en `CostSection` o `ShiftSection`. Arréglalo antes de continuar; es el bug que este test existe para atrapar.
|
|
|
|
---
|
|
|
|
### Task 6: Secciones 4 y 5 — el producto y la prueba
|
|
|
|
**Files:**
|
|
- Create: `src/components/landing/ProductSection.tsx`
|
|
- Create: `src/components/landing/ProofSection.tsx`
|
|
- Modify: `src/pages/LandingPage.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `EASE_EXPO` (solo `ProductSection`), `inView` + `revealStagger` + `revealUp` (solo `ProofSection`), `useReducedMotion`, `CalendarGridVisual`, `RevenueLineVisual`, `TeamLoadVisual`, `DashboardMockVisual`.
|
|
- Produces: `ProductSection`, `ProofSection` (named exports, cero props).
|
|
|
|
- [ ] **Step 1: Crear `ProductSection.tsx`**
|
|
|
|
```tsx
|
|
import { useRef, useState } from "react";
|
|
import { motion, useMotionValueEvent, useScroll } from "framer-motion";
|
|
import { CalendarDays, LineChart, Users } from "lucide-react";
|
|
import { EASE_EXPO } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
|
|
import { RevenueLineVisual } from "./visuals/RevenueLineVisual";
|
|
import { TeamLoadVisual } from "./visuals/TeamLoadVisual";
|
|
|
|
const ACTOS = [
|
|
{
|
|
icono: CalendarDays,
|
|
rotulo: "Agenda",
|
|
titulo: "Tu día completo en una pantalla.",
|
|
copy: "Arrastra una cita y se reacomoda. El sistema no te deja agendar dos personas en el mismo lugar a la misma hora, ni asignar a quien no está en turno.",
|
|
visual: <CalendarGridVisual density={0.48} />,
|
|
},
|
|
{
|
|
icono: LineChart,
|
|
rotulo: "Inteligencia",
|
|
titulo: "Los números que tu contador te da en marzo, hoy a las 3 de la tarde.",
|
|
copy: "Ingresos por día, por servicio y por especialista. Qué creció, qué cayó y desde cuándo. Sin exportar nada, sin pedirle un reporte a nadie.",
|
|
visual: <RevenueLineVisual />,
|
|
},
|
|
{
|
|
icono: Users,
|
|
rotulo: "Equipo",
|
|
titulo: "Deja de repartir el trabajo por intuición.",
|
|
copy: "La carga real de cada especialista, lado a lado. Quién está saturado, quién tiene hueco y quién trae de vuelta a los clientes.",
|
|
visual: <TeamLoadVisual />,
|
|
},
|
|
];
|
|
|
|
export function ProductSection() {
|
|
const reduced = useReducedMotion();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [activo, setActivo] = useState(0);
|
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
|
|
|
|
useMotionValueEvent(scrollYProgress, "change", (p) => {
|
|
// Tres tramos iguales. Se clampea al último para que el final del recorrido no
|
|
// devuelva un índice fuera de rango.
|
|
setActivo(Math.min(ACTOS.length - 1, Math.floor(p * ACTOS.length)));
|
|
});
|
|
|
|
// Con movimiento reducido no hay panel sticky: los tres actos se apilan enteros.
|
|
if (reduced) {
|
|
return (
|
|
<section data-ld-section className="landing-light ld-section">
|
|
<div className="ld-wrap grid grid-cols-1 gap-20">
|
|
{ACTOS.map((a) => (
|
|
<div key={a.rotulo} className="grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
|
|
<div>
|
|
<p className="ld-eyebrow mb-4 text-brand-600">{a.rotulo}</p>
|
|
<h3 className="ld-title">{a.titulo}</h3>
|
|
<p className="ld-lead mt-5" style={{ color: "var(--ld-muted)" }}>
|
|
{a.copy}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-black/[0.07] bg-white p-6">{a.visual}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section data-ld-section className="landing-light">
|
|
<div ref={ref} className="relative" style={{ height: `${ACTOS.length * 100}vh` }}>
|
|
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
|
|
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
|
{/* Texto: los tres actos ocupan la misma caja y se relevan. */}
|
|
<div className="relative min-h-[19rem]">
|
|
{ACTOS.map((a, i) => {
|
|
const Icono = a.icono;
|
|
return (
|
|
<motion.div
|
|
key={a.rotulo}
|
|
className="absolute inset-0 grid grid-cols-1 content-center"
|
|
animate={{
|
|
opacity: activo === i ? 1 : 0,
|
|
y: activo === i ? 0 : 18,
|
|
}}
|
|
transition={{ duration: 0.5, ease: EASE_EXPO }}
|
|
style={{ pointerEvents: activo === i ? "auto" : "none" }}
|
|
aria-hidden={activo !== i}
|
|
>
|
|
<div className="mb-4 flex items-center gap-2.5 text-brand-600">
|
|
<Icono className="h-4 w-4" />
|
|
<span className="ld-eyebrow">{a.rotulo}</span>
|
|
</div>
|
|
<h3 className="ld-title">{a.titulo}</h3>
|
|
<p className="ld-lead mt-5 max-w-lg" style={{ color: "var(--ld-muted)" }}>
|
|
{a.copy}
|
|
</p>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Panel: cambia de visual con el acto. */}
|
|
<div className="relative min-h-[20rem] rounded-3xl border border-black/[0.07] bg-white p-5 shadow-[0_30px_80px_-40px_rgba(15,23,42,0.35)] sm:min-h-[24rem] sm:p-7">
|
|
{ACTOS.map((a, i) => (
|
|
<motion.div
|
|
key={a.rotulo}
|
|
className="absolute inset-5 grid grid-cols-1 content-center sm:inset-7"
|
|
animate={{
|
|
opacity: activo === i ? 1 : 0,
|
|
scale: activo === i ? 1 : 0.97,
|
|
}}
|
|
transition={{ duration: 0.5, ease: EASE_EXPO }}
|
|
style={{ pointerEvents: activo === i ? "auto" : "none" }}
|
|
aria-hidden={activo !== i}
|
|
>
|
|
{a.visual}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Indicador de progreso entre actos. */}
|
|
<div className="ld-wrap flex items-center justify-center gap-2 pb-6">
|
|
{ACTOS.map((a, i) => (
|
|
<motion.span
|
|
key={a.rotulo}
|
|
className="h-1 rounded-full bg-current"
|
|
animate={{ width: activo === i ? 28 : 10, opacity: activo === i ? 0.8 : 0.2 }}
|
|
transition={{ duration: 0.4, ease: EASE_EXPO }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
Nota: esta sección no usa `inView` ni `revealUp` a propósito — el scrub por scroll reemplaza al reveal
|
|
por viewport. `EASE_EXPO` es lo único que necesita de `motion.ts`.
|
|
|
|
- [ ] **Step 2: Crear `ProofSection.tsx`**
|
|
|
|
```tsx
|
|
import { motion } from "framer-motion";
|
|
import { ShieldCheck } from "lucide-react";
|
|
import { inView, revealStagger, revealUp } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
import { DashboardMockVisual } from "./visuals/DashboardMockVisual";
|
|
|
|
export function ProofSection() {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<section data-ld-section className="landing-light ld-section pt-0">
|
|
<div className="ld-wrap">
|
|
<motion.div
|
|
className="mx-auto max-w-2xl text-center"
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={inView}
|
|
variants={revealStagger(reduced)}
|
|
>
|
|
<motion.p
|
|
className="ld-eyebrow mb-5 text-brand-600"
|
|
variants={revealUp(reduced, 12)}
|
|
>
|
|
La prueba
|
|
</motion.p>
|
|
<motion.h2 className="ld-title" variants={revealUp(reduced, 24)}>
|
|
Esto no es una maqueta.
|
|
</motion.h2>
|
|
<motion.p
|
|
className="ld-lead mt-6"
|
|
style={{ color: "var(--ld-muted)" }}
|
|
variants={revealUp(reduced)}
|
|
>
|
|
Es el tablero de la cuenta demo, con datos reales de un negocio sembrado. Puedes entrar,
|
|
mover una cita y ver cómo cambian los números.
|
|
</motion.p>
|
|
</motion.div>
|
|
|
|
<DashboardMockVisual className="mt-14" />
|
|
|
|
<motion.div
|
|
className="mx-auto mt-8 flex max-w-xl items-start gap-3 text-sm"
|
|
style={{ color: "var(--ld-muted)" }}
|
|
initial={{ opacity: reduced ? 1 : 0 }}
|
|
whileInView={{ opacity: 1 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.6, delay: reduced ? 0 : 0.3 }}
|
|
>
|
|
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-600" />
|
|
<span>
|
|
Entorno de demostración. Los datos se pueden reiniciar en cualquier momento, así que
|
|
muévelos sin miedo.
|
|
</span>
|
|
</motion.div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Montar en `LandingPage.tsx`**
|
|
|
|
Añade los dos imports y las dos etiquetas después de `<ShiftSection />`:
|
|
|
|
```tsx
|
|
import { HeroSection } from "../components/landing/HeroSection";
|
|
import { CostSection } from "../components/landing/CostSection";
|
|
import { ShiftSection } from "../components/landing/ShiftSection";
|
|
import { ProductSection } from "../components/landing/ProductSection";
|
|
import { ProofSection } from "../components/landing/ProofSection";
|
|
|
|
export default function LandingPage() {
|
|
return (
|
|
<div className="landing">
|
|
<HeroSection />
|
|
<CostSection />
|
|
<ShiftSection />
|
|
<ProductSection />
|
|
<ProofSection />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verificar tipos**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores.
|
|
|
|
- [ ] **Step 5: Correr el test**
|
|
|
|
Run: `npm.cmd run test:landing`
|
|
Expected: «7 secciones» falla con `5 !== 7`. Todo lo demás en verde.
|
|
|
|
---
|
|
|
|
### Task 7: Secciones 6 y 7 — objeciones, cierre y nav
|
|
|
|
**Files:**
|
|
- Create: `src/components/landing/ObjectionsSection.tsx`
|
|
- Create: `src/components/landing/ClosingSection.tsx`
|
|
- Create: `src/components/landing/LandingNav.tsx`
|
|
- Modify: `src/pages/LandingPage.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `inView`, `revealStagger`, `revealUp`, `EASE_EXPO`, `useReducedMotion`.
|
|
- Produces: `ObjectionsSection`, `ClosingSection`, `LandingNav` (named exports, cero props). `ClosingSection` debe rendir el atributo `data-ld-closing-cta` en su CTA.
|
|
|
|
- [ ] **Step 1: Crear `ObjectionsSection.tsx`**
|
|
|
|
```tsx
|
|
import { motion } from "framer-motion";
|
|
import { inView, revealStagger, revealUp } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
|
|
/** Las tres objeciones reales de un dueño de negocio en LATAM, sin endulzar. */
|
|
const OBJECIONES = [
|
|
{
|
|
dice: "No soy de tecnología.",
|
|
responde:
|
|
"Si sabes usar WhatsApp, sabes usar esto. Se arrastra con el dedo y no hay nada que configurar antes de empezar a usarlo.",
|
|
},
|
|
{
|
|
dice: "Mi equipo no lo va a adoptar.",
|
|
responde:
|
|
"Cada empleado ve solo su día y sus propios números. No administra nada, no configura nada. Es menos trabajo que preguntarte a ti a qué hora entra.",
|
|
},
|
|
{
|
|
dice: "Ya tengo mi cuaderno y me funciona.",
|
|
responde:
|
|
"Tu cuaderno te dice a qué hora es la cita. No te dice qué servicio te está costando dinero, ni quién de tus clientes dejó de venir hace cuatro meses.",
|
|
},
|
|
];
|
|
|
|
export function ObjectionsSection() {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<section data-ld-section className="landing-light ld-section">
|
|
<div className="ld-wrap">
|
|
<motion.div
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={inView}
|
|
variants={revealStagger(reduced)}
|
|
>
|
|
<motion.p
|
|
className="ld-eyebrow mb-5"
|
|
style={{ color: "var(--ld-muted)" }}
|
|
variants={revealUp(reduced, 12)}
|
|
>
|
|
Lo que estás pensando
|
|
</motion.p>
|
|
<motion.h2 className="ld-title max-w-2xl" variants={revealUp(reduced, 24)}>
|
|
Tres razones para no hacerlo. Y por qué ninguna aguanta.
|
|
</motion.h2>
|
|
</motion.div>
|
|
|
|
<div className="mt-14 grid grid-cols-1 gap-px overflow-hidden rounded-2xl bg-black/[0.09]">
|
|
{OBJECIONES.map((o, i) => (
|
|
<motion.div
|
|
key={o.dice}
|
|
className="grid grid-cols-1 gap-3 bg-[var(--ld-paper)] p-7 sm:grid-cols-[1fr_1.5fr] sm:gap-10 sm:p-9"
|
|
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.65, delay: reduced ? 0 : i * 0.1 }}
|
|
>
|
|
<p className="text-xl font-extrabold leading-snug tracking-tight sm:text-2xl">
|
|
«{o.dice}»
|
|
</p>
|
|
<p className="text-[0.98rem] leading-relaxed" style={{ color: "var(--ld-muted)" }}>
|
|
{o.responde}
|
|
</p>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Crear `ClosingSection.tsx`**
|
|
|
|
```tsx
|
|
import { Link } from "react-router-dom";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowRight, Sparkles } from "lucide-react";
|
|
import { inView, revealStagger, revealUp } from "../../lib/motion";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
|
|
|
|
export function ClosingSection() {
|
|
const reduced = useReducedMotion();
|
|
|
|
return (
|
|
<section data-ld-section className="landing-dark relative overflow-hidden">
|
|
{/* Cierra el círculo del hero: la misma retícula, ahora llena. */}
|
|
<div className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center">
|
|
<div className="w-[150%] max-w-none opacity-[0.1] blur-[2px] sm:w-[110%]">
|
|
<CalendarGridVisual density={0.72} />
|
|
</div>
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background:
|
|
"radial-gradient(ellipse at 50% 50%, rgba(8,8,10,0.35) 0%, rgba(8,8,10,0.94) 70%, #08080a 100%)",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="ld-wrap ld-section text-center"
|
|
initial="hidden"
|
|
whileInView="show"
|
|
viewport={inView}
|
|
variants={revealStagger(reduced)}
|
|
>
|
|
<motion.h2 className="ld-display mx-auto max-w-3xl" variants={revealUp(reduced, 28)}>
|
|
Deja de adivinar.
|
|
</motion.h2>
|
|
<motion.p
|
|
className="ld-lead mx-auto mt-7 max-w-lg"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
variants={revealUp(reduced)}
|
|
>
|
|
Entra a la demo con una cuenta ya cargada y mira tu negocio como se ve cuando tienes los
|
|
números enfrente.
|
|
</motion.p>
|
|
|
|
<motion.div className="mt-11" variants={revealUp(reduced)}>
|
|
<Link
|
|
data-ld-closing-cta
|
|
to="/login"
|
|
className="inline-flex min-h-[56px] items-center justify-center gap-2 rounded-full bg-white px-9 text-base font-bold text-[#08080a] transition-transform hover:scale-[1.02]"
|
|
>
|
|
Entrar a la demo
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</motion.div>
|
|
|
|
<motion.p
|
|
className="mt-6 text-xs"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
variants={revealUp(reduced, 8)}
|
|
>
|
|
Sin registro. Sin tarjeta. Cuentas ya cargadas.
|
|
</motion.p>
|
|
</motion.div>
|
|
|
|
<footer
|
|
className="ld-wrap flex flex-col items-center justify-between gap-4 border-t px-5 py-8 text-xs sm:flex-row"
|
|
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-muted-dark)" }}
|
|
>
|
|
<div className="flex items-center gap-2 font-bold">
|
|
<Sparkles className="h-4 w-4" />
|
|
AgendaMax
|
|
</div>
|
|
<span>© {new Date().getFullYear()} AgendaMax · Entorno de demostración</span>
|
|
<Link to="/login" className="font-semibold underline-offset-4 hover:underline">
|
|
Iniciar sesión
|
|
</Link>
|
|
</footer>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Crear `LandingNav.tsx`**
|
|
|
|
```tsx
|
|
import { Link } from "react-router-dom";
|
|
import { motion, useScroll, useTransform } from "framer-motion";
|
|
import { Sparkles } from "lucide-react";
|
|
import { useReducedMotion } from "../../lib/useReducedMotion";
|
|
|
|
/**
|
|
* Nav flotante. Arranca transparente sobre el hero y se condensa al scrollear:
|
|
* fondo y desenfoque suben juntos, que es lo que evita el salto brusco de un
|
|
* `position: fixed` con fondo sólido desde el primer píxel.
|
|
*/
|
|
export function LandingNav() {
|
|
const reduced = useReducedMotion();
|
|
const { scrollY } = useScroll();
|
|
const fondo = useTransform(scrollY, [0, 140], ["rgba(8,8,10,0)", "rgba(8,8,10,0.72)"]);
|
|
const desenfoque = useTransform(scrollY, [0, 140], ["blur(0px)", "blur(14px)"]);
|
|
const borde = useTransform(scrollY, [0, 140], ["rgba(255,255,255,0)", "rgba(255,255,255,0.09)"]);
|
|
|
|
return (
|
|
<motion.header
|
|
className="safe-top fixed inset-x-0 top-0 z-50 border-b"
|
|
style={
|
|
reduced
|
|
? { background: "rgba(8,8,10,0.82)", borderColor: "rgba(255,255,255,0.09)" }
|
|
: { background: fondo, backdropFilter: desenfoque, borderColor: borde }
|
|
}
|
|
>
|
|
<nav className="ld-wrap flex items-center justify-between gap-4 px-5 py-3.5">
|
|
<Link
|
|
to="/"
|
|
className="flex items-center gap-2 text-[0.95rem] font-extrabold tracking-tight text-white"
|
|
>
|
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/12">
|
|
<Sparkles className="h-4 w-4" />
|
|
</span>
|
|
AgendaMax
|
|
</Link>
|
|
<Link
|
|
to="/login"
|
|
className="tap-target inline-flex items-center rounded-full bg-white px-5 text-sm font-bold text-[#08080a]"
|
|
>
|
|
Entrar a la demo
|
|
</Link>
|
|
</nav>
|
|
</motion.header>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Completar `LandingPage.tsx`**
|
|
|
|
```tsx
|
|
import { LandingNav } from "../components/landing/LandingNav";
|
|
import { HeroSection } from "../components/landing/HeroSection";
|
|
import { CostSection } from "../components/landing/CostSection";
|
|
import { ShiftSection } from "../components/landing/ShiftSection";
|
|
import { ProductSection } from "../components/landing/ProductSection";
|
|
import { ProofSection } from "../components/landing/ProofSection";
|
|
import { ObjectionsSection } from "../components/landing/ObjectionsSection";
|
|
import { ClosingSection } from "../components/landing/ClosingSection";
|
|
|
|
/**
|
|
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
|
|
* secciones, que no reciben props ni comparten estado. Export default porque
|
|
* `React.lazy` en App.tsx lo exige.
|
|
*
|
|
* Orden del funnel: problema → costo → mecanismo → prueba → objeción → cierre.
|
|
* `LandingNav` es chrome fijo, no cuenta como sección.
|
|
*/
|
|
export default function LandingPage() {
|
|
return (
|
|
<div className="landing">
|
|
<LandingNav />
|
|
<HeroSection />
|
|
<CostSection />
|
|
<ShiftSection />
|
|
<ProductSection />
|
|
<ProofSection />
|
|
<ObjectionsSection />
|
|
<ClosingSection />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verificar tipos**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores.
|
|
|
|
- [ ] **Step 6: Correr el test**
|
|
|
|
Run: `npm.cmd run test:landing`
|
|
Expected: «la landing tiene las 7 secciones del funnel» ahora pasa. **Falla** «`/login` ofrece al menos 3 cuentas de acceso mágico» y «un clic en la tarjeta de dueña entra al panel», porque `LoginPage` todavía no tiene `[data-magic-login]`. Verifica también «la landing no desborda en horizontal a 390px»: si falla, el culpable más probable es un `w-[150%]` de fondo sin `overflow-hidden` en su sección.
|
|
|
|
---
|
|
|
|
### Task 8: Login mágico
|
|
|
|
**Files:**
|
|
- Modify: `src/pages/LoginPage.tsx` (reescritura completa)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `useAuth` de `src/lib/auth`; `api.auth.demoUsers()` de `src/lib/api`; `Spinner` de `src/components/ui`; `InstallAppPrompt`; `EASE_EXPO`, `revealStagger`, `revealUp` de `src/lib/motion`; `useReducedMotion`.
|
|
- Produces: `LoginPage` (named export, como hoy). Contrato de DOM que los cuatro audits dependen:
|
|
- `input[type="email"]`, `input[type="password"]`, `button[type="submit"]` **visibles en la carga inicial**, sin interacción.
|
|
- `[data-magic-login]` por tarjeta; `[data-magic-login][data-role="owner"]` para la dueña.
|
|
|
|
- [ ] **Step 1: Reescribir `src/pages/LoginPage.tsx`**
|
|
|
|
```tsx
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { AlertCircle, ArrowLeft, ArrowRight, Lock, Mail, Sparkles, Wand2 } from "lucide-react";
|
|
import { motion } from "framer-motion";
|
|
import { useAuth } from "../lib/auth";
|
|
import { api } from "../lib/api";
|
|
import { Spinner } from "../components/ui";
|
|
import { InstallAppPrompt } from "../components/InstallAppPrompt";
|
|
import { revealStagger, revealUp } from "../lib/motion";
|
|
import { useReducedMotion } from "../lib/useReducedMotion";
|
|
|
|
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
|
|
|
type DemoUser = { email: string; name: string; role: string; avatar_color: string };
|
|
|
|
/** Qué desbloquea cada rol. Es la información que convierte una lista de correos
|
|
* en una decisión: el visitante elige qué producto quiere ver. */
|
|
const GRUPOS = [
|
|
{
|
|
role: "owner",
|
|
titulo: "Dueña del negocio",
|
|
detalle: "Tablero completo: ingresos, equipo, clientes y caja.",
|
|
limite: 1,
|
|
},
|
|
{
|
|
role: "employee",
|
|
titulo: "Especialista",
|
|
detalle: "Solo su agenda del día y su propio desempeño.",
|
|
limite: 2,
|
|
},
|
|
{
|
|
role: "admin",
|
|
titulo: "Plataforma",
|
|
detalle: "Consola multi-negocio: alta, reset y métricas de todos los tenants.",
|
|
limite: 1,
|
|
},
|
|
] as const;
|
|
|
|
export function LoginPage() {
|
|
const { login, user } = useAuth();
|
|
const navigate = useNavigate();
|
|
const reduced = useReducedMotion();
|
|
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
|
|
const [password, setPassword] = useState(DEMO ? "demo1234" : "");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [pendiente, setPendiente] = useState<string | null>(null);
|
|
const [demoUsers, setDemoUsers] = useState<DemoUser[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (user) navigate("/", { replace: true });
|
|
}, [user, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!DEMO) return;
|
|
api.auth
|
|
.demoUsers()
|
|
.then((r) => setDemoUsers(r.users))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const grupos = useMemo(
|
|
() =>
|
|
GRUPOS.map((g) => ({
|
|
...g,
|
|
cuentas: demoUsers.filter((u) => u.role === g.role).slice(0, g.limite),
|
|
})).filter((g) => g.cuentas.length > 0),
|
|
[demoUsers]
|
|
);
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
await login(email, password);
|
|
navigate("/", { replace: true });
|
|
} catch (err: any) {
|
|
setError(err.message || "No pudimos iniciar sesión");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Acceso mágico, solo demo. `DEMO` es una constante de build, así que esta rama
|
|
// entera —y con ella la contraseña— se elimina del bundle de producción por
|
|
// dead-code elimination. Por eso la contraseña vive aquí dentro y no arriba.
|
|
const quickLogin = DEMO
|
|
? async (mail: string) => {
|
|
setEmail(mail);
|
|
setPassword("demo1234");
|
|
setError(null);
|
|
setPendiente(mail);
|
|
setLoading(true);
|
|
try {
|
|
await login(mail, "demo1234");
|
|
navigate("/", { replace: true });
|
|
} catch (err: any) {
|
|
setError(err.message || "No pudimos iniciar sesión");
|
|
} finally {
|
|
setLoading(false);
|
|
setPendiente(null);
|
|
}
|
|
}
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="landing landing-dark relative min-h-screen-safe overflow-hidden">
|
|
<div
|
|
className="pointer-events-none absolute inset-0"
|
|
style={{
|
|
background:
|
|
"radial-gradient(ellipse at 20% 0%, rgba(59,102,255,0.22) 0%, rgba(8,8,10,0) 55%)",
|
|
}}
|
|
/>
|
|
|
|
<div className="safe-top safe-x relative mx-auto flex w-full max-w-5xl flex-col px-5 pb-12 pt-6">
|
|
<Link
|
|
to="/"
|
|
className="tap-target inline-flex w-fit items-center gap-2 text-sm font-semibold"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Volver al inicio
|
|
</Link>
|
|
|
|
<motion.div
|
|
className="mt-10 grid grid-cols-1 items-start gap-10 lg:mt-14 lg:grid-cols-[1.05fr_1fr] lg:gap-14"
|
|
initial="hidden"
|
|
animate="show"
|
|
variants={revealStagger(reduced)}
|
|
>
|
|
{/* ---- Columna izquierda: acceso mágico ---- */}
|
|
<motion.div variants={revealUp(reduced)}>
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/12">
|
|
<Sparkles className="h-5 w-5" />
|
|
</span>
|
|
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
|
|
</div>
|
|
|
|
<h1 className="ld-title mt-8 text-[clamp(1.75rem,4vw,2.5rem)]">
|
|
Entra sin registrarte.
|
|
</h1>
|
|
<p className="ld-lead mt-4 max-w-md" style={{ color: "var(--ld-muted-dark)" }}>
|
|
Elige con qué ojos quieres ver el producto. Cada cuenta abre una vista distinta del
|
|
mismo negocio.
|
|
</p>
|
|
|
|
{DEMO && (
|
|
<div className="mt-9 grid grid-cols-1 gap-7">
|
|
{grupos.map((g) => (
|
|
<div key={g.role} className="grid grid-cols-1 gap-3">
|
|
<div>
|
|
<div className="text-sm font-extrabold">{g.titulo}</div>
|
|
<div className="text-xs" style={{ color: "var(--ld-muted-dark)" }}>
|
|
{g.detalle}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{g.cuentas.map((u) => (
|
|
<button
|
|
key={u.email}
|
|
type="button"
|
|
data-magic-login
|
|
data-role={u.role}
|
|
onClick={() => quickLogin?.(u.email)}
|
|
disabled={loading}
|
|
className="group flex min-h-[56px] w-full items-center gap-3 rounded-2xl border px-4 py-2.5 text-left transition-colors hover:bg-white/[0.06] disabled:opacity-60"
|
|
style={{ borderColor: "var(--ld-hairline)" }}
|
|
>
|
|
<span
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
|
|
style={{ background: u.avatar_color }}
|
|
>
|
|
{u.name
|
|
.split(" ")
|
|
.map((p) => p[0])
|
|
.slice(0, 2)
|
|
.join("")}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-bold">{u.name}</span>
|
|
<span
|
|
className="block truncate text-[11px]"
|
|
style={{ color: "var(--ld-muted-dark)" }}
|
|
>
|
|
{u.email}
|
|
</span>
|
|
</span>
|
|
{pendiente === u.email ? (
|
|
<Spinner />
|
|
) : (
|
|
<Wand2
|
|
className="h-4 w-4 shrink-0 opacity-40 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* ---- Columna derecha: formulario manual ----
|
|
Visible sin interacción a propósito: visual-audit, responsive-audit y
|
|
pwa-e2e rellenan estos campos justo después del goto. Colapsarlo tras
|
|
un toggle rompería los tres. */}
|
|
<motion.div
|
|
className="w-full rounded-3xl border p-6 sm:p-7"
|
|
style={{ borderColor: "var(--ld-hairline)", background: "rgba(255,255,255,0.04)" }}
|
|
variants={revealUp(reduced)}
|
|
>
|
|
<h2 className="text-lg font-extrabold">Entrar con correo</h2>
|
|
<p className="mt-1 text-sm" style={{ color: "var(--ld-muted-dark)" }}>
|
|
Si ya tienes una cuenta del negocio.
|
|
</p>
|
|
|
|
<form onSubmit={submit} className="mt-6 grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label className="label" style={{ color: "var(--ld-muted-dark)" }}>
|
|
Correo
|
|
</label>
|
|
<div className="relative">
|
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
type="email"
|
|
className="input pl-9"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="[email protected]"
|
|
autoComplete="email"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="label" style={{ color: "var(--ld-muted-dark)" }}>
|
|
Contraseña
|
|
</label>
|
|
<div className="relative">
|
|
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
type="password"
|
|
className="input pl-9"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
autoComplete="current-password"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 rounded-lg bg-rose-500/15 px-3 py-2 text-sm font-medium text-rose-200">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
|
|
{loading && !pendiente ? (
|
|
<Spinner />
|
|
) : (
|
|
<>
|
|
Entrar <ArrowRight className="h-4 w-4" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="mt-5">
|
|
<InstallAppPrompt />
|
|
</div>
|
|
|
|
{DEMO && (
|
|
<p className="mt-5 text-center text-xs" style={{ color: "var(--ld-muted-dark)" }}>
|
|
Demo · cualquier cuenta usa la contraseña{" "}
|
|
<code className="font-mono font-semibold text-white">demo1234</code>
|
|
</p>
|
|
)}
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verificar tipos**
|
|
|
|
Run: `npm.cmd run typecheck`
|
|
Expected: sin errores.
|
|
|
|
- [ ] **Step 3: Correr el test de aceptación completo**
|
|
|
|
Run: `npm.cmd run test:landing`
|
|
Expected: **todas** las comprobaciones en verde, con `13 comprobaciones OK` al final.
|
|
|
|
- [ ] **Step 4: Revisar el login a 390px con el ojo**
|
|
|
|
Run: `npm.cmd run audit:responsive`
|
|
Expected: este comando **va a fallar** en la página `login` porque `PAGES` sigue apuntando a `/`, que ahora es la landing. Eso se arregla en la Tarea 9. Lo que se busca aquí es que el resto de páginas no haya regresionado. Anota los hallazgos de `login` y sigue.
|
|
|
|
---
|
|
|
|
### Task 9: Arreglar los audits existentes
|
|
|
|
Los tres scripts hacen `goto('/')` y a continuación `fill('input[type="email"]')`. Con la landing en `/` ese `fill` falla. El arreglo es un cambio de URL: el formulario sigue visible sin interacción previa.
|
|
|
|
**Files:**
|
|
- Modify: `visual-audit.mjs:16` y `:44`
|
|
- Modify: `responsive-audit.mjs:53` y `:264`
|
|
- Modify: `pwa-e2e.mjs:187, 200, 230, 250, 262, 280, 288`
|
|
|
|
**Interfaces:**
|
|
- Consumes: el contrato de DOM de `/login` que produjo la Tarea 8.
|
|
- Produces: nada que consuman tareas posteriores.
|
|
|
|
- [ ] **Step 1: `visual-audit.mjs` — apuntar el login a `/login` y añadir la landing**
|
|
|
|
En `PAGES` (línea 16), sustituye la entrada `login` y añade `landing` al frente:
|
|
|
|
```js
|
|
const PAGES = [
|
|
{ name: "landing", path: "/", login: false },
|
|
{ name: "login", path: "/login", login: false },
|
|
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
|
|
```
|
|
|
|
En el helper `login()` (línea 44):
|
|
|
|
```js
|
|
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
|
```
|
|
|
|
- [ ] **Step 2: `responsive-audit.mjs` — lo mismo**
|
|
|
|
En `PAGES` (línea 53):
|
|
|
|
```js
|
|
const PAGES = [
|
|
// La landing es pública y no lleva sesión; se audita como cualquier otra página.
|
|
{ name: "landing", path: "/", role: null },
|
|
{ name: "login", path: "/login", role: null },
|
|
{ name: "calendar", path: "/calendar", role: "owner", waitFor: ".fc" },
|
|
```
|
|
|
|
En el helper `login()` (línea 264):
|
|
|
|
```js
|
|
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
|
```
|
|
|
|
- [ ] **Step 3: `pwa-e2e.mjs` — los siete `goto` que esperan el login**
|
|
|
|
`InstallAppPrompt` vive en el login y dentro del panel, no en la landing. Los siete contextos que
|
|
prueban el prompt deben aterrizar en `/login`. Ya existe el helper `url(pathname)` en la línea 12;
|
|
úsalo. Añade una constante junto a `baseUrl` (línea 5):
|
|
|
|
```js
|
|
const loginUrl = new URL("login", BASE.endsWith("/") ? BASE : `${BASE}/`).href;
|
|
```
|
|
|
|
Y sustituye `baseUrl` por `loginUrl` en estos siete `goto`:
|
|
|
|
| Línea | Contexto | Por qué |
|
|
|---|---|---|
|
|
| 187 | `chromiumPage` | Espera el botón de instalar y luego `assertInstallAction(…, "login")`. |
|
|
| 200 | `dismissalPage` | Prueba el descarte del prompt. |
|
|
| 230 | `businessPage` | Hace `login(businessPage, OWNER_EMAIL, …)` justo después. |
|
|
| 250 | `iosPage` | Verifica las instrucciones de iOS del prompt. |
|
|
| 262 | `adminPage` | Hace `login(adminPage, ADMIN_EMAIL, …)` justo después. |
|
|
| 280 | `standalonePage` | Asegura que el prompt **no** aparece en standalone. Sobre la landing pasaría por vacío y no probaría nada. |
|
|
| 288 | `unsupportedPage` | Igual: la ausencia solo significa algo donde el prompt debería estar. |
|
|
|
|
Ejemplo del cambio en la línea 187:
|
|
|
|
```js
|
|
await chromiumPage.goto(loginUrl, { waitUntil: "networkidle" });
|
|
```
|
|
|
|
- [ ] **Step 4: Verificar el audit responsive completo**
|
|
|
|
Requiere `npm run dev` corriendo.
|
|
|
|
Run: `npm.cmd run audit:responsive`
|
|
Expected: sin hallazgos nuevos. Presta atención a dos filas concretas: `landing` en `iphone-12` (390px) no debe reportar `overflow`, y `login` en cualquier dispositivo táctil no debe reportar `ios-zoom` — si lo hace, alguna utilidad `text-sm` está pisando la regla de 16px en un input.
|
|
|
|
- [ ] **Step 5: Verificar el audit visual**
|
|
|
|
Run: `npm.cmd run audit:visual`
|
|
Expected: termina sin errores y deja capturas nuevas en `screenshots/`, incluida `landing-*` en los cuatro viewports. Ábrelas: es la única verificación real de que la estética quedó como se diseñó.
|
|
|
|
- [ ] **Step 6: Confirmar que los tests de API no regresionaron**
|
|
|
|
Run: `npm.cmd run test:unit`
|
|
Then: `npm.cmd run test:e2e`
|
|
Then: `npm.cmd run test:admin`
|
|
Then: `npm.cmd run test:booking`
|
|
Expected: los cuatro en verde. Ninguno depende del ruteo del cliente, así que un fallo aquí significa que se tocó algo del servidor por error.
|
|
|
|
---
|
|
|
|
### Task 10: Verificación de build y del aislamiento del chunk
|
|
|
|
La promesa de que el panel no paga `framer-motion` solo se puede comprobar sobre el build.
|
|
|
|
**Files:**
|
|
- Ninguno. Solo verificación.
|
|
|
|
**Interfaces:**
|
|
- Consumes: todo lo anterior.
|
|
- Produces: nada.
|
|
|
|
- [ ] **Step 1: Build de producción**
|
|
|
|
Detén `npm run dev` antes (el preflight aborta si `:3000` está ocupado).
|
|
|
|
Run: `npm.cmd run build`
|
|
Expected: `tsc -b` limpio y `vite build` exitoso. En la tabla de salida debe aparecer un chunk `motion-*.js` propio. Si no aparece, el `manualChunks` de la Tarea 1 no se aplicó.
|
|
|
|
- [ ] **Step 2: Confirmar que la landing es su propio chunk y que el panel no importa motion**
|
|
|
|
```bash
|
|
node -e "const fs=require('fs');const d='dist/assets';const f=fs.readdirSync(d);console.log('chunks:',f.filter(n=>n.endsWith('.js')).join('\n '));"
|
|
```
|
|
|
|
Expected: la lista incluye un `motion-*.js` y un chunk separado para la landing (Vite lo nombra por el módulo dinámico, típicamente `LandingPage-*.js`). Que existan como archivos distintos del entry es lo que prueba el split.
|
|
|
|
- [ ] **Step 3: Verificar en el navegador que el panel no descarga el chunk de motion**
|
|
|
|
Run: `npm.cmd start`
|
|
|
|
Luego, en el navegador con la pestaña de red abierta:
|
|
1. Ve a `http://localhost:3000/login` y entra con la tarjeta de la dueña.
|
|
2. Filtra las peticiones por `motion`.
|
|
|
|
Expected: **cero** peticiones a `motion-*.js` durante la sesión del panel. `/login` sí lo carga (usa `revealUp`), lo cual es correcto y esperado: es una página pública. Lo que no debe ocurrir es que `/dashboard` o `/calendar` lo pidan.
|
|
|
|
- [ ] **Step 4: Correr el test de PWA sobre el build**
|
|
|
|
Con `npm.cmd start` corriendo en `:3000`:
|
|
|
|
Run: `npm.cmd run test:pwa`
|
|
Expected: `PWA checks passed`. Si falla en `assertInstallAction`, algún `goto` de la Tarea 9 quedó apuntando a `baseUrl` en lugar de `loginUrl`.
|
|
|
|
- [ ] **Step 5: Repasar la landing a mano en móvil real o emulado**
|
|
|
|
Abre `http://localhost:3000/` en un iPhone (o en el emulador de dispositivo de Safari/Chrome a 390px) y recorre la landing entera de arriba abajo.
|
|
|
|
Lista de comprobación:
|
|
- El hero no arranca desplazado ni con zoom.
|
|
- Nada se puede arrastrar en horizontal.
|
|
- Las secciones sticky (3 y 4) no se solapan con la siguiente.
|
|
- El nav no tapa el titular del hero.
|
|
- Los dos CTA llevan a `/login`.
|
|
|
|
- [ ] **Step 6: Verificar con movimiento reducido activado**
|
|
|
|
En macOS: Ajustes → Accesibilidad → Pantalla → Reducir movimiento. En Windows: Configuración → Accesibilidad → Efectos visuales → Efectos de animación (desactivar).
|
|
|
|
Recarga `/` y recorre la landing.
|
|
Expected: todo el contenido visible y legible, sin animaciones. Las secciones 3 y 4 aparecen como pilas estáticas con todos sus visuales presentes, no como paneles vacíos.
|
|
|
|
- [ ] **Step 7: Cierre — dejar registro**
|
|
|
|
Añade a `.superpowers/sdd/progress.md` una entrada con: qué se implementó, el resultado de cada comando de verificación, y cualquier follow-up diferido. **No hagas commit.**
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Cobertura del spec:**
|
|
|
|
| Requisito del spec | Tarea |
|
|
|---|---|
|
|
| `/` sirve la landing; con sesión redirige | 3 (`App.tsx`), verificado en 2 |
|
|
| `/login` enlazable con acceso mágico de un clic | 8, verificado en 2 |
|
|
| `npm run typecheck` limpio | pasos finales de 1, 3, 4, 5, 6, 7, 8; build en 10 |
|
|
| `audit:responsive` sin hallazgos nuevos, landing incluida | 9 pasos 2 y 4 |
|
|
| Cero scroll horizontal | test de la Tarea 2, comprobación 6; audit en 9 |
|
|
| `prefers-reduced-motion` legible y estático | Tarea 2 comprobaciones 7; ramas `reduced` en 3-8; manual en 10 paso 6 |
|
|
| Chunk de motion no se descarga en el panel | 1 paso 3; verificado en 10 pasos 1-3 |
|
|
| Landing con `React.lazy` | 3 paso 1 |
|
|
| Ocho piezas de sección, una por archivo | 3, 5, 6, 7 |
|
|
| Cinco visuales animados | 3 paso 2 (calendario), 4 (los otros cuatro) |
|
|
| Tokens `.landing` en `@layer components` | 1 paso 7 |
|
|
| Los siete `goto` de pwa-e2e | 9 paso 3 |
|
|
| Formulario manual visible sin interacción | 8 paso 1; test de la Tarea 2 |
|
|
| No commits | Global Constraints; Tarea 10 paso 7 |
|
|
|
|
**Consistencia de nombres verificada:** `homePathFor`, `revealUp`, `revealStagger`, `inView`, `EASE_EXPO`, `baseTransition`, `useReducedMotion`, `CalendarGridVisual`, `RevenueLineVisual`, `TeamLoadVisual`, `NotebookVisual`, `DashboardMockVisual`, `LandingNav`, `HeroSection`, `CostSection`, `ShiftSection`, `ProductSection`, `ProofSection`, `ObjectionsSection`, `ClosingSection`. Todos se definen en una tarea antes de consumirse.
|
|
|
|
**Atributos de datos** — definidos en la Tarea 2 y producidos donde corresponde: `data-ld-section` (7 secciones: Hero, Cost, Shift, Product, Proof, Objections, Closing), `data-ld-hero-title` (Tarea 3), `data-ld-closing-cta` (Tarea 7), `data-magic-login` + `data-role` (Tarea 8).
|
|
|
|
**Deuda conocida que este plan deja intacta a propósito:** `npm run lint` sigue roto (ESLint 9 sin config, hueco preexistente), y el modelo de autenticación demo sigue como está. Ninguna de las dos cae en el alcance.
|