From 4c19244df93d606409a620b3fa27e3c13ff876f5 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Tue, 28 Jul 2026 14:25:58 -0600 Subject: [PATCH] feat: public landing page with magic login, brand palette and demo build flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 + .superpowers/sdd/progress.md | 78 + .superpowers/sdd/task-1-report.md | 89 +- .superpowers/sdd/task-2-report.md | 77 +- .superpowers/sdd/task-3-report.md | 164 +- .superpowers/sdd/task-4-report.md | 146 +- CLAUDE.md | 363 +++ Dockerfile | 14 + .../plans/2026-07-28-landing-funnel-bi.md | 2620 +++++++++++++++++ .../2026-07-28-landing-funnel-bi-design.md | 309 ++ index.html | 9 +- landing-e2e.mjs | 202 ++ package-lock.json | 57 +- package.json | 3 + public/sw.js | 6 +- pwa-e2e.mjs | 19 +- responsive-audit.mjs | 654 ++++ src/App.tsx | 122 +- src/components/AdminShell.tsx | 118 +- src/components/AppShell.tsx | 112 +- src/components/BrandMark.tsx | 21 + src/components/DemoSwitcher.tsx | 2 +- src/components/Modal.tsx | 15 +- src/components/landing/ClosingSection.tsx | 109 + src/components/landing/CostSection.tsx | 149 + src/components/landing/HeroSection.tsx | 119 + src/components/landing/LandingNav.tsx | 45 + src/components/landing/ObjectionsSection.tsx | 80 + src/components/landing/ProductSection.tsx | 159 + src/components/landing/ProofSection.tsx | 56 + src/components/landing/ShiftSection.tsx | 118 + .../landing/visuals/CalendarGridVisual.tsx | 84 + .../landing/visuals/DashboardMockVisual.tsx | 142 + .../landing/visuals/FloatingMomentsVisual.tsx | 122 + .../landing/visuals/NotebookVisual.tsx | 69 + .../landing/visuals/RevenueLineVisual.tsx | 93 + .../landing/visuals/SwatchFanVisual.tsx | 120 + .../landing/visuals/TeamLoadVisual.tsx | 54 + src/components/ui.tsx | 21 +- src/index.css | 423 ++- src/lib/motion.ts | 38 + src/lib/useBreakpoint.ts | 45 + src/lib/useReducedMotion.ts | 23 + src/lib/useSidebarCollapsed.ts | 43 + src/pages/CalendarPage.tsx | 145 +- src/pages/DashboardPage.tsx | 2 +- src/pages/EmployeesPage.tsx | 4 +- src/pages/LandingPage.tsx | 31 + src/pages/LoginPage.tsx | 305 +- src/pages/NotificationsPage.tsx | 4 +- src/pages/ServicesPage.tsx | 4 +- visual-audit.mjs | 10 +- vite.config.ts | 15 +- 53 files changed, 7310 insertions(+), 525 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/superpowers/plans/2026-07-28-landing-funnel-bi.md create mode 100644 docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md create mode 100644 landing-e2e.mjs create mode 100644 responsive-audit.mjs create mode 100644 src/components/BrandMark.tsx create mode 100644 src/components/landing/ClosingSection.tsx create mode 100644 src/components/landing/CostSection.tsx create mode 100644 src/components/landing/HeroSection.tsx create mode 100644 src/components/landing/LandingNav.tsx create mode 100644 src/components/landing/ObjectionsSection.tsx create mode 100644 src/components/landing/ProductSection.tsx create mode 100644 src/components/landing/ProofSection.tsx create mode 100644 src/components/landing/ShiftSection.tsx create mode 100644 src/components/landing/visuals/CalendarGridVisual.tsx create mode 100644 src/components/landing/visuals/DashboardMockVisual.tsx create mode 100644 src/components/landing/visuals/FloatingMomentsVisual.tsx create mode 100644 src/components/landing/visuals/NotebookVisual.tsx create mode 100644 src/components/landing/visuals/RevenueLineVisual.tsx create mode 100644 src/components/landing/visuals/SwatchFanVisual.tsx create mode 100644 src/components/landing/visuals/TeamLoadVisual.tsx create mode 100644 src/lib/motion.ts create mode 100644 src/lib/useBreakpoint.ts create mode 100644 src/lib/useReducedMotion.ts create mode 100644 src/lib/useSidebarCollapsed.ts create mode 100644 src/pages/LandingPage.tsx diff --git a/.gitignore b/.gitignore index 3ecae7f..5d4cbe2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ Thumbs.db # OpenCode runtime state (machine-local: sessions, goals) .opencode/ + +# Grafo de conocimiento (regenerable con `graphify update`) +graphify-out/ diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index c5483a9..b918abf 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -43,3 +43,81 @@ 3. Replace local overlapsRange with imported overlaps. 4. Hoist per-slot employee-name SELECT out of slots loop. 5. MonthCalendar nav buttons → h-11 w-11 (44px touch target). + +== DEMO EMAIL DOMAIN TASK == +- Task 1: complete — source constants, README credentials, active fixtures, and executable plan login example updated. Commits 6355d18..50dea33; review clean. +- Task 2: complete — local data/agendapro.db migrated 2 users; second update changed 0; appointments 161 and clients 15 preserved; review clean. +- Task 3: complete with limitation — typecheck/build/unit/admin passed; auth paths passed; e2e/booking slot assertions remain blocked by pre-existing null working-hours data, not the email change; review approved. +- Task 4: complete — committed source/docs through 3e056a0, pushed to GitHub and Gitea, Coolify deployment wbnbq9b0kneba67sznu7p1qf finished, runtime container healthy. +- Task 5: complete — production health/page passed, new admin/owner logins returned 200, old-domain logins returned 401; active DB was fresh and seeded with new emails, so targeted SQL was a no-op. +- Final review: complete — final-fix review approved; regex acceptance fix review approved. + +== AGENDAMAX PWA INSTALLABILITY TASK == +- Task 1: complete — manifest, iOS metadata, reproducible Playwright-generated 192/512 PNG icons, and package scripts. Commits 9d2ab39..de947f9; review clean. +- Task 2: complete — versioned shell/static service worker and production-only registration. Commits de947f9..65233f2; review clean. +- Task 3: complete — install prompt hook/component, iOS Safari guide, safe-area styling, and Login/AppShell/AdminShell integration; review fixes added prompt guard, appinstalled precedence, close-button label, and compact mobile layout. Commits 65233f2..9ded129; review clean. +- Task 4: complete — production-server PWA endpoint/browser tests, online/offline API bypass checks, three-shell coverage, and install documentation; review clean. Commits 9ded129..43f5d13. +- Final fix wave: complete — credential-aware cache policy, AgendaMax-scoped cleanup, session dismissal persistence, configurable PWA test credentials, dialog semantics, and dismissal regression coverage. Commit 0b466f3; final whole-feature review approved. + +== LANDING PÚBLICA + ACCESO DE UN CLIC (2026-07-28) == +Spec: docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md +Plan: docs/superpowers/plans/2026-07-28-landing-funnel-bi.md + +Ruteo: `/` sirve la landing (pública, React.lazy); el login pasa a `/login`. `homePathFor(user)` +centraliza el destino por rol. `/b/:slug` intacto, fuera del AuthProvider. + +Dos iteraciones de diseño a pedido del usuario: +1. Primera versión oscura/sobria (criterio Apple). Entregada y verificada. +2. Rediseño a claro, vívido y animado, con copy humano para dueñas de salón y público de 50+ + (guía de dolores: Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md, Q15/Q72/Q87: + hablar de lo que gana el cliente, no de lo que hace el sistema). Se eliminó todo vocabulario de + sistema del copy visible: ni «business intelligence», ni «datos», ni «métricas», ni «panel». +3. Paleta rebasada sobre los colores REALES del panel a pedido del usuario: las 8 muestras del + abanico del hero son PIE_COLORS de DashboardPage.tsx; la marca sale de public/favicon.svg vía + src/components/BrandMark.tsx (única fuente); CTA = brand-500→brand-700 como .btn-primary. + +Defectos encontrados y corregidos durante la verificación (no estaban en el plan): +- El chunk de entrada arrastraba framer-motion (39 KB gz) a TODA carga del panel, porque LoginPage + era eager. Se hizo lazy junto con la landing. +- La landing descargaba recharts (111 KB gz) y FullCalendar (76 KB gz) sin usarlos, porque + DashboardPage y CalendarPage eran eager. Ambas a lazy, con Suspense en el de AppShell. +- `clsx` lo comparten lib/format.ts y recharts; Rollup lo asignaba al chunk `charts`, así que el + entry importaba 111 KB para una utilidad de 200 bytes. Fijado al chunk `react`. +- LOGIN CON EL TEXTO PEGADO AL BORDE en móvil: `.safe-x` está fuera de @layer y gana a `px-5`, + dejando el padding en 0 con inset 0 (todo lo que no sea iPhone landscape). Nueva clase `.ld-gutter`. + Ningún check existente lo detectaba (no hay desborde: hay cero margen) → añadido check `gutter`. +- `shell-height` del audit daba falso positivo en toda página que scrollea: comparaba #root contra + el viewport vía el fallback a body.firstElementChild. Acotado a [data-app-shell]; a cambio se + añadió el check estático `raw-viewport-unit` sobre las fuentes públicas (verificado inyectando + `min-h-screen` en ProofSection: dispara). +- Rótulo «Como una de tus muchachas» en /login con Diego Castillo en la lista debajo. Reescrito sin + asumir género («Como alguien de tu equipo»); igual en ObjectionsSection. +- Anclas bajo el nav fijo (scroll-mt-24); degradado del cierre invisible tras `-z-10`; etiquetas del + abanico ilegibles (eliminadas); avisos flotantes solapando el abanico (reubicados a las 3 zonas + libres) y detalle truncado (acortado). + +Audits ajustados por el cambio de ruteo (`/` → `/login`): visual-audit.mjs, responsive-audit.mjs y +los SIETE `goto(baseUrl)` de pwa-e2e.mjs que esperan InstallAppPrompt. La landing se añadió a las +listas de páginas de los dos audits. + +VERIFICACIÓN FINAL (todo en verde): +- typecheck: 0 errores +- test:landing (NUEVO, WebKit): 13/13 — ruteo, acceso de un clic, sin desborde a 390px y + prefers-reduced-motion sin contenido invisible en las 7 secciones +- audit:responsive: 0 hallazgos (9 dispositivos × 10 páginas) +- audit:visual: 62 pantallas, 0 fallos, 0 desborde, 0 errores de consola +- test:pwa sobre el build: passed +- test:unit 39/39 · test:e2e 33/33 · test:admin 17/17 · test:booking 12/12 +- build: el chunk de entrada NO importa charts, framer ni calendar (verificado en dist/) +- WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin + objetivos <40px, sin errores + +Sin commits (política del repo). `npm run lint` sigue roto por falta de eslint.config.js (preexistente). + +FOLLOW-UPS DIFERIDOS: +1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta). + No es un defecto medible; revisar si molesta en uso real. +2. La landing no tiene contenido de precios ni captura de leads: en fase demo la conversión es entrar + a la demo. Habrá que decidirlo antes de un lanzamiento real. +3. Seguridad sin cambios: `/login` expone contraseñas de cuentas existentes bajo la bandera DEMO. + Autorizado solo para esta fase; es lo único que separa esto de una fuga si se publica. diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index f6cd60e..770a7e9 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -1,74 +1,37 @@ -# Task 1 Report - AgendaMax demo access references +# Task 1 Implementation Report -## Status +## Scope -Complete for the source and documentation layer. Local and production SQLite data were not touched. +Implemented only the static AgendaMax PWA contract from Task 1. Existing worktree changes and graphify outputs were not modified. -## Changed files +## Changed Files -Implementation commit `6355d18a07b5bef6fd3515b80a0f768b0ae1050b` (`docs: standardize AgendaMax demo access`) changed: +- `scripts/generate-pwa-icons.mjs`: reproducible Playwright generator using the existing `public/favicon.svg` as a base64 data URL. +- `public/icon-192.png`: generated 192x192 PNG install icon. +- `public/icon-512.png`: generated 512x512 PNG install icon. +- `public/manifest.webmanifest`: exact AgendaMax manifest metadata and icon declarations. +- `index.html`: manifest link, Apple install metadata, and `viewport-fit=cover`; existing favicon, theme, and description preserved. +- `package.json`: added `generate:pwa-icons` and `test:pwa` scripts. -- `README.md` - added an explicit `Contraseña` column with `demo1234` for all eight demo accounts; changed only Admin and Dueño to `@agendamax.demo`. -- `server/index.ts` - changed the fresh default owner email. -- `server/scripts/seed.ts` - changed fresh admin and owner emails; retained `demo1234`. -- `src/pages/LoginPage.tsx` - changed only the development default owner email. -- `admin-test.mjs`, `e2e-test.mjs`, `e2e-full.mjs`, `server/scripts/booking-e2e.mjs`, `visual-audit.mjs` - aligned active platform login fixtures and assertions. -- `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` - aligned its copy-pasteable booking login example. +## Verification -The current untracked Task 1 brief, design, and migration plan were not modified or staged. The completed Task 5 report was classified as historical and was not modified. No employee email domain or password was changed. +- `node scripts/generate-pwa-icons.mjs`: exit 0, no output. +- PNG signature/dimensions Node check: passed; `public/icon-192.png` is PNG 192x192 and `public/icon-512.png` is PNG 512x512. +- Reproducibility check: passed. SHA256 values remained stable across regeneration: + - 192 icon: `A5073D1EA7662F7E90D4E8E1DF12FF563A6DCC161FFCF6C4591D7C5F2695F6FE` + - 512 icon: `19914C9D4C3F626782C85920DAD9E16B069809B5D876A903BF88695E1F9F045F` +- `npm.cmd run typecheck`: exit 0. +- `npm.cmd run build`: exit 0; Vite transformed 2463 modules and emitted the production bundle. +- Dist asset/manifest check: passed; `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist and the manifest contract is valid. +- `git diff --check`: no whitespace errors. Git emitted only existing line-ending normalization warnings. -## TDD evidence +## Self-Review -### RED - -The five executable login fixtures were updated before the source constants. A read-only source assertion was then run against `server/index.ts`, `server/scripts/seed.ts`, and `src/pages/LoginPage.tsx`. It failed as expected with `AssertionError` because `owner@agendamax.demo` and `admin@agendamax.demo` were not yet present while the old source constants remained. - -The focused runtime visual check was also attempted before source changes: - -```text -npm run audit:visual -page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:5173/ -``` - -This did not reach the application and did not touch the database. - -### GREEN - -After the minimal source and README edits, the corrected read-only assertion passed: - -```text -source and active login fixtures: PASS -``` - -The final active-reference and README credential assertion also passed: - -```text -active references and README credentials: PASS -``` - -## Tests and outputs - -- `npm run typecheck` - passed, exit code 0; `tsc -b --noEmit` reported no errors. -- `npm run build` - passed, exit code 0; Vite transformed 2463 modules and built in 5.63s. -- `npm run test:unit` - passed: 39 tests, 39 pass, 0 fail. -- `npm run audit:visual` - unavailable: no server was listening on `localhost:5173` (`ERR_CONNECTION_REFUSED`). -- `npm run lint` - unavailable: ESLint 9.39.5 could not find `eslint.config.js`, `eslint.config.mjs`, or `eslint.config.cjs`. -- `rg -n ...` - unavailable because `rg` is not installed/on PATH in this environment. The equivalent scoped repository search found no old domain in active application, README, fixture, or executable plan references. -- `git diff --cached --check` - passed before the implementation commit. - -The database-mutating API suites (`test:e2e`, `test:admin`, and `test:booking`) were not run because this task explicitly forbids touching the ignored local database. They remain for the post-migration verification task. - -## Self-review - -- The quick-login behavior is unchanged: `LoginPage` still loads `/api/auth/demo-users`, and the existing password and account-switching logic remain intact. -- Only platform-owned Admin and Dueño domains changed to `@agendamax.demo`; all employee domains remain on their existing business domains. -- All documented demo rows explicitly show `demo1234`, including every employee row. -- No migration, abstraction, production command, local database command, `.env.local.ps1`, or `data/` file was added or changed. -- The implementation commit contains exactly the ten intended Task 1 files and excludes the supplied untracked design/plan files. -- Remaining `@agendapro.demo` matches are intentional migration/history references in the new migration plan/spec and the completed Task 5 report. +- The browser-tab icon remains `/favicon.svg`. +- The manifest uses `/` for both `start_url` and `scope`, standalone display, portrait-primary orientation, the requested colors, and PNG icon declarations. +- The generator resolves paths from its own module location, closes pages and the browser, and produces deterministic 1x screenshots. +- No unrelated tracked or untracked worktree files were staged. ## Concerns -- Live/API and visual login verification is pending a running dev server and the later SQLite migration; those prerequisites were intentionally not started in this task. -- Lint remains unavailable until the repository ESLint 9 configuration is supplied or the project pins a compatible ESLint setup. -- The exact `rg` inventory command cannot be reproduced until ripgrep is installed/on PATH. +- `npm.cmd run test:pwa` was not run because `pwa-e2e.mjs` is intentionally a Task 4 deliverable and does not exist at this task boundary. The script is present as required for the later task. diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md index 6e1957e..1f579c6 100644 --- a/.superpowers/sdd/task-2-report.md +++ b/.superpowers/sdd/task-2-report.md @@ -1,61 +1,38 @@ -# Task 2 Report — scheduling.ts pure functions + unit tests +# Task 2 Implementation Report -## Status -✅ COMPLETE — all acceptance criteria met. +## Scope -## Files changed -- **Created** `server/lib/scheduling.ts` — 15 pure exports (types + functions), zero imports. ~95 lines. -- **Created** `server/lib/scheduling.test.ts` — 15 `test()` blocks using `node:test` + `node:assert/strict`. -- **Modified** `package.json` — added `"test:unit": "node --import tsx --test server/lib/scheduling.test.ts"` to `scripts`. +Implemented only the production AgendaMax service worker and its production-only registration. Existing `.superpowers/sdd/*` changes and `graphify-out/*` outputs were preserved and were not staged. + +## Changed Files + +- `public/sw.js`: added versioned shell/static caching with `agendamax-shell-v1`, install/activate lifecycle handling, network-first navigation fallback, and cache-first handling limited to script, style, image, font, manifest, and worker requests. +- `src/main.tsx`: registered `/sw.js` after the React root render only when `import.meta.env.PROD` and service workers are supported. ## Verification -### `npm run typecheck` → 0 errors -``` -> agenda-pro@1.0.0 typecheck -> tsc -b --noEmit -(clean — no diagnostics) -``` -`allowImportingTsExtensions: true` is set in `tsconfig.json`, so `import { ... } from "./scheduling.ts"` resolves correctly under tsc. +- `npm.cmd run typecheck`: exit 0. -### `npm run test:unit` → all PASS -``` -✔ parseWorkingHours: json válido → mapa 1..7 -✔ parseWorkingHours: null/invalid → null -✔ isoDayOfWeek: lunes=1, domingo=7 -✔ getWorkingHoursForDate: empleado tiene prioridad sobre negocio -✔ getWorkingHoursForDate: día cerrado → null -✔ normalizeText/tokens: quita acentos y lowercase -✔ specialtyMatch: coincidencia exacta de etiqueta → 1 -✔ specialtyMatch: etiqueta con acento/case → 1 -✔ specialtyMatch: sin etiquetas → 0.5 -✔ specialtyMatch: etiqueta no relacionada → 0.5 -✔ overlaps: bordes inclusivos de no-traslape -✔ hasConflict: lista vacía → false -✔ scoreCandidate: pesos 50/30/20 -✔ scoreCandidate: mayor efficiency → mayor score -✔ scoreCandidate: menor load (más disponible) → mayor score -ℹ tests 15 -ℹ pass 15 -ℹ fail 0 -ℹ duration_ms 201.8453 -``` + ```text + > agenda-pro@1.0.0 typecheck + > tsc -b --noEmit + ``` -**Summary line:** `# tests 15 | # pass 15 | # fail 0 | duration 201.8 ms` +- `npm.cmd run build`: exit 0. Vite transformed 2463 modules and emitted the production bundle. +- `Test-Path -LiteralPath "dist/sw.js"`: `True`; Vite copied the public worker to the production output. +- `git diff --check`: no whitespace errors. Git emitted only LF-to-CRLF normalization warnings for existing/changed working-tree files. -## Deviations from plan -- **None.** The plan said "≈14 tests" but the spec actually contains 15 `test()` blocks — all 15 are present and pass. Code is verbatim from the plan (only the pure functions; no DB imports added — those belong to Task 3). +## Self-Review -## Self-review - -- **All 15 tests present?** ✅ Counted 15 `test(` calls in the file; runner reports `tests 15 / pass 15`. -- **`scoreCandidate` verifies 50/30/20 weights?** ✅ The "pesos 50/30/20" test asserts three exact points on the response surface: - - `(1, 1, 0)` → 100 (50·1 + 30·1 + 20·1) - - `(0, 0, 1)` → 0 (all terms zero) - - `(0.5, 0, 1)` → 25 (only specialty term contributes: 50·0.5 = 25) - These three points uniquely pin the 50/30/20 coefficients — any other weights would fail at least one assertion. The two follow-up tests ("mayor efficiency", "menor load") additionally confirm the monotonic direction of each knob. -- **`overlaps` boundary case verified?** ✅ The "bordes inclusivos de no-traslape" test explicitly asserts `overlaps(100, 200, 200, 300) === false` — i.e. one window ending exactly when another starts is **not** an overlap. Two true-overlap cases (forward and reverse order) are also covered. -- **No DB leakage?** ✅ `scheduling.ts` has zero `import` statements and does not reference `DatabaseSync`, `db`, or any module. Task 3 can append the DB section cleanly. +- Cache name and shell URLs match the Task 2 contract exactly. +- Activation removes older cache names and claims clients after activation. +- Fetch handling rejects non-GET requests, cross-origin requests, and paths beginning with `/api/` before any response interception. +- Navigation requests use network-first behavior and fall back to the cached root document. +- Static caching is allowlisted by request destination; there is no catch-all cache path. +- Registration is deferred until `load`, uses `{ updateViaCache: "none" }`, and registration failure is non-fatal. +- Only `public/sw.js` and `src/main.tsx` are intended for the Task 2 commit. ## Concerns -None. Ready for Task 3. + +- Validation is static/build-level only; browser service-worker runtime behavior is intentionally left to the later PWA end-to-end task. +- `git diff --check` reports normal line-ending normalization warnings from the existing PowerShell/Git configuration, not whitespace errors. diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md index ec2c88b..5507cc3 100644 --- a/.superpowers/sdd/task-3-report.md +++ b/.superpowers/sdd/task-3-report.md @@ -1,41 +1,145 @@ -# Task 3 Report — scheduling.ts DB functions +# Task 3 Report: Add Install Detection and UI -## Files changed -- `server/lib/scheduling.ts` — appended DB-backed functions from Task 3 plan (`CandidateInfo`, `safeArr`, `getCandidates`, `getExistingBusy`, `isAvailable`, `rankOne`, `pickBestSlotEmployee`, `AutoAssignResult`, `AutoAssignCtx`, `autoAssign`, `runInTransaction`) + `import type { DatabaseSync } from "node:sqlite"`. +## Status -## Typecheck -`npm run typecheck` → **0 errors** (after the deviation below). +Implemented Task 3 only. The install hook detects standalone mode, Chromium's deferred install event, and iOS Safari instructions. The reusable prompt is rendered once in each required shell surface and uses the existing modal/button/icon patterns. -## test:unit -`npm run test:unit` → **15/15 pass** (pure tests unaffected). +## Changed files -## tx smoke-test output -Temp file `_tx-smoke.mjs` (deleted after run). It imports `runInTransaction` and `db`, exercises happy path + throw path, and verifies a real `UPDATE` was rolled back. +- `src/lib/useInstallPrompt.ts`: added the browser-only install state hook, local deferred-event type, event listener lifecycle, install action, and dismissal behavior. +- `src/components/InstallAppPrompt.tsx`: added the reusable Chromium/iOS install action and iOS instructions modal. +- `src/index.css`: added `.safe-area-bottom` using `env(safe-area-inset-bottom)`. +- `src/pages/LoginPage.tsx`: added one compact install action below the login form. +- `src/components/AppShell.tsx`: added one install action to the mobile header. +- `src/components/AdminShell.tsx`: added one install action to the mobile header. -``` - PASS runInTransaction returns fn value (42) - PASS no active tx after commit - PASS runInTransaction rethrows on error - PASS no active tx after rollback - PASS rollback undid the UPDATE -5/5 passed -EXIT=0 +## Verification + +### Typecheck + +Command: + +```text +npm.cmd run typecheck ``` -`PRAGMA active_transaction` was used (newer node:sqlite exposes it; falls back to a no-row / 0 object) to confirm no dangling transaction after both commit and rollback. The real-state rollback check (UPDATE `_tx_probe SET v=999` inside a throwing fn) proves the ROLLBACK is functional, not just emitted. +Output: -## Deviations -1. **Plan typo fix in `autoAssign` (line 256).** The plan literally wrote: - ```ts - if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) { - ``` - but inside `autoAssign` the loop variable `c` is a `CandidateInfo` (no `.info` field), so `c.info.id` is a type error (TS2339). Fixed to `c.id < best.info.id`. `best` is still a `RankedCandidate` so `best.info.id` is correct. The semantic intent (tie-break by lowest employee id) is preserved exactly. Note: the sibling function `pickBestSlotEmployee` uses `c.id` correctly in the plan — confirming the autoAssign line was a typo, not an intentional shape. +```text +> agenda-pro@1.0.0 typecheck +> tsc -b --noEmit +``` -No other deviations. The `import type { DatabaseSync }` is placed mid-file (after `scoreCandidate`), which is valid because ES module `import` declarations are hoisted; `tsc -b --noEmit` and `tsx` both accept it. +Exit code: `0`. + +### Production build + +Command: + +```text +npm.cmd run build +``` + +Output summary: + +```text +Preflight OK: Node v26.4.0. +vite v5.4.21 building for production... +✓ 2465 modules transformed. +✓ built in 4.08s +``` + +Exit code: `0`. + +### Diff validation + +Command: + +```text +git diff --check +``` + +Result: no whitespace errors. Git emitted only existing LF/CRLF conversion notices for worktree files. ## Self-review -- **Does `autoAssign` return `null` when no candidate is free?** YES. Two null paths: - 1. `candidates.length === 0` → immediate `return null` (line 245). - 2. After the loop, `if (!best) return null` (line 260) covers the case where every candidate was filtered out (closed that day, slot outside working window, or has a conflict). -- **Does it respect per-employee working hours?** YES. For each candidate it calls `getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs))` — employee override wins, business is the fallback, and a closed day returns `null` → candidate is `continue`d. Then the slot must satisfy `ctx.startMs >= wh.startMs && ctx.endMs <= wh.endMs`, otherwise the candidate is skipped. -- **Does `runInTransaction` roll back on throw?** YES. Verified by smoke test: a thrown error inside `fn` causes `db.exec("ROLLBACK")` to run, the error is re-thrown, and a real `UPDATE` made inside the throwing fn is undone (the probe row retained its pre-tx value). + +- The hook uses a local `BeforeInstallPromptEvent` interface and does not add an unsafe global declaration. +- `beforeinstallprompt` and `appinstalled` listeners are removed on unmount. +- Standalone detection suppresses the CTA, and unsupported browsers remain unchanged. +- iOS instructions are limited to Safari and the existing `Modal` owns backdrop/Escape close behavior. +- The safe-area utility is applied only inside the iOS modal content. +- Each shell renders exactly one prompt instance outside `SidebarContent`, avoiding duplicate listeners from the desktop/mobile sidebar duplication. +- The accessible action text remains `Instalar AgendaMax` in every supported prompt. +- No Task 4 tests or documentation changes were added. + +## Concerns + +- No browser-level visual/manual check was run; verification was limited to the required typecheck, production build, and diff inspection. +- `git diff --check` reports line-ending notices from the existing Windows worktree configuration, not whitespace failures. + +## Preserved worktree changes + +Existing `.superpowers/sdd/*` changes and untracked `graphify-out/*` artifacts were not reverted or staged. + +## Review Fixes + +- `src/lib/useInstallPrompt.ts`: added a ref-based in-flight guard so rapid clicks can invoke the native prompt only once. The deferred event is restored when `prompt()` or `userChoice` fails, the error is contained, and the state returns to `available` for retry. `appinstalled` now clears dismissal before setting `installed`. +- `src/components/Modal.tsx`: added `aria-label="Cerrar"` to the shared icon-only close button. +- `src/components/InstallAppPrompt.tsx`: added an explicit `compact` presentation. Header instances use a fixed icon button with a screen-reader label and tooltip, while Login keeps the full visible action label. +- `src/components/AppShell.tsx` and `src/components/AdminShell.tsx`: enabled the compact prompt presentation in the narrow mobile headers. + +## Review Fix Verification + +### Typecheck + +Command: + +```text +npm.cmd run typecheck +``` + +Output: + +```text +> agenda-pro@1.0.0 typecheck +> tsc -b --noEmit +``` + +Exit code: `0`. + +### Production build + +Command: + +```text +npm.cmd run build +``` + +Output summary: + +```text +Preflight OK: Node v26.4.0. +vite v5.4.21 building for production... +✓ 2465 modules transformed. +✓ built in 3.95s +``` + +Exit code: `0`. + +### Diff inspection + +`git diff --check` reported no whitespace errors. The only messages were existing Windows LF/CRLF conversion notices. The implementation diff contains only the hook, shared prompt, shared modal accessibility, and two mobile header call-site fixes described above. + +## Review Fix Self-Review + +- A second click while `installInFlight.current` is true returns immediately, including clicks occurring after React has scheduled the deferred-event state update. +- Failed native prompt or choice promises no longer produce an unhandled rejection and restore the captured event for a retryable `available` state. +- A later `appinstalled` event overrides any prior dismissal and returns the hook state as `installed`. +- Both modal close mechanisms remain intact, and the icon-only close control now has an accessible name. +- Header install controls are compact without hiding the action from assistive technology; the full accessible name remains `Instalar AgendaMax`. +- No unrelated worktree files were modified or staged, and no Task 4 tests or documentation were added. + +## Remaining Concerns + +- No browser-level visual/manual check was run; verification is limited to typecheck, production build, and source diff inspection. +- The existing Windows worktree continues to emit LF/CRLF conversion notices during Git operations. diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md index ee156b3..323afc7 100644 --- a/.superpowers/sdd/task-4-report.md +++ b/.superpowers/sdd/task-4-report.md @@ -1,89 +1,79 @@ -# Task 4 Report — backend `booking.ts`: slots con working_hours + `pickBestSlotEmployee` +# Task 4 Report -**Branch:** `feat/auto-assign-specialist` -**Date:** 2026-07-26 -**Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` (Task 4) +## Files -## Files changed -- `server/routes/booking.ts` — only file modified by this task. Diff: **+56 / −31** lines (`git diff --stat`). - -No other files touched. `POST /:slug/book` left exactly as-is (Task 5 territory). - -## What changed -1. **Imports (top of file).** Added scheduling imports from `../lib/scheduling.ts`: - `parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy, pickBestSlotEmployee, type WorkingHoursMap`. -2. **`publicBusiness(slug)`** now SELECTs two extra columns: `auto_assign_specialist, working_hours` (so the public business payload exposes them for the frontend). -3. **`GET /:slug/slots`** computation block fully rewritten: - - `bizWh = parseWorkingHours(biz.working_hours)` — real business working-hours map. - - When `employeeId` is **absent** (`useRanking = !employeeId`): pre-fetches ranked `CandidateInfo[]` via `getCandidates(...)` and the per-employee busy map via `getExistingBusy(...)`, then for every grid step calls `pickBestSlotEmployee(...)` to attach the best-ranked free specialist. - - When `employeeId` is **provided**: resolves the per-employee working-hours window (falling back to business hours) and uses the existing busy list with the local `overlapsRange` helper. - - Day window comes from `getWorkingHoursForDate(...)` — **no more hardcoded `09:00`/`20:00`**. - - If the resolved window is `null` (business closed that day) → `res.json({ slots: [], service: {...} })`, no throw. - - 30-min grid, `t + dur*60000 <= dayEnd` end-clamp, `t < now+30min` skip, 40-slot cap, and the response shape `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` are all preserved. -4. Added a tiny module-local `overlapsRange(a,b,c,d)` helper (equivalent to `overlaps` in scheduling.ts; the plan suggested this to keep imports minimal). +- Added `pwa-e2e.mjs` with production endpoint, manifest, SPA fallback, Chromium install prompt, iOS instructions, standalone/unsupported states, service-worker readiness/source checks, and real browser `/api/health` request verification. +- Updated `README.md` with local production validation, Android/Chrome installation, iOS Safari instructions, standalone behavior, connectivity expectations, and Coolify HTTPS validation guidance. ## Verification -### `npm run typecheck` -``` -> agenda-pro@1.0.0 typecheck -> tsc -b --noEmit -``` -**0 errors.** ✓ +- `node --check pwa-e2e.mjs`: passed. +- `npm.cmd run typecheck`: passed. +- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`. +- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000. +- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit reported 8 console 404 messages while still exiting 0. +- `git diff --check`: passed with no whitespace errors. Git emitted existing LF-to-CRLF working-copy warnings for tracked files. -### Smoke test (live server on :3000, seeded business `lumiere-estetica-spa`) -Wrote a throwaway `.mjs` script (deleted after) that logs in as owner, reads `/api/settings` for the slug, then hits `/api/public/` and `/api/public//slots`. - -**Public business now exposes the new fields:** -``` -[info] public.business.auto_assign_specialist = 0 -[info] public.business.working_hours = string -[ok] publicBusiness() exposes auto_assign_specialist + working_hours -``` - -**Slots — auto-assign path (no `employee_id`, uses `pickBestSlotEmployee`):** -Service `id=6` "Corte + arreglo de barba" (Barbería, 45 min), date `2026-07-27` (Monday, dow=1). -``` -[info] slots status=200 count=18 -[info] service in resp = { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 } -[sample] first slot = { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' } -[sample] last slot = { time: '07:00 p.m.', iso: '2026-07-28T01:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' } -[ok] all 18 slots have employee_id+employee_name and iso within 09:00-20:00 (inWindow=true) -``` -- 18 slots, each carries `employee_id` + `employee_name` (resolved by `pickBestSlotEmployee`). -- Window strictly within the seed's Lun-Vie 09:00–20:00. -- Only Mateo Herrera offers service #6 in the seed, so the ranker correctly resolves every slot to him. - -**Closed-day path (Saturday, default seed = closed):** -``` -[info] Saturday 2026-08-01 slots count=0 (expected 0 if Sat closed) -[ok] closed day → { slots: [] } with service object (no throw) -``` -The endpoint returns `{ slots: [], service: {...} }` — no exception, matching constraint #5. - -**Specific-employee path (`employee_id=2`, the only employee offering svc #6):** -``` -status: 200 count: 18 -all match eid: true -first: { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' } -service in resp: { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 } -``` -Every slot's `employee_id === 2`; service object included. - -## Deviations from the plan -1. **Dropped `isoDateStr` from the Task-4 import list.** The plan's Task-4 import block includes `isoDateStr`, but the Task-4 code never references it, and `tsconfig.json` has `"noUnusedLocals": true` — keeping it would fail the `npm run typecheck` verification step. Task 5 ("Ampliar imports de scheduling") explicitly adds more imports and can re-introduce `isoDateStr` at that point when it's actually used by the `POST /book` rewrite. Functionality identical. -2. **Renamed `const any = …` → `const anyEmp = …`** in the fallback query inside the candidates block. `any` is a reserved-ish TypeScript type name and using it as a value identifier is confusing and risky under strict mode. Purely cosmetic; behavior identical. +The first PWA run exposed a test-harness race where the synthetic event could fire before React registered its listener. The harness was corrected to dispatch 100 ms after `load`; the subsequent production run passed. ## Self-review -- **Does the day window come from `working_hours` (not the old hardcoded 09/20)?** Yes. The old `new Date(${date}T09:00:00)` / `T20:00:00` literals are gone. The window now comes from `getWorkingHoursForDate(singleEmpWh | null, bizWh, dateObj)`, which reads `parseWorkingHours(biz.working_hours)`. Confirmed empirically: a Saturday (closed in seed) returns 0 slots, while a Monday returns 18 slots within 09:00–20:00. -- **When `employeeId` is null, does it use `pickBestSlotEmployee`?** Yes. `useRanking = !employeeId` gates the branch; when true, the per-slot resolver is `chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, {name, category}, slotStart, slotEnd)`. The previous "first free employee" inner loop is removed. -- **Response shape preserved?** Yes — `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` verified in all three paths (ranking, specific-employee, closed-day). -- **30-min grid, 40-slot cap, skip-past/<30min-from-now?** All preserved (loop body unchanged in spirit; observed 18 slots, well under 40). -- **Closed day → `{slots: []}` + service object, no throw?** Confirmed. -- **POST /book untouched?** Yes — diff shows the entire POST handler is byte-identical to the pre-task version. +- Endpoint checks use the configured `PWA_BASE_URL`, defaulting to `http://localhost:3000`. +- Manifest assertions cover standalone display, root start URL/scope, and both required icon sizes. +- Browser contexts are isolated for Chromium prompt, iOS Safari instructions, standalone mode, and unsupported/no-event mode. +- Service-worker readiness has a five-second bound, and the source guard checks cover `/api/` and non-GET requests. +- The browser API check observes a real request and validates its JSON response rather than using a fixture. +- No app authentication, tenant data, API route, database schema, or runtime behavior was changed. ## Concerns -- The seed only has one employee (Mateo Herrera) offering service #6, so the ranker resolves every slot to the same person. To genuinely exercise the multi-candidate ranking in integration, Task 7's `booking-e2e.mjs` (or a manual second employee with overlapping service + different `efficiency_score` / `specialties`) would be needed. Out of scope for Task 4. -- `biz.working_hours` is returned to the frontend as a raw JSON **string** (not parsed). Frontend will need to `JSON.parse` it — that's expected per `shared/types.ts` (`working_hours?: WorkingHoursMap | string | null`) and consistent with how Settings already returns it. No action needed here. -- A leftover orphan `tsx watch` process from an initial failed `Start-Process` attempt was killed during cleanup; the user's main `npm run dev` (concurrently) was left untouched and is still serving on :3000. + +- The visual audit continues to record eight pre-existing 404 console messages on some authenticated/admin visits. They were not introduced by Task 4 and were not changed because the task explicitly limits scope to PWA verification and production documentation. +- The report itself and pre-existing `.superpowers/sdd/*` and `graphify-out/*` changes remain outside the Task 4 commit staging set. + +## Review Fixes + +- Added `fetchWithTimeout()` using `AbortController` for every direct endpoint/source fetch. The in-page `/api/health` request now uses its own bounded `AbortController` as well. +- Added exact-one accessible-action coverage for the login page, authenticated business shell, and authenticated admin shell. Business/admin flows use the existing demo credentials and a mobile viewport so their compact install controls are visible and accessible. +- Replaced fixed sleeps in standalone/unsupported checks with bounded DOM-state polling for zero matching install actions. +- Replaced iOS body-text checks with visible exact-text locators for `Compartir` and `Añadir a pantalla de inicio`. +- Relaxed service-worker source checks to tolerate whitespace and quote style while still requiring the pathname `/api/` bypass and non-GET guard. +- Tracked all browser contexts and closes them explicitly before closing the browser in `finally`; default UI/navigation timeouts are bounded. + +## Review-Fix Verification + +- `node --check pwa-e2e.mjs`: passed. +- `npm.cmd run typecheck`: passed. +- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`. +- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000. +- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit still reports 8 pre-existing console 404 messages. +- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings. + +## Review-Fix Self-review + +- Direct network operations now have bounded completion, including response-body reads for endpoint/source assertions and the browser-side API response. +- Login, business, and admin each assert one and only one accessible `Instalar AgendaMax` action; the two shell checks exercise the authenticated UI path rather than assuming route markup. +- Negative install-state checks wait on the DOM condition with a timeout and fail non-zero if the condition never becomes true. +- All changes remain in the PWA test harness and this report; application behavior and unrelated tests are unchanged. + +## Remaining Concerns + +- The visual audit's 8 pre-existing 404 console messages remain outside Task 4 scope. They do not cause failed loads, overflow failures, or a non-zero audit exit. + +## Service-worker Behavioral Fix + +- Added a reload and `navigator.serviceWorker.controller` gate before behavioral API checks, ensuring requests run through an active, controlling worker. +- While online, the browser now verifies `GET /api/health` returns `200` and a safe invalid `POST /api/auth/login` returns `401`. +- With the Playwright context offline, both API requests must produce bounded network errors rather than cached responses. Connectivity is restored in a `finally` block. + +## Service-worker Behavioral Verification + +- `npm.cmd run typecheck`: passed. +- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`. +- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000. +- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings. + +## Service-worker Behavioral Self-review + +- The online assertions validate real statuses (`200` and `401`) before offline mode is enabled, so a cached fixture cannot satisfy the positive path. +- Offline assertions require both requests to reject with a network-error result and do not accept a response status or body. +- The offline state is always reverted in `finally`, and the existing bounded request, navigation, worker-readiness, UI, install-surface, and cleanup checks remain intact. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e12a8ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,363 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +> El código, la UI y los mensajes de error de este repo están en **español**. Mantén ese idioma en +> strings visibles al usuario y en mensajes de error de la API. + +## Requisitos del entorno + +- **Node.js >= 22.5** (recomendado 24+). El servidor usa `node:sqlite` nativo: no hay dependencia + de compilación nativa, pero tampoco funciona en Node < 22.5. `scripts/preflight.mjs` valida esto + antes de `dev`, `build` y `start`. +- Windows/PowerShell: si `npm.ps1` está bloqueado por execution policy, usa **`npm.cmd`**. +- `.npmrc` apunta la caché de npm a `.cache/npm` dentro del proyecto (el caché global no siempre es + escribible en las máquinas de este proyecto). Instala con `npm run setup` (`npm ci`), no con + `npm install`, para no mutar el lockfile. +- `scripts/run-tsx.mjs` envuelve a `tsx` con un fallback de `os.userInfo()`; todo script de servidor + se lanza a través de él, nunca con `npx tsx` directo. + +## Comandos + +```bash +npm run setup # npm ci con caché local (instalación recomendada) +npm run dev # preflight + server (:3000) y vite (:5173) en paralelo +npm run dev:background # lo mismo, desacoplado; logs en .cache/runtime/dev.*.log +npm run build # tsc -b && vite build -> dist/ +npm start # producción: sirve API + dist/ en :3000 +npm run typecheck # tsc -b --noEmit <-- este es el gate real de calidad +npm run seed # regenera la DB demo +``` + +Overrides de puerto sin tocar código (preflight aborta si el puerto está ocupado): + +```bash +PORT=3001 VITE_PORT=5174 API_URL=http://127.0.0.1:3001 npm run dev +$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev +``` + +`RESET_DB=1` borra `data/agendapro.db` al arrancar y vuelve a sembrar desde cero. + +### Tests + +```bash +npm run test:unit # node:test sobre server/lib/{scheduling,time,metrics}.test.ts (puro, sin servidor) +npm run test:e2e # API: login, CRUD, dashboard, roles, aislamiento -> requiere `npm run dev` +npm run test:admin # consola admin: alta con plantilla, reset-demo, cascade -> requiere `npm run dev` +npm run test:booking # booking público: auto-asignación y 409 -> requiere `npm run dev` +npm run audit:visual # Playwright: 10 páginas x 4 viewports -> screenshots/ +npm run test:pwa # requiere `npm run build` + `npm start` (golpea :3000) +``` + +Detalle importante: los `.mjs` de e2e/admin/booking/visual apuntan a **`http://localhost:5173`** +(pasan por el proxy de Vite), mientras que `pwa-e2e.mjs` apunta a **`:3000`** porque necesita el +build servido y el service worker (que solo se registra en `import.meta.env.PROD`). +`PWA_BASE_URL` permite apuntarlo a otro host; la validación real de instalabilidad exige un dominio +**HTTPS**, no una IP. + +Un solo test unitario: + +```bash +node --import tsx --test server/lib/scheduling.test.ts +node --import tsx --test --test-name-pattern="autoAssign" server/lib/scheduling.test.ts +``` + +**`npm run lint` está roto** (ESLint 9 requiere `eslint.config.js` y el repo no tiene ninguno; es un +hueco preexistente). No lo uses como señal de verificación: usa `typecheck` + los tests. + +## Arquitectura + +### Forma general + +Un solo proceso Node sirve la API y, en producción, el frontend compilado: + +- `server/` — Express + SQLite (`node:sqlite`). Los routers se montan en [server/index.ts](server/index.ts). +- `src/` — SPA React 18 + Vite + Tailwind. En dev, Vite proxya `/api` al `:3000`. +- `shared/types.ts` — los tipos TS que cruzan cliente/servidor. **Actualízalo junto con el esquema.** +- Aliases: `@/*` → `src/*`, `@server/*` → `server/*` (definidos en `tsconfig.json`; Vite solo resuelve `@`). +- En producción `server/index.ts` sirve `dist/` estático con fallback SPA (`app.get("*")`) que excluye + `/api/`. Los TS del servidor se ejecutan directo con `tsx` — no hay paso de compilación de backend + (el `Dockerfile` hace `npx tsx server/index.ts`). + +### Multi-tenancy: la invariante central + +Cada negocio es un tenant. Salvo `businesses` y `users`, **toda** tabla lleva `business_id`, y toda +query debe filtrar por `req.user!.business_id`. Nunca aceptes un `business_id` que venga del body. +El patrón canónico está en todas las rutas: + +```ts +db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`) + .get(id, req.user!.business_id) +``` + +Tres roles (`users.role`): `admin` (administrador de plataforma, `business_id` NULL, opera todos los +negocios vía `/api/admin`), `owner` y `employee`. Middlewares en [server/lib/auth.ts](server/lib/auth.ts): +`authRequired`, `ownerOnly`, `adminOnly`, más el helper `err(res, status, msg)`. + +`src/App.tsx` esconde rutas por rol, pero eso es solo UX: **la autorización real vive en los +middlewares del servidor** y los tests de e2e/admin verifican el aislamiento (403 / datos de otro +tenant no visibles). + +### Autenticación (demo, deliberadamente trivial) + +El token **es el id del usuario en texto plano** y las contraseñas se comparan sin hashear +([server/routes/auth.ts](server/routes/auth.ts)). `authRequired` rehidrata `req.user` desde la DB en +cada request. El cliente guarda el token en `localStorage` bajo `ap_token` +([src/lib/api.ts](src/lib/api.ts)). Esto habilita el "Ver como…" del `DemoSwitcher`. +No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `api.ts`, el +`AuthProvider` y los `.mjs` de test a la vez. + +### Esquema y migraciones + +[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta: + +- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`). +- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` **en ese orden**, + cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4` + está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del + archivo. Versión actual: **4**. + +Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV4ToV5()` nueva que +haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`. +Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable. + +### Siembra y plantillas + +`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma +(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière". +`seedBusiness({ businessId, template, ownerEmail, ownerName })` de +[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el +alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`, +`blank`) están en [server/lib/templates.ts](server/lib/templates.ts). + +Cuentas demo (todas con contraseña `demo1234`): `admin@agendamax.demo`, `owner@agendamax.demo`, y +seis empleados `*@lumiere.mx`. Los `.mjs` de test dependen de estos emails y del dominio +`agendamax.demo`. + +### Zonas horarias: no uses `date('now')` + +Es el error más fácil de cometer aquí. Los negocios son de México (UTC-6), así que el `date('now')` +de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla: + +- Las citas se guardan en UTC con formato ISO-Z **`YYYY-MM-DDTHH:MM:SSZ`** (sin milisegundos). +- `datetime(columna)` de SQLite devuelve el formato canónico **`YYYY-MM-DD HH:MM:SS`**. +- Por eso [server/lib/time.ts](server/lib/time.ts) tiene **dos familias** de helpers: + `bizDayBoundsIso()` para comparar contra `start_at` crudo, y `bizDayBoundsSqlite()` para comparar + contra `datetime(columna)`. Elegir mal produce rangos que no matchean nada. +- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en + [server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante + explícito, por eso son testeables (`server/lib/time.test.ts`). + +### Agendado, auto-asignación y guard anti doble-reserva + +[server/lib/scheduling.ts](server/lib/scheduling.ts) está partido en dos mitades a propósito: +primero helpers **puros** (`parseWorkingHours`, `specialtyMatch`, `hasConflict`, `scoreCandidate`, +`getWorkingHoursForDate`…) cubiertos por `scheduling.test.ts`, y después los que tocan la DB +(`getCandidates`, `getExistingBusy`, `isAvailable`, `pickBestSlotEmployee`, `autoAssign`, +`runInTransaction`). Si añades lógica pura, ponla en la primera mitad y testéala. + +Las **dos** rutas que crean citas — `POST /api/appointments` +([server/routes/appointments.ts](server/routes/appointments.ts)) y `POST /api/public/:slug/book` +([server/routes/booking.ts](server/routes/booking.ts)) — deben mantener esta secuencia dentro de +`runInTransaction` (`BEGIN IMMEDIATE` … `COMMIT` / `ROLLBACK`): + +1. resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es `employee` → `autoAssign`), +2. `isAvailable(db, empId, startMs, endMs)`; si falla, lanzar `{ status: 409, error: … }`, +3. `INSERT` de la cita en la **misma** transacción. + +Sacar el chequeo de la transacción reabre la carrera de doble reserva. Los errores se lanzan como +objetos `{ status, error }` y el handler los traduce con `err()`. + +Limitación conocida: `getExistingBusy` acota por día vía `start_at`, así que **no detecta citas que +cruzan medianoche**; hoy queda neutralizado por el guard de horario laboral. Revisítalo si alguna vez +se permite agendar 24h. + +`businesses.working_hours` y `employees.working_hours` guardan un JSON `Record<1..7, {start,end}|null>` +(1=Lunes … 7=Domingo); `null` en el empleado significa "hereda del negocio". El lado cliente lo +maneja en [src/lib/workingHours.ts](src/lib/workingHours.ts). + +### Frontend + +Rutas públicas y protegidas ([src/App.tsx](src/App.tsx)): + +``` +/ → LandingPage público; con sesión redirige según rol +/login → LoginPage formulario + acceso de un clic a las cuentas demo +/b/:slug → BookingPage fuera del AuthProvider; no asumas usuario +/dashboard… → panel sin sesión → /login +``` + +`homePathFor(user)` es la **única** definición de a dónde va cada rol (`admin` → `/admin`, +`owner` → `/dashboard`, `employee` → `/calendar`). No la repliques. + +**Cuatro páginas se cargan con `React.lazy`** y el motivo es medible, no estético: + +| Lazy | Por qué | +|---|---| +| `LandingPage`, `LoginPage` | Son las únicas que usan `framer-motion` (~40 KB gz). Con el login eager, Rollup mete `lib/motion` en el chunk de entrada y el panel paga la librería en cada carga. | +| `DashboardPage`, `CalendarPage` | Son las únicas que importan `recharts` (~111 KB gz) y FullCalendar (~76 KB gz). Eager, la landing las descargaba sin graficar ni agendar nada. | + +Su frontera de `Suspense` está en el `` de `AppShell` (fallback `RouteSpinner`). +`clsx` está fijado al chunk `react` en `vite.config.ts` a propósito: lo comparten `lib/format.ts` y +recharts, y sin fijarlo Rollup lo asigna al chunk `charts`, de modo que el chunk de entrada acaba +importando 111 KB de recharts para obtener una utilidad de 200 bytes. Verificable: `npm run build` y +comprobar que `index-*.js` no importe `charts-*` ni `framer-*`. + +- [src/lib/api.ts](src/lib/api.ts) es el **único** cliente HTTP de la app autenticada: un `request()` + que inyecta el bearer y normaliza errores a `Error & { status }`. Añade endpoints ahí, no `fetch` + suelto en componentes. El flujo público usa [src/lib/publicApi.ts](src/lib/publicApi.ts) aparte. +- React Query con `staleTime: 15_000`, sin refetch al enfocar, `retry: 1` ([src/main.tsx](src/main.tsx)). +- `AppShell` para negocio, `AdminShell` para plataforma; `/b/:slug` (reservas públicas) se monta + **fuera** del `AuthProvider` en [src/App.tsx](src/App.tsx) — no asumas usuario en ese árbol. +- Calendario con FullCalendar. En móvil abre en vista Día + vista Lista; Semana/Mes se reservan a + tablet/desktop a propósito (columnas aplastadas). `vite.config.ts` separa FullCalendar, recharts, + react y react-query en chunks manuales. +- Estilos de formulario (`.input`, `.select`, `.textarea`) viven en un `@layer components` de + `src/index.css`; si los sacas de la capa, la especificidad de Tailwind los pisa. + +### Responsividad (iPhone / iPad) — invariantes que no hay que romper + +Objetivo: iPhone 12 (390px), iPhone 16 Pro Max (440px) e iPad (744-1180px). Verificable con +`npm run audit:responsive` (WebKit real; requiere `npm run dev`). Reglas que sostienen el +comportamiento actual: + +- **Ningún campo de formulario por debajo de 16px en táctil.** Mobile Safari hace auto-zoom al + enfocar un `input`/`select`/`textarea` con `font-size < 16px` y **no revierte** al desenfocar: la + vista queda escalada y corrida. Era la causa del "arranca con zoom y desplazada" al tocar el campo + de correo. La regla vive en `@media (pointer: coarse)` en [src/index.css](src/index.css) y va por + tipo de puntero, no por ancho: un iPad también se toca con el dedo. No se resuelve con + `maximum-scale=1` porque eso rompe el pinch-zoom de accesibilidad. +- **Alturas de viewport con `dvh`, nunca `100vh` a secas.** `100vh` en iOS no descuenta la barra de + URL dinámica. Usa `.h-screen-safe` / `.min-h-screen-safe` / `.max-h-screen-safe`, que declaran + `vh` como fallback y `dvh` encima. +- **`viewport-fit=cover` obliga a descontar los insets del sistema.** El chrome (headers, sidebars, + bottom-sheets) usa `.safe-top`, `.safe-x` y `.safe-area-bottom`, apoyadas en las variables + `--safe-*` de `:root`. Sin ellas el contenido queda bajo la Dynamic Island o el home indicator. +- **Los objetivos táctiles se deciden por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja + fuera a las tablets, que son táctiles. Hay dos utilidades: `.tap-target` (40px de alto, para chips + densos) y `.icon-btn` (40×40, para botones de solo icono). +- **`.icon-btn` es una clase explícita a propósito; no intentes detectar los botones de icono por + selector.** Se probó `button:has(> svg:only-child)` y es una trampa: `:only-child` solo mira hijos + **elemento**, así que un botón o enlace con etiqueta (` Nueva cita`) también encaja, porque el + texto es un nodo de texto. Esa regla, al fijar `display: inline-flex`, convirtió los `NavLink` del + menú lateral en inline y los repartió en dos columnas en iPad Pro. Dos corolarios: `.icon-btn` + **no** toca `display` (está fuera de `@layer` y ganaría a `hidden`/`lg:block`, sacando el botón de + colapsar en el móvil), y centra el icono con `margin-inline: auto` sobre el `svg`, porque el + preflight de Tailwind lo deja en `display: block` y así ignora `text-align`. +- **Scrollbars personalizadas solo en `pointer: fine`.** Una barra de 10px en táctil roba ancho al + layout (el ancho útil bajaba de 390 a 380px) y descuadra los cálculos de 100%. +- **No combines `.safe-x` con `px-*` en el mismo elemento.** `.safe-x` está declarada **fuera** de + `@layer` en [src/index.css](src/index.css), así que gana a las utilidades de Tailwind. Como resuelve + `env(safe-area-inset-left, 0px)`, en cualquier dispositivo que no sea un iPhone en landscape deja el + padding lateral en **0** y el texto pega con el borde. Ni el check de overflow ni el de zoom lo + detectan, porque no hay desborde: hay cero margen. Usa `.ld-gutter` (o el patrón + `padding-inline: max(1.25rem, var(--safe-left))` de `.ld-section`), que resuelve las dos cosas en una + declaración. `npm run audit:responsive` lo vigila con el check `gutter` en las páginas públicas. +- **El check `shell-height` solo aplica a páginas con shell de altura fija** (`[data-app-shell]`, o sea + `AppShell` y `AdminShell`). Una landing o un login scrollean a propósito y son legítimamente más + altos que el viewport; compararlos contra `innerHeight` no mide un defecto, mide que existe scroll. + Para esas páginas el equivalente es el check estático `raw-viewport-unit`, que busca `h-screen`/`100vh` + crudos en las fuentes. +- **Nada debe poder desplazar la página en horizontal.** `body` lleva `overflow-x: hidden` como red + de seguridad, pero las causas se arreglan en origen: por ejemplo, un `grid` sin `grid-cols-1` + explícito dimensiona su columna implícita a `max-content` y la estiraba 23px más que la pantalla. + +### Landing pública y marca + +La landing vive en [src/components/landing/](src/components/landing/), una sección por archivo, todas +sin props ni estado compartido; [src/pages/LandingPage.tsx](src/pages/LandingPage.tsx) solo las +compone. El test de aceptación es `npm run test:landing` (`landing-e2e.mjs`, WebKit), y depende de +estos atributos de datos — si los quitas, se rompe: `data-ld-section` (deben ser **7**), +`data-ld-hero-title`, `data-ld-closing-cta`, `data-magic-login` + `data-role`. + +**La paleta no se inventa: se hereda del panel.** Los tokens `--ld-*` de `src/index.css` apuntan a los +colores que la app ya usa. En particular, las ocho muestras del abanico del hero son `PIE_COLORS` de +[src/pages/DashboardPage.tsx](src/pages/DashboardPage.tsx) — los mismos hex de los servicios en la dona +y de los avatares que reparte el seed. Por eso el hilo de la página («sus colores se vuelven sus +números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico. + +- La marca vive en **un** sitio: [src/components/BrandMark.tsx](src/components/BrandMark.tsx), que + replica `public/favicon.svg` (cuadrado `#3b66ff`, renglones blancos, punto `#f17616`). Si tocas esos + colores, toca también el favicon y `npm run generate:pwa-icons`, o la pestaña deja de coincidir. +- El azul hace de superficie de acción (`.ld-cta` usa brand-500 → brand-700, los dos tonos que + `.btn-primary` ya usa en normal y hover) y el naranja marca, nunca es fondo de botón. Es la misma + lógica del favicon. +- **`NotebookVisual` es la única excepción** y es deliberada: papel crema, renglones y grafito. Es lo + que el producto reemplaza; si se pareciera al producto, la sección dejaría de contar un cambio. +- Tipografía: Inter de cuerpo (la misma del panel) y **Fraunces** solo en titulares. El cuerpo va a + 17px mínimo con interlínea 1.6 porque el público objetivo incluye personas de 50+. +- Las animaciones salen de [src/lib/motion.ts](src/lib/motion.ts) con un único easing. Con + `prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un + `initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El + test lo verifica recorriendo las siete secciones. + +### Barra lateral + +Un único menú vertical, colapsable a solo iconos en `lg+` mediante +[src/lib/useSidebarCollapsed.ts](src/lib/useSidebarCollapsed.ts) (persistido en `localStorage` bajo +`ap_sidebar_collapsed`, sincronizado entre pestañas). Colapsada mide 68px y devuelve **188px** al +contenido, que es lo que más se nota en un iPad Pro portrait. `AppShell` y `AdminShell` comparten el +patrón: `sidebarContent(mini)` en lugar de un JSX fijo, porque el panel móvil siempre se muestra +completo aunque la de escritorio esté colapsada. En modo `mini` cada entrada conserva `title` para no +perder su nombre accesible al quedarse sin texto. + +### Calendario responsivo + +Es la parte que más se ha roto históricamente. Tres tamaños vía +[src/lib/useBreakpoint.ts](src/lib/useBreakpoint.ts), alineados con los media queries del CSS: + +| | phone (≤640) | tablet (641-1024) | desktop (≥1025) | +|---|---|---|---| +| Vista inicial | `timeGridDay` | `timeGridThreeDay` | `timeGridWeek` | +| Toolbar | prev/next/hoy + Día/Lista | + 3 días/Semana | + Mes | + +El corte tablet/desktop está en 1024/1025 y **no coincide con el `lg:` de Tailwind** (también 1024): +un iPad Pro portrait mide justo 1024px, así que a ese ancho hay barra lateral fija *y* vista de 3 +días. Es intencionado — con la barra desplegada quedan ~712px de calendario, y 7 columnas ahí son +~100px por día. Si cambias uno de los dos umbrales, cambia también el otro +([src/index.css](src/index.css) tiene los media queries de tablet). + +- **La altura del calendario se calcula contra el viewport, no con `height="100%"`.** FullCalendar mide + su contenedor una sola vez al montar y solo re-mide en resize de ventana, así que se quedaba con + valores viejos cuando el layout cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor + de 525px). Se mide `innerHeight - rect.top` y se pasa como número; ver `CAL_BOTTOM_GAP` / + `CAL_MIN_HEIGHT` en [src/pages/CalendarPage.tsx](src/pages/CalendarPage.tsx). El `ResizeObserver` + observa la **barra de filtros**, no el `body`: el shell es `overflow-hidden` de altura fija, así que + el body nunca cambia de tamaño y observarlo no vuelve a disparar. +- **No pongas `contentHeight` fijo.** `.fc-timegrid-slot` mide 2.4rem y la grilla de 08:00-21:00 son 26 + slots (~1000px), así que un `contentHeight` en píxeles hacía el calendario 1.4× más alto que la + pantalla y arrastraba la página. El calendario debe scrollear **dentro** de su tarjeta. +- **La tablet usa 3 días a propósito.** Con 7 columnas en 820px y varios especialistas a la misma hora, + `slotEventOverlap={false}` parte cada evento en columnas de ~30px y los títulos quedan en `"A.."`. +- No añadas bloques (avisos, empty states) como hermanos del calendario dentro de su tarjeta: con la + altura ya fijada no caben y se solapan con la grilla. + +### PWA + +`public/sw.js` cachea solo el app shell y **hace bypass explícito de `/api/`** y de cualquier request +no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un test dedicado en +`pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de +`activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`. + +## Convenciones de trabajo + +- **No hagas commits salvo que se pidan explícitamente** (política registrada en + `.superpowers/sdd/progress.md`). La verificación por tarea es typecheck + tests, no un commit. +- Este repo se desarrolla con un flujo spec-driven: las especificaciones y planes viven en + `docs/superpowers/{specs,plans}/` y la bitácora de ejecución con hallazgos de review en + `.superpowers/sdd/`. Léelos antes de retomar un feature a medias — `progress.md` lista los + follow-ups diferidos. +- `AUDIT.md` documenta las brechas funcionales frente al producto real que se está emulando; sirve de + backlog de producto. +- Existe un grafo de conocimiento en `graphify-out/` (ver el skill `graphify`): úsalo para orientarte + antes de leer código a ciegas; refréscalo con `graphify update` si quedó desactualizado. +- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son + regenerables; no los versiones ni los tomes como fuente de verdad. + +## Seguridad + +Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y +`cors()` abierto. La validación de entrada es manual y ad-hoc en cada handler: `zod` figura en +`dependencies` pero **no se importa en ningún archivo**. Antes de un despliegue real haría falta +hashing (bcrypt), JWT firmados, validación estricta, rate-limiting y HTTPS. Tenlo presente antes de +proponer este código para producción. diff --git a/Dockerfile b/Dockerfile index e42a270..a633e9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,20 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . + +# El login mágico y el "Ver como…" del panel viven detrás de la constante de build +# `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, ver +# src/pages/LoginPage.tsx, src/components/AppShell.tsx y src/components/DemoSwitcher.tsx). +# En un build de producción `DEV` es false, así que sin esta variable Vite elimina esas +# ramas por dead-code elimination: el despliegue quedaba con la landing prometiendo +# "no pide registro" y un /login sin ninguna cuenta de prueba que ofrecer. +# +# Este despliegue ES la demostración del producto, de ahí el default en 1. Cuando deje +# de serlo, basta pasar `--build-arg VITE_DEMO_UI=0` (o declararlo como build variable en +# Coolify) y las cuentas demo desaparecen del bundle sin tocar código. +ARG VITE_DEMO_UI=1 +ENV VITE_DEMO_UI=$VITE_DEMO_UI + RUN npm run build # ---- Runtime ---- diff --git a/docs/superpowers/plans/2026-07-28-landing-funnel-bi.md b/docs/superpowers/plans/2026-07-28-landing-funnel-bi.md new file mode 100644 index 0000000..977754e --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-landing-funnel-bi.md @@ -0,0 +1,2620 @@ +# 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 `framer-motion@11.x.x` 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 = "owner@agendamax.demo"; +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 ( +
+
+
+ Cargando AgendaMax… +
+
+ ); +} + +function AppRoutes() { + const { user, loading } = useAuth(); + if (loading) return ; + + const home = user ? homePathFor(user) : "/login"; + const isAdmin = user?.role === "admin"; + + return ( + + {/* ---- Públicas ---- */} + + ) : ( + }> + + + ) + } + /> + : } /> + + {/* Sin sesión, cualquier otra ruta manda al login. */} + {!user && } />} + + {/* ---- Admin de plataforma ---- */} + {user && isAdmin && ( + }> + } /> + } /> + } /> + } /> + + )} + + {/* ---- Negocio ---- */} + {user && !isAdmin && ( + }> + {user.role === "owner" ? ( + } /> + ) : ( + } /> + )} + } /> + } /> + } /> + } /> + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {/* La autorización real vive en los middlewares del servidor; esto es UX. */} + } /> + } /> + + )} + + ); +} + +export default function App() { + return ( + + {/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */} + } /> + + + + } + /> + + ); +} +``` + +- [ ] **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 ( + + ); +} +``` + +- [ ] **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 ( +
+ {/* 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. */} +
+
+ +
+
+
+ + + + Business intelligence para tu negocio + + +

+ {LINEAS.map((linea, i) => ( + + {linea} + + ))} +

+ + + Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los + convierte en decisiones que te dejan dinero. + + + + + Entrar a la demo + + + + Ver cómo funciona + + + + + + Sin registro. Sin tarjeta. Cuentas ya cargadas. + +
+ + {/* Indicador de scroll con respiración lenta. Se apaga con movimiento reducido. */} + {!reduced && ( + + + + )} +
+ ); +} +``` + +- [ ] **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 ( +
+ +
+ ); +} +``` + +- [ ] **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 ( + + + + + + + + + {/* Rejilla base: cuatro guías, muy tenues. Dan escala sin competir. */} + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + + + {/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */} + + + ); +} +``` + +- [ ] **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 ( +
+ {EQUIPO.map((p, i) => ( +
+
+ {p.nombre} + {p.citas} citas +
+
+ +
+
+ ))} +
+ ); +} +``` + +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 ( +