Compare commits

...
19 Commits
Author SHA1 Message Date
AgendaPro DevandClaude Opus 5 0069d23744 docs: record the production verification of both fixes
Adds the production evidence for commit 4281567 (`test:pwa` passing against the HTTPS
domain, the install button present with the chunk delayed, the chart running as a
CSSAnimation, smoke 16/16) and what this deploy taught about the setup:

- Auto-deploy webhooks are active on BOTH remotes, so pushing to gitea and github in
  sequence queues two concurrent deploys of the same commit.
- `force=true` is never needed after a push; it stops the container before building and
  adds avoidable downtime.
- Measured 241s of unavailability for this deploy — explicitly not attributed to cold
  start, since a second deploy was building concurrently.
- This Coolify instance only answers on collection endpoints; everything per-resource
  404s, so container env vars and logs are not readable over the API.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:41:17 -06:00
AgendaPro DevandClaude Opus 5 4281567207 fix: two defects that only reproduce over a real network
Neither is visible against localhost, which is why both shipped.

**Install button never appeared on `/login`** — a regression from making the login a
lazy route. `beforeinstallprompt` fires once per page load and is never replayed;
`useInstallPrompt` attached its listener from a `useEffect`, i.e. at mount, and
`InstallAppPrompt` now mounts only after an extra round-trip for its chunk. Locally
that round-trip is a millisecond so the listener still won a race it should never
have been in. Over a real connection the event was long gone, so a user on a slow
link lost the install button entirely.

The listener now lives in `src/lib/installPrompt.ts` and registers when the module
evaluates — `main.tsx` imports it for its side effect before mounting React. The hook
only reads from that store. Reproduced deterministically by delaying
`/assets/LoginPage-*.js` by 1.5s via `route()`: absent before, present after (also at
a 4s delay). `test:pwa` against the HTTPS domain now passes.

**Revenue chart did not animate on a real iPhone.** Measured rather than guessed:
the path animation does run in WebKit, and it triggers with the chart 100% visible at
y=601..715 of an 844px viewport — so neither "broken" nor "fires too early". What
fits is that iOS Safari suspends `requestAnimationFrame` during momentum scrolling
while framer-motion interpolates against wall-clock time: the animation spends its
1.4s without painting a frame and snaps to the end on resume, which looks exactly
like it never ran.

The chart's three animations move to CSS keyframes, which keep their own timeline in
the engine. The component only decides *when* (a `useInView` setting
`data-ld-rev-visible`). `prefers-reduced-motion` resolves in CSS too, and still
resolves to the *drawn* state — a line left at `dashoffset: 1px` with no animation
would be invisible forever. Verified: `getAnimations()` returns a `CSSAnimation`, and
under reduced motion the line renders complete.

Not verified: no physical iPhone here, and Playwright WebKit on Windows does not
reproduce iOS's rAF suspension. This is the standard mitigation and changes nothing
on desktop, but on-device confirmation is still outstanding.

Verified: typecheck clean; landing 13/13, PWA passed, responsive 0 findings, visual
62 screens / 0 errors, unit 39/39, e2e 33/33, admin 17/17, booking 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:09:29 -06:00
AgendaPro DevandClaude Opus 5 842a9ea07e docs: record the landing deploy and document the demo build flag
README: route table for `/`, `/login` and `/b/:slug`; what `VITE_DEMO_UI` does to a
production build and how to turn the demo accounts off; the two test commands that
were missing (`test:landing`, `audit:responsive`).

progress.md: the deploy entry, including the defect it caught — the deployed bundle
had every magic-login string eliminated, so the new landing would have promised "no
registration" and led to an empty form — and the two measurement traps that cost a
false pass (a Playwright context without `hasTouch` reports `pointer: fine`, and
`GET /deployments/{uuid}` nests the application object before the deployment status).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:36:46 -06:00
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00
AgendaPro Dev 0b466f33f3 fix: address final PWA review findings 2026-07-27 17:19:45 -06:00
AgendaPro Dev 43f5d1374c test: verify service worker API bypass 2026-07-27 17:01:25 -06:00
AgendaPro Dev a8becf07d6 test: harden AgendaMax PWA checks 2026-07-27 16:57:40 -06:00
AgendaPro Dev b6dcad68fb test: verify AgendaMax PWA installation 2026-07-27 16:45:14 -06:00
AgendaPro Dev 9ded129478 fix: harden PWA install prompt 2026-07-27 16:32:58 -06:00
AgendaPro Dev 0238dff5b1 feat: add cross-platform PWA install prompt 2026-07-27 16:28:54 -06:00
AgendaPro Dev 65233f2e4d feat: add production PWA service worker 2026-07-27 16:24:26 -06:00
AgendaPro Dev de947f9d35 feat: add AgendaMax PWA metadata and icons 2026-07-27 16:20:36 -06:00
AgendaPro Dev 9d2ab39d84 docs: plan AgendaMax PWA implementation 2026-07-27 16:16:58 -06:00
AgendaPro Dev fe8b701e27 docs: specify AgendaMax PWA installability 2026-07-27 15:12:58 -06:00
AgendaPro Dev 3e056a0dbf docs: fix demo domain acceptance check 2026-07-27 13:57:44 -06:00
AgendaPro Dev 3f84371842 docs: harden demo email migration checks 2026-07-27 13:48:12 -06:00
AgendaPro Dev 811b664405 docs: plan demo email migration 2026-07-27 12:13:35 -06:00
AgendaPro Dev 50dea33728 docs: record demo access task report 2026-07-27 11:38:49 -06:00
AgendaPro Dev 6355d18a07 docs: standardize AgendaMax demo access 2026-07-27 11:36:42 -06:00
73 changed files with 9361 additions and 527 deletions
+3
View File
@@ -37,3 +37,6 @@ Thumbs.db
# OpenCode runtime state (machine-local: sessions, goals)
.opencode/
# Grafo de conocimiento (regenerable con `graphify update`)
graphify-out/
+241
View File
@@ -43,3 +43,244 @@
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 <Outlet> 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
`npm run lint` sigue roto por falta de eslint.config.js (preexistente).
---
## 2026-07-28 — Commit y despliegue a producción (pedido explícito)
Commit `4c19244` en `main` (53 archivos), empujado a **gitea** y a **github**. Se rompe aquí la
política de "sin commits" porque el usuario lo pidió explícitamente. `graphify-out/` pasó a
.gitignore: es regenerable y ensuciaba todo `git status`.
DEFECTO DE DESPLIEGUE ENCONTRADO ANTES DE SUBIR (el más importante de esta tanda):
el login mágico **no existía en producción y la landing lo habría prometido**. `DEMO` es
`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, una constante de build: en un build de
producción `DEV` es false y Vite eliminaba por dead-code elimination el acceso de un clic y el
«Ver como…». Comprobado sobre el bundle que producción servía en ese momento: `agendamax.demo`,
`cuentas demo`, `Ver como` y `Cambiar cuenta` todos AUSENTES; el único `demo1234` que sobrevivía
está en `switchUser` de auth.tsx, que no está detrás de la bandera. Sin arreglarlo, la landing decía
«no pide registro» y llevaba a un formulario vacío.
Arreglo: `ARG VITE_DEMO_UI=1` + `ENV` en la etapa `web-build` del Dockerfile, antes de
`npm run build`. Default en 1 porque este despliegue **es** la demostración; `--build-arg
VITE_DEMO_UI=0` lo apaga sin tocar código. Verificado construyendo de las dos formas: con la bandera
aparecen `data-magic-login`, `[email protected]`, `Ver como` y `Cambiar cuenta`; sin ella
desaparecen las cuatro. En ambos casos el entry sigue importando solo icons/query/react.
También: `CACHE_NAME` del service worker a `agendamax-shell-v2`. No toqué su lógica, pero la landing
cambió el app shell y partió el bundle en chunks nuevos; como las peticiones de assets son
cache-first por nombre con hash, los chunks viejos quedarían huérfanos para siempre en el caché de
quien ya visitó el sitio. El `activate` los purga al cambiar el sufijo. `pwa-e2e.mjs` solo verifica
el prefijo, así que no hubo que tocar el test.
DESPLIEGUE (Coolify 4.1.2, app `s30f7egdlkx4wyjp59o1iunc`, build pack dockerfile, rama main,
fuente `urieljarethbusiness-cpu/agendamax`):
- El **auto-deploy por webhook sí está configurado**: el push a github disparó el deploy 145 solo.
Mi `POST /deploy?force=true` (146) quedó encolado detrás y fue redundante.
- Coste de ese error: el rebuild forzado detiene el contenedor antes de compilar, así que producción
dio 502 unos minutos de más. Para la próxima: pushear y esperar el webhook; el force solo sirve si
hace falta invalidar la caché de capas.
- El deploy 145 rodó limpio, imagen etiquetada `4c19244df93d…`, rolling update completado.
VERIFICACIÓN EN PRODUCCIÓN (https://agendamax.urieljareth.org, WebKit, 16/16):
- El hash del bundle que sirve producción (`index-D_TEdX1K.js`) es idéntico al del build local CON
la bandera. Un build sin ella da otro hash, así que es prueba de que el flag se aplicó.
- Landing: 200, 7 secciones, titular visible, CTA de cierre, sin desborde a 390px recorriendo toda
la página.
- Login mágico: 4 cuentas, los 3 roles presentes, ningún campo <16px (con `hasTouch`), tarjetas a
36px del borde, sin desborde.
- Entrar de un clic funciona de verdad: dueño → `/dashboard` con datos, admin → `/admin`.
- Sin errores de JS. Service worker `agendamax-shell-v2` con el bypass de `/api` intacto.
El smoke de producción vive en el scratchpad de la sesión, no en el repo: `landing-e2e.mjs` ya cubre
lo mismo en local y no se pidió otro test versionado. Si se va a desplegar seguido, vale la pena
promoverlo a `npm run test:prod`.
Dos trampas de medición que costaron una pasada en falso, por si reaparecen:
- Un contexto de Playwright **sin `hasTouch`** reporta `pointer: fine`, así que el `@media (pointer:
coarse)` que sube los campos a 16px no aplica y el check falla midiendo un CSS que el dispositivo
real nunca ve. `responsive-audit.mjs` ya lo hacía bien.
- `LoginPage` navega a `/` y es la ruta `/` la que redirige según el rol. Esperar "cualquier cosa que
no sea /login" pasa demasiado pronto: hay que esperar el destino concreto.
- `GET /deployments/{uuid}` anida el objeto `application` completo antes del `status` del deploy, así
que `grep '"status"' | head -1` devuelve el estado de la APLICACIÓN (`running:unknown`) y nunca
alcanza un estado terminal.
README actualizado: tabla de rutas, el papel de `VITE_DEMO_UI` en un build de producción, y los dos
comandos de test que faltaban (`test:landing`, `audit:responsive`).
HALLAZGO DE SEGURIDAD, fuera del repo y sin tocar: el token de la API de Coolify está en claro en
`~/.openclaw/workspace/check_coolify.ps1`, y `~/coolify-agent-skill.md` tiene correo, contraseña y
una llave SSH privada en texto plano. Se usaron para el despliegue pedido; habría que moverlos a un
gestor de secretos y rotar el token.
---
## 2026-07-28 — Dos defectos que solo aparecen en producción
Encontrados después del despliegue anterior: ninguno de los dos se ve contra `localhost`.
### 1. El botón «Instalar AgendaMax» no aparecía en `/login` (regresión propia)
`npm run test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` falló en
`assertInstallAction(..., "login")`. Contra `localhost` pasaba.
Causa: `beforeinstallprompt` se dispara **una sola vez** por carga y no se repite.
`useInstallPrompt` registraba su listener dentro de un `useEffect`, o sea al montar el componente. Al
volver `LoginPage` una ruta `lazy` —cambio de esta misma tanda— `InstallAppPrompt` monta después de
una ida y vuelta de red extra para traer su chunk. En local eso es un milisegundo y el listener llega
a tiempo; sobre la red real el evento ya pasó. No era solo el test: un usuario en conexión lenta
perdía el botón de instalar.
Arreglo: `src/lib/installPrompt.ts`, un almacén que registra el listener al evaluarse el módulo, que
`main.tsx` importa por su efecto antes de montar React. `useInstallPrompt` pasa a leer de ahí
(`eventoDisponible`/`tomarEvento`/`devolverEvento`) y ya no añade listeners propios.
Reproducción determinista, porque el test local no lo veía: interceptar `/assets/LoginPage-*.js` con
`route()` y retrasarlo 1.5s, manteniendo el disparo sintético en `load`+100ms.
- Contra producción con el código viejo: evento a los 490ms, botón AUSENTE.
- Contra el build con el arreglo, retraso de 1.5s y de 4s: botón PRESENTE en los dos.
### 2. La gráfica de ingresos no animaba en un iPhone real (reportado por el usuario)
`aria-label="Ingresos mensuales en tendencia ascendente"`, en la landing.
Lo primero fue descartar hipótesis midiendo, no suponiendo:
- ¿Está roto el `pathLength`? No: en WebKit a 390px con scroll lento la animación **sí** corría
(dasharray 0.14 → 1 en ~1.5s).
- ¿Se dispara demasiado pronto, con el elemento asomando por el borde? **No.** Se dispara con la
gráfica en y=601..715 de un viewport de 844px, **100% visible**. Hipótesis descartada.
- ¿`prefers-reduced-motion`? Reproduce el síntoma **exactamente**: sale ya dibujada y estática. Es
comportamiento por diseño, no un defecto. Queda como causa posible del reporte.
Causa que sí se puede blindar: iOS Safari suspende `requestAnimationFrame` durante el scroll por
inercia. framer-motion interpola contra el reloj de pared, así que la animación consume su 1.4s sin
pintar un fotograma y al reanudarse salta al estado final — se ve idéntico a "nunca animó".
Arreglo: sacar las tres animaciones de la gráfica de framer-motion y pasarlas a keyframes CSS
(`ld-rev-trazo`, `ld-rev-aparecer`, `ld-rev-aterrizar` en index.css). El componente solo decide
cuándo empezar, con un `useInView` que pone `data-ld-rev-visible`. Las animaciones CSS llevan su
propia línea de tiempo en el motor y siguen dibujando cuando rAF no corre. `prefers-reduced-motion`
también se resuelve en CSS, así que el componente ya no necesita el hook.
Verificado: dashoffset 0.93 → 0 en ~1.5s con el área entrando en su retardo; con movimiento reducido
la línea queda dibujada (dashoffset 0), **no invisible**; y `getAnimations()` devuelve una
`CSSAnimation` llamada `ld-rev-trazo` de 1400ms en estado `running`, o sea que la lleva el motor.
**Lo que NO pude verificar:** no tengo un iPhone físico aquí, y Playwright WebKit en Windows no
reproduce la suspensión de rAF durante el scroll de iOS. El arreglo es la mitigación estándar para
ese comportamiento y no cambia nada visualmente en escritorio, pero la confirmación en el dispositivo
queda pendiente del usuario. Si con Reduce Motion **desactivado** sigue sin animar, la causa es otra
y hay que volver a medir en el dispositivo.
Gates tras los dos arreglos: typecheck 0 · test:landing 13/13 · test:pwa passed · audit:responsive 0
hallazgos · audit:visual 62 pantallas 0 fallos 0 errores · unit 39/39 · e2e 33/33 · admin 17/17 ·
booking 12/12.
VERIFICADO EN PRODUCCIÓN (commit `4281567`, bundle `index-CGg6by-0.js`):
- `test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` **passed** — es exactamente el test
que fallaba antes del arreglo.
- La reproducción con el chunk retrasado 1.5s contra producción: evento sintético a los 471ms, botón
«Instalar AgendaMax» PRESENTE (antes AUSENTE).
- La gráfica: `getAnimations()` devuelve `CSSAnimation` en producción, y el muestreo confirma que la
línea se dibuja.
- Smoke completo 16/16.
### Sobre desplegar aquí, para la próxima
- **El auto-deploy por webhook está activo en los DOS remotos.** Pushear a gitea y a github seguido
encoló dos deploys simultáneos del mismo commit (150 y 151). Es inofensivo —misma imagen— pero
duplica el trabajo del servidor y añade un swap de contenedor extra. Si solo se quiere un deploy,
pushear a uno y dejar que el otro se sincronice después.
- **Nunca hace falta `force=true` después de un push**; el webhook ya construye. El force detiene el
contenedor antes de compilar y añade una caída evitable.
- Ventana de indisponibilidad medida en este despliegue: **241s** desde el primer 502 hasta el primer
200 en `/api/health`. Ojo con atribuirla: había un segundo deploy construyendo a la vez, así que es
la ventana del despliegue completo, **no** una medida limpia del arranque en frío.
- Hecho de código relacionado, sin medir y sin tocar: `tsx` está en `devDependencies` y la etapa de
runtime del Dockerfile hace `npm ci --omit=dev`, así que la imagen no lo contiene y
`CMD ["npx","tsx",...]` lo resuelve del registro **en cada arranque de contenedor**. Eso alarga el
arranque y hace que levantar dependa de que npm sea alcanzable. Moverlo a `dependencies` es una
línea; no se hizo porque nadie lo pidió y cambia la imagen de runtime.
- La API de esta instancia (Coolify 4.1.2) solo responde en endpoints de colección: `/resources`,
`/projects`, `/servers`, `/deployments` y `/deployments/{uuid}`. Todo lo per-resource
(`/applications/{uuid}`, `/…/envs`, `/…/logs`) devuelve 404, así que no se pueden leer las
variables de entorno ni los logs del contenedor por API.
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.
+29 -42
View File
@@ -1,50 +1,37 @@
# Task 1 Report — Migración V3→V4 + tipos compartidos
# Task 1 Implementation Report
## Files changed
- `H:\MegaSync\Proyectos\AgendaPro\server\db.ts`
- Added `migrateV3ToV4()` immediately before `export function runMigrations()`.
- Registered `migrateV3ToV4();` as the last call inside `runMigrations()` (after `migrateV2ToV3();`).
- `H:\MegaSync\Proyectos\AgendaPro\shared\types.ts`
- Replaced `Business` interface to add booking fields (`booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`) and v4 fields (`auto_assign_specialist`, `working_hours`).
- Added `WorkingDay` and `WorkingHoursMap` types after the `Business` interface.
- Added `specialties`, `working_hours`, `efficiency_score` to the `Employee` interface (after `service_ids?`).
## Scope
Implemented only the static AgendaMax PWA contract from Task 1. Existing worktree changes and graphify outputs were not modified.
## Changed Files
- `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.
## Verification
### `npm run typecheck` output (last line)
```
> [email protected] typecheck
> tsc -b --noEmit
```
Exit code 0 — **0 errors**. (Output was just the script banner + the tsc command echo; tsc prints nothing on success.)
- `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.
### tsx column check output (verbatim)
```
businesses has auto_assign_specialist: true
businesses has working_hours: true
employees has specialties: true
employees has working_hours: true
employees has efficiency_score: true
schema_version: [Object: null prototype] { value: '4' }
business working_hours backfilled: true
```
The check was run twice (second run for idempotency): identical output both times, no errors, schema_version stable at `4`.
## Self-Review
## Deviations from the plan
- None in the actual code edits. The `migrateV3ToV4` function, the `runMigrations` body, and all type additions match the plan verbatim.
- Verification detail: the plan's one-liner `npx tsx -e "..."` command failed under Windows PowerShell 5.1 because PowerShell parsed the embedded double-quoted SQL string (`"SELECT value FROM meta WHERE key='schema_version'"`) as terminating the outer `-e` argument, producing an "Unterminated string literal" esbuild error. To get a clean result without altering any project code, the check was executed via a throwaway `.mjs` file placed in the pre-approved temp dir (`C:\Users\URIELJ~1\AppData\Local\Temp\opencode`) that imports `server/db.ts` via an absolute `file:///H:/MegaSync/Proyectos/AgendaPro/server/db.ts` URL. The file was deleted after running. No project files were created or modified beyond `server/db.ts` and `shared/types.ts`.
## Self-review
- **All `Business` fields from the plan added?** Yes — interface body matches the plan exactly, including `booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`, `auto_assign_specialist`, `working_hours`. The DB already has the v3 booking columns from `migrateV2ToV3`, so the type is now honest vs. the DB.
- **Does `migrateV3ToV4` guard with `schema_version >= "4"`?** Yes — first line of the function is `if (getMeta("schema_version") >= "4") return;`, mirroring the v2/v3 pattern. String comparison is safe here because the values are single-digit ASCII numerics ("3" < "4").
- **Is the backfill idempotent?** Yes, in two layers:
1. The schema-version guard short-circuits the entire function on subsequent runs.
2. Even if that guard were bypassed, the backfill UPDATE is gated by `WHERE working_hours IS NULL`, so businesses whose `working_hours` was already filled (e.g. by the user via PATCH `/settings`) are never overwritten. Confirmed by the second run producing identical output.
- **Column-add idempotency:** Each ALTER is guarded by `columnExists(...)` — re-running against a DB that already has the column is a no-op.
- **Backfill content matches the plan's default** (`1..5 = 09:00-20:00`, `6,7 = null`), which replicates the previously hard-coded hours used by `booking.ts` pre-redesign.
- **Pre-existing WIP untouched:** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, and the FullCalendar block of `src/index.css` were not modified. Only `server/db.ts` and `shared/types.ts` were edited.
- **No commit was made** (per repo policy).
- 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
None. Typecheck is clean and the migration ran end-to-end against the real `data/agendapro.db`, producing exactly the expected column set and backfilled data.
- `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.
+27 -50
View File
@@ -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
```
> [email protected] 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
> [email protected] 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.
+134 -30
View File
@@ -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
> [email protected] 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
> [email protected] 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.
+68 -78
View File
@@ -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`
```
> [email protected] 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/<slug>` and `/api/public/<slug>/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:0020: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:0020: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.
+386
View File
@@ -0,0 +1,386 @@
# 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`): `[email protected]`, `[email protected]`, 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 `<Outlet>` 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<T>()`
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 (`<Plus/> 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.
- **La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion.** iOS Safari
suspende `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion interpola
contra el reloj de pared: la animación gasta su duración sin pintar un fotograma y al reanudarse
salta al estado final. En un iPhone se ve como si nunca hubiera animado — fue un defecto reportado
desde producción, y ningún audit lo detecta porque en WebKit de escritorio sí anima.
[RevenueLineVisual](src/components/landing/visuals/RevenueLineVisual.tsx) solo decide *cuándo*
empieza (un `useInView` que pone `data-ld-rev-visible`); el *cómo* son los keyframes `ld-rev-*` de
[src/index.css](src/index.css), que llevan su propia línea de tiempo en el motor. El trazo usa
`pathLength={1}` para que dasharray/dashoffset sean fracciones. Si añades otra animación de trazo
de línea en la landing, hazla igual.
### 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`.
**`beforeinstallprompt` se captura a nivel de módulo, no en un hook.** El navegador lo dispara una
sola vez por carga y no lo repite. Como `LoginPage` es una ruta `lazy`, `InstallAppPrompt` monta
después de una ida y vuelta de red extra: un listener registrado al montar llega tarde y el botón
«Instalar AgendaMax» no aparece nunca. Por eso el listener vive en
[src/lib/installPrompt.ts](src/lib/installPrompt.ts), que `main.tsx` importa por su efecto **antes**
de montar React, y [useInstallPrompt](src/lib/useInstallPrompt.ts) solo lee de ese almacén. No
devuelvas el listener a un `useEffect`, y si vuelves eager alguna ruta no asumas que eso lo arregla.
Detalle de verificación: `npm run test:pwa` contra `localhost` **no** detecta ese defecto, porque el
chunk lazy llega antes que el evento. Solo se ve contra el dominio HTTPS real
(`PWA_BASE_URL=https://…`), donde la latencia abre la ventana. Si tocas el arranque del prompt,
prueba contra producción o retrasa el chunk a propósito con `route()`.
## 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.
+14
View File
@@ -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 ----
+46 -15
View File
@@ -44,6 +44,12 @@ npm run dev
Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000).
| Ruta | Qué es |
|---|---|
| `/` | Landing pública. Con sesión abierta redirige al panel según el rol. |
| `/login` | Acceso. En fase demo ofrece entrar de un clic con las cuentas de abajo. |
| `/b/:slug` | Reserva pública del negocio, sin sesión. |
## Producción
```bash
@@ -51,13 +57,37 @@ npm run build # compila TS y empaqueta el frontend en dist/
npm start # sirve la API y el frontend estático en http://localhost:3000
```
El acceso de un clic y el selector «Ver como…» del panel viven detrás de la constante de
build `DEMO`, que en un build de producción solo se enciende con `VITE_DEMO_UI=1`. El
`Dockerfile` la declara con ese valor por defecto porque el despliegue **es** la
demostración; pasar `--build-arg VITE_DEMO_UI=0` los elimina del bundle.
## Instalar AgendaMax como app
- **Android/Chrome**: abre la URL HTTPS y selecciona la acción de instalación mostrada por AgendaMax o por la barra de direcciones del navegador.
- **iPhone/iPad Safari**: usa **Compartir -> Añadir a pantalla de inicio**; iOS no expone el aviso de instalación dentro de la página de Android.
- La app instalada puede abrirse sin la interfaz del navegador, pero los datos del negocio siguen requiriendo conexión a internet.
- Validación local: ejecuta `npm.cmd run build`, inicia producción con `npm.cmd start` y después ejecuta `npm.cmd run test:pwa`.
- La validación de producción requiere el dominio HTTPS de Coolify, no una dirección IP HTTP.
`npm.cmd run test:pwa` usa estas variables opcionales para los flujos autenticados;
si no se definen, usa las cuentas demo locales:
```text
[email protected]
[email protected]
PWA_PASSWORD=demo1234
```
## Tests y auditoría visual
```bash
npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant)
npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide),
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/
npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant)
npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
npm run test:landing # WebKit: ruteo landing/login, acceso de un clic y prefers-reduced-motion
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide),
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/
npm run audit:responsive # WebKit: iPhone/iPad reales — auto-zoom, safe areas, objetivos táctiles y canaletas
```
La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow,
@@ -65,18 +95,19 @@ errores de consola y confirma que el calendario, el modal de cita y el cambio de
## Cuentas demo
Todas usan la contraseña **`demo1234`**.
Todas usan la contraseña **`demo1234`**. En `/login` se puede entrar con un clic, sin
escribirlas.
| Rol | Email | Nombre |
|----------|----------------------------------|------------------|
| **Admin**| `admin@agendapro.demo` | Administrador |
| Dueño | `owner@agendapro.demo` | Daniela Reyes |
| Empleado | `[email protected]` | Valentina Cruz |
| Empleado | `[email protected]` | Mateo Herrera |
| Empleado | `sofia.rami[email protected]` | Sofía Ramírez |
| Empleado | `[email protected]` | Diego Castillo |
| Empleado | `[email protected]` | Isabela Torres |
| Empleado | `carolina.me[email protected]` | Carolina Méndez |
| Rol | Email | Contraseña | Nombre |
|----------|----------------------------------|------------|------------------|
| **Admin**| `admin@agendamax.demo` | `demo1234` | Administrador |
| Dueño | `owner@agendamax.demo` | `demo1234` | Daniela Reyes |
| Empleado | `[email protected]` | `demo1234` | Valentina Cruz |
| Empleado | `[email protected]` | `demo1234` | Mateo Herrera |
| Empleado | `sofía.ramí[email protected]` | `demo1234` | Sofía Ramírez |
| Empleado | `[email protected]` | `demo1234` | Diego Castillo |
| Empleado | `[email protected]` | `demo1234` | Isabela Torres |
| Empleado | `carolina.mé[email protected]` | `demo1234` | Carolina Méndez |
> En la pantalla de login aparecen botones de acceso rápido a las cuentas demo,
> y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**.
+2 -2
View File
@@ -14,7 +14,7 @@ function check(name, cond, extra = "") {
}
// Admin login
const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendapro.demo", password: "demo1234" });
const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendamax.demo", password: "demo1234" });
check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100));
const A = aLogin.token;
@@ -54,7 +54,7 @@ const { json: oBiz } = await req("GET", "/business", null, OT);
check("new owner business name", oBiz.business?.name === "Barbería Test");
// Owner1 still sees only Lumière (isolation the other way)
const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" });
const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
const { json: o1Emps } = await req("GET", "/employees", null, owner1.token);
check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`);
@@ -1113,7 +1113,7 @@ function check(name, cond, extra = "") {
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
}
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" });
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
const t = login.token;
check("login owner", !!t);
@@ -0,0 +1,273 @@
# Demo Email Domain Implementation Plan
> **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:** Standardize demo accounts on `@agendamax.demo`, make every demo login explicit in the README, and migrate local and Coolify data without deleting records.
**Architecture:** Keep the current simple demo authentication and replace only the two platform-owned demo email constants plus their test fixtures. Apply one targeted, idempotent SQLite `UPDATE` to the exact existing admin and owner rows; appointments, businesses, and related records remain unchanged. Deploy the source through the existing GitHub mirror and verify the live app through health and login endpoints.
**Tech Stack:** React/Vite, Express, TypeScript, Node 22 `node:sqlite`, SQLite, PowerShell/OpenSSH, Coolify v4.1.2.
## Global Constraints
- Keep the demo password exactly `demo1234`.
- Replace active `@agendapro.demo` references with `@agendamax.demo`; retain the old domain only in explicit negative-login assertions or migration-history documentation.
- Do not reset or delete local or production business, appointment, client, employee, ticket, or review data.
- Never commit `.env.local.ps1` or print token values.
- Production deployment must finish before the production data migration is run.
- If production deployment fails, stop before changing the production SQLite database and use the documented rollback path.
---
### Task 1: Update Demo Identity References and Access Documentation
**Files:**
- Modify: `README.md:66-82` — show email and password for every demo account.
- Modify: `server/index.ts:38-43` — use the new default owner email.
- Modify: `server/scripts/seed.ts:315-334` — use the new admin and owner emails for fresh databases.
- Modify: `src/pages/LoginPage.tsx:8-15` — use the new default development login.
- Modify: `admin-test.mjs`, `e2e-test.mjs`, `e2e-full.mjs`, `server/scripts/booking-e2e.mjs`, `visual-audit.mjs`, and any other active file returned by `rg -l 'agendapro\.demo'` — keep automated login fixtures aligned.
- Modify: active plan/report documents containing executable login examples; keep old-domain examples only as migration-history documentation or explicit negative-login assertions.
**Interfaces:**
- Fresh seed continues to create users with the existing schema and password.
- The login page continues to load `/api/auth/demo-users` and quick-login users; only the initial email constant changes.
- [ ] **Step 1: Record the current active references and credentials**
Run:
```powershell
rg -n --glob '!node_modules' --glob '!data/**' 'agendapro\.demo|demo1234' .
```
Expected: references are limited to the known seed, login defaults, tests, README, explicit negative-login assertions, and migration-history documentation; no secret token values are printed.
- [ ] **Step 2: Update the README credentials table first**
The resulting table must explicitly show:
```markdown
| **Admin**| `[email protected]` | `demo1234` | Administrador |
| Dueño | `[email protected]` | `demo1234` | Daniela Reyes |
```
Every employee row must also include the same password in its password column. Keep the note that the login screen offers quick access.
- [ ] **Step 3: Update fresh-seed and UI constants**
Use these exact values:
```ts
// server/index.ts and server/scripts/seed.ts
ownerEmail: "[email protected]"
// server/scripts/seed.ts
VALUES (NULL, '[email protected]', 'demo1234', 'Administrador', 'admin', '#0f172a')
// src/pages/LoginPage.tsx
useState(DEMO ? "[email protected]" : "")
```
- [ ] **Step 4: Update all active automated fixtures**
Replace only the email domain in existing login assertions and fixtures. Do not change employee domains or passwords.
- [ ] **Step 5: Verify the source tree has no stale active domain**
Run:
```powershell
rg -n --glob '!node_modules' --glob '!data/**' --glob '!docs/superpowers/specs/**' 'agendapro\.demo' .
```
Expected: no output from application code or positive fixtures; the intentional old-domain negative-login assertions and migration-history documents are the only allowed matches.
### Task 2: Migrate the Local SQLite Demo Users
**Files:**
- Modify: local ignored database `data/agendapro.db` only; no tracked database files.
**Interfaces:**
- Uses the existing `server/db.ts` connection and `users` table.
- Produces updated exact admin/owner email rows while preserving all row counts outside `users.email`.
- [ ] **Step 1: Confirm the local database and take a count snapshot**
Run from the repository root:
```powershell
Test-Path -LiteralPath .\data\agendapro.db
node --import tsx --input-type=module -e "import { db } from './server/db.ts'; console.log(JSON.stringify({users: db.prepare('SELECT id,email,role FROM users ORDER BY id').all(), appointments: db.prepare('SELECT COUNT(*) AS c FROM appointments').get(), clients: db.prepare('SELECT COUNT(*) AS c FROM clients').get()}));"
```
Expected: the local database exists or the command reports that a fresh seed is required; existing appointment/client counts are recorded before mutation.
Before updating, inspect the exact old/new admin and owner rows from the `users`
snapshot. For either role, if its target email already exists while its matching
old-domain row also exists, stop and resolve that unique-email collision instead
of running the update. If no old-domain rows exist and the new rows are present,
record a safe no-op and skip the update.
- [ ] **Step 2: Apply the idempotent local email update**
Run:
```powershell
node --% --import tsx --input-type=module -e "import { db } from './server/db.ts'; const r = db.prepare(\"UPDATE users SET email = CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email = '[email protected]' AND role = 'admin') OR (email = '[email protected]' AND role = 'owner')\").run(); console.log(JSON.stringify(r));"
```
Expected: only the exact email/role pairs are changed; an old-domain employee or
other role is untouched. Running the same command again reports zero additional
changes.
- [ ] **Step 3: Verify local credentials and preservation**
Run:
```powershell
node --% --import tsx --input-type=module -e "import { db } from './server/db.ts'; console.log(JSON.stringify({demo: db.prepare(\"SELECT email,role,password FROM users WHERE email IN ('[email protected]','[email protected]') ORDER BY role\").all(), appointments: db.prepare('SELECT COUNT(*) AS c FROM appointments').get(), clients: db.prepare('SELECT COUNT(*) AS c FROM clients').get()}));"
```
Expected: `[email protected]` and `[email protected]` use `demo1234`; appointment and client counts match the snapshot.
### Task 3: Run Local Regression Checks
**Files:**
- No additional files; validate Task 1 and Task 2 together.
**Interfaces:**
- Existing package scripts are the regression contract: typecheck, build, unit tests, and API tests.
- [ ] **Step 1: Run typecheck and build**
```powershell
npm run typecheck
npm run build
```
Expected: both commands exit with code 0.
- [ ] **Step 2: Run unit and admin/API tests**
```powershell
npm run test:unit
npm run test:admin
npm run test:e2e
```
Expected: typecheck, build, unit, admin, and non-slot authentication checks pass. On the
already-migrated local database, `businesses.working_hours` and employee
`working_hours` are null, so the slot-dependent e2e/booking assertions may fail
before booking (including their existing downstream dereference); record this
pre-existing limitation as unrelated to the email change and do not change
scheduling data for this task.
- [ ] **Step 3: Inspect the final diff**
```powershell
git diff --check
git status --short
```
Expected: only intended source, test, README, and specification/plan files are changed; `.env.local.ps1` and `data/agendapro.db` remain ignored.
### Task 4: Deploy AgendaMax Through Coolify
**Files:**
- Modify: the tracked AgendaPro source files from Tasks 1-3 through a normal commit.
- Do not modify: `.env.local.ps1`, local SQLite files, or Coolify credentials in Git.
**Interfaces:**
- Source mirror: the existing GitHub remote used by Coolify (`urieljarethbusiness-cpu/agendamax`).
- Coolify application: UUID `s30f7egdlkx4wyjp59o1iunc`, FQDN `https://agendamax.urieljareth.org`.
- [ ] **Step 1: Commit only the intended tracked changes**
```powershell
git add README.md server/index.ts server/scripts/seed.ts src/pages/LoginPage.tsx admin-test.mjs e2e-test.mjs e2e-full.mjs server/scripts/booking-e2e.mjs visual-audit.mjs docs/superpowers/specs/2026-07-27-demo-email-domain-design.md docs/superpowers/plans/2026-07-27-demo-email-domain.md
git diff --cached --check
git commit -m "docs: standardize AgendaMax demo access"
```
Add any additional active fixture files found by Task 1 explicitly; never use `git add .`.
- [ ] **Step 2: Push the source mirror used by Coolify**
```powershell
git push github main
```
Expected: the new commit is available in the GitHub mirror without exposing credentials in output.
- [ ] **Step 3: Trigger and monitor the Coolify deploy**
```powershell
. .\.env.local.ps1
$h = @{ Authorization = "Bearer $env:COOLIFY_TOKEN" }
$result = Invoke-RestMethod "$env:COOLIFY_API_URL/deploy?uuid=s30f7egdlkx4wyjp59o1iunc&force=true" -Headers $h
$deployment = $result.deployments[0].deployment_uuid
Invoke-RestMethod "$env:COOLIFY_API_URL/deployments/$deployment" -Headers $h
```
Poll until the status is `finished`; do not run the production SQL migration while it is queued, in progress, or failed.
### Task 5: Migrate and Verify Production Data
**Files:**
- Modify: persistent SQLite database inside the running AgendaMax Coolify app container only.
- Do not modify: source files or unrelated production tables.
**Interfaces:**
- Production app data path: `/app/data/agendapro.db`.
- Proxmox access: host `192.168.0.200`, LXC `102`; use the existing `scripts/Invoke-ProxmoxSsh.ps1`/`ProxmoxAgent.ps1` helpers and environment variables.
- [ ] **Step 1: Identify the running AgendaMax app container and verify the old rows**
Use the existing Proxmox SSH chain to run inside LXC 102:
```sh
container=$(docker ps --format '{{.ID}} {{.Names}}' | awk '$2 ~ /agendamax|s30f7/ {print $1; exit}')
test -n "$container" || { echo 'AgendaMax container not found' >&2; exit 1; }
docker exec "$container" node -e "const {DatabaseSync}=require('node:sqlite'); const db=new DatabaseSync('/app/data/agendapro.db'); console.log(db.prepare(\"SELECT id,email,role FROM users WHERE email IN ('[email protected]','[email protected]','[email protected]','[email protected]') ORDER BY id\").all())"
```
Expected: the app is running the new image and the current exact admin/owner
rows are visible before mutation. If an old row and its matching new row both
exist, stop and resolve the unique-email collision before updating.
- [ ] **Step 2: Apply the production email update**
```sh
docker exec "$container" node -e "const {DatabaseSync}=require('node:sqlite'); const db=new DatabaseSync('/app/data/agendapro.db'); const r=db.prepare(\"UPDATE users SET email=CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email='[email protected]' AND role='admin') OR (email='[email protected]' AND role='owner')\").run(); console.log(r)"
```
Expected: only the matching admin and owner rows move to `@agendamax.demo`; no
old-domain employee or other role is rewritten, and no business or appointment
rows are deleted.
If the preflight query shows an old row alongside its matching new row, stop and
resolve the unique-email collision. If it shows no old-domain rows because the
deployment seeded a fresh database with the new domain, record the update as a
safe no-op and skip it.
- [ ] **Step 3: Verify live health and credentials**
```powershell
$health = Invoke-RestMethod https://agendamax.urieljareth.org/api/health
$admin = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"[email protected]","password":"demo1234"}'
$owner = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"[email protected]","password":"demo1234"}'
Write-Host "health_ok=$($health.ok) admin_role=$($admin.user.role) admin_token_present=$([bool]$admin.token) owner_role=$($owner.user.role) owner_token_present=$([bool]$owner.token)"
```
Expected: health returns `ok: true`; both logins return the correct roles. Only boolean token-presence values are printed, never token contents.
- [ ] **Step 4: Verify the deployed page and repository state**
Run the existing browser/HTTP post-deploy check if available, then:
```powershell
git status --short
```
Expected: the deployed AgendaMax page loads, no uncaught frontend errors are reported, and only intentional local follow-up changes remain.
@@ -0,0 +1,503 @@
# AgendaMax PWA Installability Implementation Plan
> **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:** Make AgendaMax installable as a web app on Android, iOS, and compatible desktop browsers without adding offline writes or changing the existing API/authentication model.
**Architecture:** Add a hand-maintained web manifest and service worker under `public/`, register the worker only in production, and provide a reusable React install prompt. Chromium uses `beforeinstallprompt`; iOS Safari receives explicit Share -> Add to Home Screen instructions. The worker caches only the app shell/static assets and bypasses all API and non-GET requests.
**Tech Stack:** React 18, TypeScript, Vite 5, Tailwind CSS, Express static hosting, Playwright, Node.js 22.5+.
## Global Constraints
- Use a manually maintained manifest and service worker; do not add `vite-plugin-pwa`.
- Keep the data model online-first; never cache `/api` responses or queue offline writes.
- Keep AgendaMax's existing Spanish visual language and make installation a secondary action.
- Production installation requires HTTPS; `localhost` is valid for local testing.
- Do not change authentication, tenant isolation, API routes, or database schema.
- Preserve the existing `public/favicon.svg` and brand color `#3b66ff`.
- Keep service-worker registration failure non-fatal and log it only in development.
## File Map
- Create `public/manifest.webmanifest`: browser install metadata and icon declarations.
- Create `public/sw.js`: versioned shell/static-asset cache with API bypass.
- Create `public/icon-192.png` and `public/icon-512.png`: install icons derived from the existing favicon mark.
- Create `scripts/generate-pwa-icons.mjs`: reproducible local icon generation using the existing Playwright dependency.
- Create `src/lib/useInstallPrompt.ts`: browser capability detection and deferred install event lifecycle.
- Create `src/components/InstallAppPrompt.tsx`: install button and iOS instruction modal.
- Create `pwa-e2e.mjs`: production-server PWA endpoint and browser-behavior checks.
- Modify `index.html`: manifest link, iOS metadata, and safe-area viewport setting.
- Modify `src/main.tsx`: production service-worker registration.
- Modify `src/index.css`: safe-area utility for the install dialog.
- Modify `src/pages/LoginPage.tsx`, `src/components/AppShell.tsx`, and `src/components/AdminShell.tsx`: render the reusable install action in the existing UI surfaces.
- Modify `package.json`: icon-generation and PWA test scripts.
- Modify `README.md`: document install behavior, HTTPS, and validation commands.
---
### Task 1: Add Static PWA Metadata and Icons
**Files:**
- Create: `scripts/generate-pwa-icons.mjs`
- Create: `public/icon-192.png`
- Create: `public/icon-512.png`
- Create: `public/manifest.webmanifest`
- Modify: `index.html:5-12`
- Modify: `package.json:7-25`
**Interfaces:**
- Produces `/manifest.webmanifest`, `/icon-192.png`, and `/icon-512.png` in the Vite output.
- Keeps `/favicon.svg` as the browser-tab icon.
- [ ] **Step 1: Add a reproducible icon generator.**
Create `scripts/generate-pwa-icons.mjs` using the already-installed Playwright package. It must load `public/favicon.svg` as a data URL, render it on a white 1:1 page, and screenshot exactly 192x192 and 512x512 PNG files:
```js
import { chromium } from "playwright";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const svg = await fs.readFile(path.join(root, "public", "favicon.svg"), "utf8");
const browser = await chromium.launch({ headless: true });
try {
for (const size of [192, 512]) {
const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
await page.setContent(`<!doctype html><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#fff}svg{display:block;width:100%;height:100%}</style>${svg}`);
await page.screenshot({ path: path.join(root, "public", `icon-${size}.png`), type: "png" });
await page.close();
}
} finally {
await browser.close();
}
```
- [ ] **Step 2: Run the generator and verify PNG dimensions.**
Run:
```text
node scripts/generate-pwa-icons.mjs
```
Expected: `public/icon-192.png` and `public/icon-512.png` exist. Verify their PNG signature and dimensions with a short Node check before continuing; do not substitute SVG files for the required PNG icons.
- [ ] **Step 3: Add the manifest.**
Create `public/manifest.webmanifest` with this exact contract:
```json
{
"name": "AgendaMax",
"short_name": "AgendaMax",
"description": "Gestión visual de citas, empleados e ingresos para tu negocio.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f7fb",
"theme_color": "#3b66ff",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
```
- [ ] **Step 4: Update HTML metadata and package scripts.**
In `index.html`, keep the existing favicon/theme/description and add:
```html
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
```
Change the viewport content to include `viewport-fit=cover` while preserving the current scale values. Add these scripts to `package.json`:
```json
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
"test:pwa": "node pwa-e2e.mjs"
```
- [ ] **Step 5: Build and inspect static output.**
Run:
```text
npm.cmd run build
```
Expected: exit code 0 and `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist. The worker is added in Task 2, so do not treat its absence as a failure in this task.
- [ ] **Step 6: Commit the static PWA contract.**
```text
git add package.json index.html public/manifest.webmanifest public/icon-192.png public/icon-512.png scripts/generate-pwa-icons.mjs
git commit -m "feat: add AgendaMax PWA metadata and icons"
```
---
### Task 2: Add the Production Service Worker
**Files:**
- Create: `public/sw.js`
- Modify: `src/main.tsx:18-25`
**Interfaces:**
- Browser loads `/sw.js` from the same origin in production.
- The worker owns only shell/static caching; API requests remain network-only.
- [ ] **Step 1: Add the service worker with explicit routing.**
Create `public/sw.js` with a versioned cache and these rules:
```js
const CACHE_NAME = "agendamax-shell-v1";
const SHELL_URLS = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => caches.match("/"))
);
return;
}
const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]);
if (!cacheableDestination.has(request.destination)) return;
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
});
})
);
});
```
The worker must not add a catch-all cache path, intercept non-GET requests, or cache requests whose path starts with `/api/`.
- [ ] **Step 2: Register the worker only in production.**
Append this registration after the React root render in `src/main.tsx`:
```ts
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
void navigator.serviceWorker
.register("/sw.js", { updateViaCache: "none" })
.catch((error: unknown) => {
if (import.meta.env.DEV) console.warn("AgendaMax service worker registration failed", error);
});
});
}
```
The registration failure is non-fatal and must never reject or delay React startup; the development-only warning is retained for local diagnostics if the environment condition is changed during debugging.
- [ ] **Step 3: Validate worker behavior statically.**
Run:
```text
npm.cmd run typecheck
npm.cmd run build
```
Expected: both exit 0, and `dist/sw.js` exists because Vite copies `public/sw.js`.
- [ ] **Step 4: Commit the worker.**
```text
git add public/sw.js src/main.tsx
git commit -m "feat: add production PWA service worker"
```
---
### Task 3: Add Install Detection and UI
**Files:**
- Create: `src/lib/useInstallPrompt.ts`
- Create: `src/components/InstallAppPrompt.tsx`
- Modify: `src/index.css:228-322`
- Modify: `src/pages/LoginPage.tsx:1-6` and rendered layout
- Modify: `src/components/AppShell.tsx:1-18` and mobile header
- Modify: `src/components/AdminShell.tsx:1-14` and mobile header
**Interfaces:**
- `useInstallPrompt(): { state: InstallPromptState; install: () => Promise<void>; dismiss: () => void }`.
- `InstallPromptState` is exactly `"unsupported" | "available" | "ios-instructions" | "installed"`.
- `<InstallAppPrompt />` renders nothing for `unsupported` or `installed` and owns the iOS `Modal` lifecycle.
- [ ] **Step 1: Define the deferred prompt type and write the hook contract.**
Create `src/lib/useInstallPrompt.ts` with a local event type instead of adding an unsafe global declaration:
```ts
import { useEffect, useState } from "react";
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
}
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone));
}
function isIOSSafari() {
const ua = navigator.userAgent;
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua);
}
export function useInstallPrompt() {
const [state, setState] = useState<InstallPromptState>(() => {
if (typeof window === "undefined") return "unsupported";
if (isStandalone()) return "installed";
return isIOSSafari() ? "ios-instructions" : "unsupported";
});
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (isStandalone()) {
setState("installed");
return;
}
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferred(event as BeforeInstallPromptEvent);
setState("available");
};
const onInstalled = () => {
setDeferred(null);
setState("installed");
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const install = async () => {
if (!deferred) return;
const event = deferred;
setDeferred(null);
await event.prompt();
const choice = await event.userChoice;
if (choice.outcome === "accepted") setState("installed");
else setDismissed(true);
};
return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) };
}
```
Keep the hook browser-only and ensure event listeners are removed on unmount. The iOS state is intentionally limited to Safari; unsupported browsers remain normal web pages.
- [ ] **Step 2: Build the reusable prompt component.**
Create `src/components/InstallAppPrompt.tsx` using `useInstallPrompt`, `Modal`, and `Download`, `Share`, and `PlusSquare` icons from `lucide-react`. The component must:
```tsx
export function InstallAppPrompt() {
const { state, install } = useInstallPrompt();
const [iosOpen, setIosOpen] = useState(false);
if (state === "unsupported" || state === "installed") return null;
if (state === "ios-instructions") {
return (
<>
<button type="button" className="btn btn-secondary w-full justify-start" onClick={() => setIosOpen(true)}>
<Download className="h-4 w-4" /> Instalar AgendaMax
</button>
<Modal open={iosOpen} onClose={() => setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
<div className="safe-area-bottom">
<ol className="space-y-3 text-sm text-slate-600">
<li className="flex gap-3"><Share className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Toca <strong>Compartir</strong> en la barra de Safari.</span></li>
<li className="flex gap-3"><PlusSquare className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Elige <strong>Añadir a pantalla de inicio</strong> y confirma.</span></li>
</ol>
</div>
</Modal>
</>
);
}
return <button type="button" className="btn btn-secondary w-full justify-start" onClick={() => void install()}><Download className="h-4 w-4" /> Instalar AgendaMax</button>;
}
```
The final component may use `aria-label`/`aria-labelledby` as needed, but must keep the exact accessible action name `/Instalar AgendaMax/`, close the iOS modal on backdrop/Escape through the existing `Modal`, and never show an install CTA after standalone detection.
- [ ] **Step 3: Add safe-area styling.**
Append a focused utility to `src/index.css`:
```css
.safe-area-bottom {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
```
Apply it to the iOS modal content/footer surface only; do not change the global page height or reserve a permanent bottom band.
- [ ] **Step 4: Integrate one visible action per page shell.**
Import `InstallAppPrompt` and render it once in each of these existing surfaces:
- `LoginPage`: immediately below the login card's submit area, using a compact centered container so it does not affect the desktop brand panel.
- `AppShell`: in the existing mobile header beside the AgendaMax wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` responsive convention.
- `AdminShell`: in the existing mobile header beside the Admin wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` convention.
Do not put the component inside `SidebarContent`, because that JSX is rendered once for desktop and again when the mobile drawer opens. One instance per shell avoids duplicate deferred prompt listeners.
- [ ] **Step 5: Verify TypeScript and responsive rendering.**
Run:
```text
npm.cmd run typecheck
npm.cmd run build
```
Expected: exit code 0 for both. Confirm the app still loads the login page and both authenticated shells without console errors.
- [ ] **Step 6: Commit the install UI.**
```text
git add src/lib/useInstallPrompt.ts src/components/InstallAppPrompt.tsx src/index.css src/pages/LoginPage.tsx src/components/AppShell.tsx src/components/AdminShell.tsx
git commit -m "feat: add cross-platform PWA install prompt"
```
---
### Task 4: Add PWA Verification and Production Documentation
**Files:**
- Create: `pwa-e2e.mjs`
- Modify: `README.md:47-64`
**Interfaces:**
- `npm run test:pwa` checks a built production server at `PWA_BASE_URL` or `http://localhost:3000`.
- The check exits non-zero on missing assets, invalid manifest fields, unexpected API interception, or UI behavior failures.
- [ ] **Step 1: Add endpoint and manifest assertions.**
In `pwa-e2e.mjs`, fetch the base URL and assert status 200 for `/manifest.webmanifest`, `/sw.js`, `/icon-192.png`, and `/icon-512.png`. Parse the manifest and assert `display === "standalone"`, `start_url === "/"`, `scope === "/"`, and both declared icon sizes. Fetch a known SPA route such as `/calendar` and assert it returns the built HTML rather than a 404. Fetch `/api/health` and assert it remains a JSON API response.
- [ ] **Step 2: Add browser install-event checks.**
Use Playwright Chromium with `BASE = process.env.PWA_BASE_URL || "http://localhost:3000"`. Add an init script that dispatches a cancelable `beforeinstallprompt` event after load and records `prompt()` calls:
```js
await page.addInitScript(() => {
window.__installPromptCalls = 0;
setTimeout(() => {
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = async () => { window.__installPromptCalls += 1; };
event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" });
window.dispatchEvent(event);
}, 100);
});
```
On `/`, assert the accessible `Instalar AgendaMax` action becomes visible, click it, and assert `window.__installPromptCalls === 1`.
- [ ] **Step 3: Add iOS and standalone checks.**
Create a second context with an iPhone Safari user agent. Before page scripts run, define `Navigator.prototype.standalone` as `false`; assert the install action is visible, click it, and assert the modal contains `Compartir` and `Añadir a pantalla de inicio`. Create a third context whose `matchMedia` returns `matches: true` for `display-mode: standalone`; assert no `Instalar AgendaMax` action is visible. Create a fourth normal context without an install event and assert no install action is visible.
- [ ] **Step 4: Add service-worker/API safety checks.**
After loading the production page, wait for `navigator.serviceWorker.ready` with a bounded timeout. Inspect the worker source returned by `/sw.js` and assert it contains the `/api/` bypass and `request.method !== "GET"` guard. Use Playwright request listeners to confirm normal page/API loading still reaches `/api/health`; do not use a cached fixture as a substitute for the real API response.
- [ ] **Step 5: Document local and production validation.**
Add a `## Instalar AgendaMax como app` section to `README.md` after the production commands:
- Android/Chrome: open the HTTPS URL and select the install action shown by AgendaMax or the browser address bar.
- iPhone/iPad Safari: use Share -> Add to Home Screen; iOS does not expose Android's in-page install prompt.
- The installed shell can be reopened without browser chrome, but business data still requires an internet connection.
- Local validation: run `npm.cmd run build`, start production with `npm.cmd start`, then run `npm.cmd run test:pwa`.
- Production validation requires the Coolify HTTPS domain, not an HTTP IP address.
- [ ] **Step 6: Run the complete verification set.**
With a built production server running on port 3000, run:
```text
npm.cmd run typecheck
npm.cmd run build
npm.cmd run test:pwa
npm.cmd run audit:visual
git diff --check
```
Expected: typecheck, build, PWA checks, and visual audit exit 0; `git diff --check` reports no whitespace errors. If the pre-existing scheduling data causes API booking assertions in unrelated suites, record that limitation without weakening PWA checks.
- [ ] **Step 7: Commit verification and docs.**
```text
git add pwa-e2e.mjs README.md
git commit -m "test: verify AgendaMax PWA installation"
```
---
## Final Review Checklist
- [ ] `manifest.webmanifest` has valid name, scope, start URL, standalone display, theme/background colors, and 192/512 PNG icons.
- [ ] `index.html` includes manifest, iOS metadata, `apple-touch-icon`, and `viewport-fit=cover`.
- [ ] `sw.js` is copied into `dist`, updates by version, falls back only for navigation, and bypasses `/api` and non-GET requests.
- [ ] Service-worker registration runs only in production and cannot block React startup.
- [ ] Android/Chromium install prompt, iOS instructions, unsupported browser, dismissed prompt, and standalone states are covered.
- [ ] Login, business mobile shell, and admin mobile shell each have one install action instance.
- [ ] No authentication, tenant data, API routes, database schema, or offline writes changed.
- [ ] `npm run typecheck`, `npm run build`, `npm run test:pwa`, and the visual audit have evidence before claiming completion.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
# Demo Accesses and Email Domain
## Goal
Make the demo credentials easy to use and standardize the platform demo accounts
from `@agendapro.demo` to `@agendamax.demo` in source data, documentation, local
data, and the deployed Coolify instance.
## Scope
- Replace active positive `@agendapro.demo` references in application code,
tests, README, and operational documentation with `@agendamax.demo`; retain
the old domain only in migration-history documentation or explicit
negative-login assertions.
- Keep the existing demo password `demo1234` and expose it clearly beside each
demo account in `README.md`.
- Update the local SQLite demo users without deleting businesses, appointments,
or other seeded records.
- Deploy the code to the existing AgendaMax Coolify application.
- Update the persistent production SQLite users for the exact admin and owner
accounts with a targeted SQL change, preserving all other data.
## Data Flow
Fresh databases receive the new admin and owner emails from `server/scripts/seed.ts`
and `server/index.ts`. Existing local and production databases are migrated by
updating only these two exact rows: `[email protected]` and
`[email protected]`. If the active database is fresh and already contains the
new emails, the SQL update is a no-op and is skipped.
The employee accounts already use their business domains and are not changed.
Login verification uses `POST /api/auth/login` with the documented password and
does not expose password values through the API.
## Production Procedure
1. Run the local checks and build.
2. Push the change to the AgendaMax source mirror used by Coolify.
3. Trigger and monitor a Coolify deployment.
4. Inspect the exact old/new admin and owner rows. If an old row and its matching
new row both exist, stop and resolve the unique-email collision. If no old
rows exist and the new rows are present, record a safe no-op. Otherwise run a
targeted SQLite update inside the persistent production app volume:
`UPDATE users SET email = CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email = '[email protected]' AND role = 'admin') OR (email = '[email protected]' AND role = 'owner');`
5. Verify health, login for admin and owner, and the production page.
If deployment fails, do not run the production SQL migration; inspect the
deployment first and use the existing Coolify rollback procedure.
## Acceptance Criteria
- `rg "agendapro\.demo"` returns no active positive application, test, or
README references; only explicit negative-login assertions and
migration-history documentation may retain the old domain.
- README lists every demo account with email and `demo1234`.
- Fresh seed creates `[email protected]` and `[email protected]`.
- Existing local data contains the new emails and retains its records.
- Coolify deployment finishes successfully.
- Production `/api/health` returns `ok: true`.
- Production admin and owner logins succeed with `demo1234`.
- Production admin and owner logins using the old domain return `401`.
- README employee emails exactly match the accent-preserving emails generated by the seed.
- The targeted SQL pairs each old email with its matching role and never rewrites
another old-domain user.
## Known Verification Limitation
The already-migrated local database has null `businesses.working_hours` and
employee `working_hours` values. As a result, the slot-dependent `test:e2e` and
`test:booking` assertions can fail before booking, including their existing
downstream dereference. This is a pre-existing scheduling-data limitation,
unrelated to the email-domain change; do not change working-hours data for this
task.
@@ -0,0 +1,174 @@
# AgendaMax PWA Installability
## Goal
Make AgendaMax installable as a web app on mobile and desktop browsers, with a
native installation prompt where the browser supports it and an explicit iOS
installation guide where Apple does not expose that prompt API.
## Decisions
- Use a manually maintained manifest and service worker instead of adding
`vite-plugin-pwa`. This keeps the caching policy visible and avoids a new
build-time dependency for a small, already stable Vite app.
- Use an online-first data model. The service worker may recover the application
shell after a previous visit, but it must never cache `/api` responses or
attempt offline writes for appointments, clients, cash, or settings.
- Keep the existing AgendaMax visual language and Spanish copy. Installation is
an optional secondary action, not a permanent mobile banner.
- Require HTTPS in deployed environments. `localhost` remains valid for local
testing because browsers treat it as a secure context.
## Current Context
- Frontend: React 18, TypeScript, Vite, Tailwind CSS, React Router.
- Production serving: Express serves `dist/` and falls back to `index.html` for
non-API routes.
- Existing brand assets: `public/favicon.svg`, `#3b66ff` theme color, AgendaMax
name and current Spanish UI.
- Existing entry points: `src/main.tsx`, `src/App.tsx`, `LoginPage`,
`AppShell`, and `AdminShell`.
- There is no manifest, service worker, install prompt handling, or iOS-specific
home-screen metadata today.
## Architecture
### Static PWA assets
Add the following public assets, copied by Vite into `dist/`:
- `manifest.webmanifest` with `name` and `short_name` set to AgendaMax,
`start_url` `/`, `scope` `/`, `display` `standalone`, portrait orientation,
brand colors, and PNG icons at 192x192 and 512x512.
- `sw.js` with a versioned cache name.
- PNG icons derived from the existing AgendaMax favicon mark. Keep the SVG
favicon unchanged for browser tabs.
Update `index.html` with the manifest link, iOS home-screen metadata, an
explicit mobile web app title, `viewport-fit=cover`, and the existing theme and
description metadata.
### Service worker
Register the worker from `src/main.tsx` only for production builds, using
`updateViaCache: "none"` so a deployed worker is checked promptly.
The worker has these rules:
- On install, cache only the stable shell/bootstrap assets and call
`skipWaiting`.
- On navigation requests, use network-first and fall back to the cached root
document. This allows deployments to deliver fresh HTML while preserving a
previously visited shell during a temporary outage.
- On same-origin static GET requests, use cache-first for immutable or hashed
assets and add successful responses to the runtime cache.
- Bypass `/api/`, non-GET requests, cross-origin requests, and requests with
credentials or other conditions that could expose tenant data.
- On activation, remove caches from older versions and call `clients.claim`.
The worker is deliberately not a data-sync layer. If an API request fails, the
existing application error/loading behavior remains authoritative.
### Install state and UI
Create a small reusable install hook/component with these states:
1. `unsupported`: no UI.
2. `available`: show an install action backed by `beforeinstallprompt`.
3. `ios-instructions`: show a short dialog explaining Safari's Share then Add
to Home Screen flow.
4. `installed`: no UI, detected from `display-mode: standalone` and
`navigator.standalone`.
The hook must retain the deferred Android prompt only until it is used, handle
`appinstalled`, and avoid showing the CTA repeatedly after a user dismisses it
within the current browser session.
Render the reusable action in:
- `LoginPage`, so a user can install before authentication;
- the authenticated business mobile header/sidebar in `AppShell`;
- the authenticated platform-admin mobile header/sidebar in `AdminShell`.
The action should remain a compact secondary button with a download icon. The
iOS dialog is instructional and must not claim that a native prompt is
available. It must not reserve a persistent bottom band or interfere with
existing mobile navigation.
Use safe-area-aware spacing where the install dialog or mobile controls touch a
screen edge.
## Data Flow
1. Browser loads the Vite entry document.
2. Production client registers `/sw.js`; the worker installs and claims future
navigations.
3. The install hook evaluates browser capability and standalone state.
4. Android/Chromium emits `beforeinstallprompt`; the hook stores the event and
exposes the CTA.
5. User selects the CTA; the hook calls the native prompt and handles the
resulting `appinstalled` or dismissal event.
6. iOS Safari is identified when not standalone; the CTA opens the instruction
dialog and does not attempt an unsupported API.
7. All authenticated API requests continue to use the existing `fetch`/auth
path and always go to the network.
## Error Handling and Compatibility
- Failure to register the service worker is non-fatal and must not prevent the
React application from rendering; log a concise warning in development only.
- Missing or invalid install events result in hidden UI, not an exception.
- Existing browsers that cannot install PWAs continue to work as normal web
pages.
- iOS installation is supported through Safari's system flow. Other iOS
browsers may render the app but are not promised a direct install action.
- The service worker must not intercept API errors, mutate auth tokens, or
serve one user's tenant data to another user.
## Testing and Verification
### Automated
- `npm run typecheck` passes.
- `npm run build` passes and emits the manifest, worker, icons, and metadata in
`dist/`.
- Add a Playwright check for Chromium's deferred install event and `prompt()`.
- Add a Playwright check for iOS-style user-agent/standalone detection and the
instructional dialog.
- Add checks that unsupported and already-installed states do not render the
CTA.
- Verify the production Express server returns status 200 for the manifest,
worker, and icon paths, while SPA fallback still serves client routes.
- Run `npm run audit:visual` and confirm no horizontal overflow at 375, 768,
1280, or 1536 pixels.
### Manual device acceptance
- Android Chrome on HTTPS displays the native install action and opens
AgendaMax in standalone mode with the correct icon/name.
- iPhone Safari on HTTPS shows the two-step installation guide and the added
home-screen app opens without browser chrome.
- After a prior visit, temporarily disabling the network still allows the
shell to load; API-backed data correctly remains unavailable rather than
showing stale cached data.
- A new deployment replaces the old worker/cache without requiring the user to
clear browser storage manually.
## Scope Exclusions
- No offline appointment creation, edits, queued writes, conflict resolution,
or background synchronization.
- No native App Store/Play Store packaging.
- No change to authentication, tenant isolation, API routes, or database schema.
## Acceptance Criteria
- AgendaMax has a valid install manifest and 192x192/512x512 icons.
- Android/Chromium exposes an in-app install action when the native event is
available.
- iOS Safari exposes clear Share -> Add to Home Screen instructions.
- Installed standalone mode hides the install action.
- The service worker is registered in production, preserves the shell after a
prior visit, and never caches `/api` or non-GET requests.
- Production HTTPS serving works through the existing Express/Docker path.
- Typecheck, build, PWA behavior checks, and visual audit pass.
@@ -0,0 +1,309 @@
# Landing de funnel con enfoque Business Intelligence + login mágico
**Fecha:** 2026-07-28
**Estado:** aprobado para plan
**Alcance:** solo frontend (`src/`) + ajuste de tres audits Playwright. No toca servidor, esquema ni API.
## Problema
AgendaMax no tiene página de aterrizaje. `/` sirve el formulario de login
([src/App.tsx:34](../../../src/App.tsx#L34) renderiza `<LoginPage/>` inline cuando no hay sesión), así
que un visitante que llega al dominio se topa con una pared de credenciales sin haber leído nunca qué
resuelve el producto ni por qué debería importarle.
Falta la mitad de arriba del funnel: no hay nada que nombre el dolor, cuantifique su costo, muestre el
mecanismo ni empuje a probar. Y el acceso a la demo — que es la conversión real en esta fase — está
escondido al pie del login como una lista de cuentas sin jerarquía.
## Objetivo
Una landing pública en `/` que venda el ángulo **business intelligence**: el dueño de negocio en LATAM
opera a ciegas. El scroll es el vehículo narrativo, no un contenedor de secciones apiladas. Al final,
un `/login` rediseñado donde entrar a la demo cuesta un clic.
### Criterios de éxito
1. `/` sirve la landing sin sesión; con sesión redirige al panel según rol.
2. `/login` es una ruta enlazable con acceso mágico de un clic por cuenta demo.
3. `npm run typecheck` limpio.
4. `npm run audit:responsive` sin hallazgos nuevos a 390 / 440 / 744 / 1180 px, con la landing incluida
en la lista de páginas auditadas.
5. Cero scroll horizontal en cualquiera de esos anchos.
6. Con `prefers-reduced-motion: reduce` la landing es completamente legible y estática.
7. El chunk de `framer-motion` no se descarga al cargar el panel.
### Fuera de alcance
- Pasarela de pago, formulario de registro o captura de leads. La conversión en fase demo es **entrar a
la demo**, no registrarse.
- Endurecer la autenticación. El token trivial y las contraseñas en claro siguen como están; ver
«Seguridad» más abajo.
- Modo oscuro para el panel. La landing usa fondos oscuros mediante un scope propio.
- Internacionalización. Todo el copy es español de LATAM, como el resto del repo.
## Decisiones tomadas
| Decisión | Elegido | Por qué |
|---|---|---|
| Dolor del hero | «No sabes qué pasa en tu negocio» | Es el ángulo BI puro y es lo que la app ya demuestra: dashboard de ingresos, top empleados, top clientes. |
| Librería de animación | `framer-motion` | Orquestación declarativa y física de resorte que no vale la pena reimplementar. Se aísla en su propio chunk para que el panel no lo pague. |
| Ruteo | Landing en `/`, login en `/login` | Es lo que espera un visitante. No rompe `/b/:slug` ni las rutas del panel. |
| Acceso mágico | Solo en `/login` | Mantiene la landing enfocada en persuadir y concentra toda la mecánica de acceso en un lugar. |
## Arquitectura
### Ruteo
```
/ → LandingPage público; con sesión → redirige al panel
/login → LoginPage formulario + acceso mágico; con sesión → redirige al panel
/b/:slug → BookingPage sin cambios, sigue fuera de AuthProvider
/dashboard… → panel protegido sin sesión → <Navigate to="/login" replace/>
```
`src/App.tsx` se reestructura: `Protected` deja de renderizar `<LoginPage/>` como fallback y en su lugar
el árbol de rutas distingue tres zonas — pública (`/`, `/login`), pública sin auth (`/b/:slug`) y
protegida. El splash de carga (`loading === true`) se conserva tal cual.
Detalle: el destino de la redirección con sesión es el mismo que hoy calcula
[src/App.tsx:43-54](../../../src/App.tsx#L43-L54) — `admin``/admin`, `owner``/dashboard`,
`employee``/calendar`. Se extrae a un helper `homePathFor(user)` para no duplicar la regla en tres
sitios.
### Code splitting
`LandingPage` se monta con `React.lazy` + `Suspense`. Dos razones que van en direcciones opuestas y por
eso importan las dos: el usuario con sesión nunca ve la landing y no debe descargarla, y el visitante
en la landing no debe descargar FullCalendar ni recharts.
`framer-motion` se añade a `manualChunks` en [vite.config.ts:33](../../../vite.config.ts#L33) como chunk
`motion`. Sin esto Rollup lo mete en el chunk compartido y los ~50 KB gzip se cobran en cada carga del
panel.
### Archivos
```
src/pages/LandingPage.tsx orquestador; solo compone secciones, ~40 líneas
src/components/landing/
LandingNav.tsx nav flotante que se condensa al scrollear
HeroSection.tsx
CostSection.tsx «el costo de no saber» — 4 fugas con contadores
ShiftSection.tsx cuaderno → tablero, scrub por scroll
ProductSection.tsx 3 actos con panel sticky
ProofSection.tsx mock del dashboard real
ObjectionsSection.tsx
ClosingSection.tsx CTA final + footer
visuals/
CalendarGridVisual.tsx retícula que se puebla de citas
RevenueLineVisual.tsx línea de ingresos que se traza
TeamLoadVisual.tsx carga por especialista
NotebookVisual.tsx cuaderno con tachones (lado «antes»)
DashboardMockVisual.tsx composición del panel a escala
src/lib/motion.ts easings y variants compartidos
src/lib/useReducedMotion.ts wrapper sobre prefers-reduced-motion
```
Una sección por archivo: cada una recibe cero props, no comparte estado con sus hermanas y se puede
leer o rehacer sin abrir las demás. Los visuales viven aparte de las secciones porque son la parte que
más va a iterar y no deberían obligar a releer el copy para tocarlos.
`src/lib/motion.ts` exporta el contrato de movimiento que todas las secciones consumen:
```ts
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const; // ease-out-expo
// Estado inicial → visible. `reduced` colapsa el initial al estado final.
export const revealUp = (reduced: boolean) => ({
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 },
show: { opacity: 1, y: 0, transition: { duration: 0.7, ease: EASE_EXPO } },
});
// Contenedor que escalona a sus hijos; 0 cuando hay movimiento reducido.
export const revealStagger = (reduced: boolean) => ({
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.08 } },
});
```
Un solo easing en todo el sitio. Es lo que produce la sensación de que los elementos «pesan algo y
frenan solos», y mezclar curvas es lo que hace que una landing se sienta improvisada.
## Criterio visual
Cinco reglas, no una lista de efectos:
| Regla | Aplicación |
|---|---|
| Una idea por pantalla | Cada sección ocupa el viewport y afirma *una* cosa. Prohibido el grid de 6 features. |
| Tipografía protagonista | Titulares `clamp(2.75rem, 7vw, 6rem)`, `tracking-tight`, peso 700-800. El texto es la imagen. |
| Silencio | 120-200px entre secciones. El aire es lo que hace que se lea caro. |
| Color contenido | Base `#08080a` y `#fafafa` alternándose a sangre. `brand-500` solo en CTAs y datos. Sin gradientes de relleno. |
| El producto es la foto | Cero stock photos. Los visuales son el producto dibujándose: agenda que se llena, línea que se traza, KPI que cuenta. |
### Tokens de la landing
El `body` del panel es `#f6f7fb` con `color-scheme: light`
([src/index.css:5-42](../../../src/index.css#L5-L42)). La landing necesita fondos oscuros a sangre sin
alterar eso, así que se declara un scope propio en `src/index.css`:
```css
@layer components {
.landing {
--ld-ink: #08080a; /* casi-negro, no negro puro: el negro puro aplana */
--ld-paper: #fafafa;
--ld-muted: #6b6b73; /* texto secundario sobre papel */
--ld-muted-dark: #8a8a94; /* texto secundario sobre tinta */
--ld-hairline: rgba(255, 255, 255, 0.09);
}
.landing-dark { background: var(--ld-ink); color: var(--ld-paper); }
.landing-light { background: var(--ld-paper); color: var(--ld-ink); }
}
```
Va **dentro** de `@layer components`, que es el default del repo: así cualquier utilidad de Tailwind
aplicada en el JSX gana por especificidad de capa y las secciones pueden sobrescribir puntualmente. Solo
las reglas que deben ganar a las utilidades salen de la capa, y aquí ninguna lo necesita
([src/index.css:415](../../../src/index.css#L415)).
## Estructura del funnel
Orden: *problema → costo → mecanismo → prueba → objeción → cierre*.
**1 · Hero** (oscuro)
> **Tu negocio te habla todos los días. Nadie está escuchando.**
> Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los convierte en
> decisiones que te dejan dinero.
> `[Entrar a la demo]` `[Ver cómo funciona ↓]`
Movimiento: titular por líneas con stagger de 80 ms. Detrás, `CalendarGridVisual` poblándose sola,
desenfocada al 20% de opacidad. Indicador de scroll con respiración lenta.
**2 · El costo de no saber** (claro) — cuatro fugas, no cuatro features. Contadores que se animan al
entrar en viewport:
- El 34% de tus horas disponibles se van vacías.
- El cliente que no volvió hace cinco meses sigue en tu lista.
- Un servicio te está costando más de lo que cobra.
- Tu mejor empleado lo sabes por corazonada, no por dato.
**3 · El cambio** (oscuro) — sección sticky con scrub por progreso de scroll. `NotebookVisual` con
tachones se desvanece mientras el mismo día se reconstruye como tablero.
> **No es que trabajes poco. Es que trabajas sin instrumentos.**
**4 · El producto en tres actos** (claro) — panel sticky a la derecha, texto scrolleando a la izquierda,
visual que cambia por acto:
- **Agenda** → `CalendarGridVisual` con una cita arrastrándose. *«Tu día completo en una pantalla.»*
- **Inteligencia** → `RevenueLineVisual` trazándose y top clientes ordenándose. *«Los números que tu
contador te da en marzo, hoy a las 3 de la tarde.»*
- **Equipo** → `TeamLoadVisual`. *«Deja de repartir el trabajo por intuición.»*
**5 · Prueba** (claro) — `DashboardMockVisual` a escala, animándose. Sello: *«Estos son datos reales de
la cuenta demo. Puedes entrar y moverlos.»*
**6 · Objeciones** (claro) — las tres reales de un dueño en LATAM:
- *«No soy de tecnología.»* → Si sabes usar WhatsApp, sabes usar esto.
- *«Mi equipo no lo va a adoptar.»* → Cada empleado ve solo su día. Nada que aprender.
- *«Ya tengo mi cuaderno y me funciona.»* → Tu cuaderno no te dice qué servicio te está costando dinero.
**7 · Cierre** (oscuro) — cierra el círculo del hero:
> **Deja de adivinar.**
> `[Entrar a la demo →]` · Sin registro. Sin tarjeta. Cuentas ya cargadas.
**8 · Footer** — logo, «Demo · AgendaMax», enlace a `/login`.
## Login mágico
`src/pages/LoginPage.tsx` se rediseña con el lenguaje visual de la landing. El cambio de fondo es que
las tarjetas de cuenta pasan a ser el camino **principal**, no un apéndice al pie.
- Consume `GET /api/auth/demo-users`, que **ya existe**
([server/routes/auth.ts:24](../../../server/routes/auth.ts#L24)) y devuelve las cuentas ordenadas
admin → owner → empleados con `email`, `name`, `role`, `avatar_color`. **No se toca el servidor.**
- Se agrupan por rol, rotulando lo que cada uno desbloquea: **Dueña** (tablero completo), **Empleado**
(solo su agenda), **Plataforma** (consola multi-negocio).
- Un clic entra, reutilizando el `quickLogin` ya presente en
[src/pages/LoginPage.tsx:45](../../../src/pages/LoginPage.tsx#L45).
- El formulario manual se conserva **visible** bajo un separador, no colapsado tras un toggle. Es la ruta
que usan los tres audits Playwright, y esconderlo tras un clic obligaría a añadir un paso de expansión
a cada uno. Los selectores `input[type="email"]` y `input[type="password"]` deben existir en el DOM en
la carga inicial de `/login`, sin interacción previa.
Se conserva la bandera `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`) tal como
está hoy. Es una constante de build, así que la contraseña demo se elimina del bundle de producción por
dead-code elimination. En local `npm run dev` la activa sola.
Cuando `DEMO` es falso, `/login` degrada al formulario manual expandido y sin tarjetas.
## Invariantes de responsividad
Ya documentadas en `CLAUDE.md`; romperlas reintroduce bugs conocidos.
- Alturas con `.min-h-screen-safe` / `.h-screen-safe` (`dvh` con `vh` de fallback), **nunca** `100vh`. Una
landing full-bleed es exactamente donde `100vh` falla en iOS por la barra de URL dinámica.
- Campos de formulario a 16px en `pointer: coarse` — ya lo garantiza la regla global de
[src/index.css:444](../../../src/index.css#L444); el login no debe declarar tamaños menores.
- CTAs con 44px de alto mínimo; los decide `pointer: coarse`, no breakpoints `sm:`.
- Todo `grid` con `grid-cols-1` explícito. Sin él la columna implícita se dimensiona a `max-content` y
desborda — es la causa exacta del comentario en
[src/pages/LoginPage.tsx:63-66](../../../src/pages/LoginPage.tsx#L63-L66).
- Visuales anchos scrollean dentro de su contenedor con `overflow-x: auto`; la página nunca.
- Secciones sticky: `position: sticky` con `top` calculado sobre `dvh`, no sobre `vh`.
## Movimiento reducido
`@media (prefers-reduced-motion: reduce)` y el hook `useReducedMotion` deben dejar la landing estática
**y completa**. El riesgo concreto es un elemento con `initial={{ opacity: 0 }}` que nunca anima y queda
invisible: el contenido desaparecería para quien tiene la preferencia activada. La regla es que con
movimiento reducido los `initial` se resuelven al estado final, no que las transiciones se acorten.
Las secciones con scrub por scroll (3 y 4) degradan a su estado final estático, con los visuales
apilados en vez de intercambiados.
## Verificación
| Comprobación | Comando |
|---|---|
| Gate de calidad del repo | `npm run typecheck` |
| Responsividad | `npm run audit:responsive` (requiere `npm run dev`) |
| Capturas | `npm run audit:visual` |
| API sin regresión | `npm run test:e2e`, `npm run test:admin`, `npm run test:booking` |
### Audits que se rompen y hay que arreglar
Tres scripts hacen `goto('/')` y a continuación `fill('input[type="email"]')`. Con `/` sirviendo la
landing ese `fill` falla. Deben apuntar a `/login`:
- [visual-audit.mjs:44](../../../visual-audit.mjs#L44)
- [responsive-audit.mjs:264](../../../responsive-audit.mjs#L264)
- [pwa-e2e.mjs:129](../../../pwa-e2e.mjs#L129) junto con los `goto(baseUrl)` de las líneas 187-288, que
ahora aterrizan en la landing en lugar del login.
El arreglo es un cambio de URL, no de flujo: el formulario manual sigue visible sin interacción previa en
`/login`, así que los `fill` existentes funcionan tal cual. Es exactamente la razón por la que el spec
prohíbe colapsarlo.
Caso aparte en `pwa-e2e.mjs`: el prompt de instalación PWA se probaba sobre `/`. Ahora `/` es la landing,
que no monta `InstallAppPrompt` (vive en el login y dentro del panel). Esos casos apuntan a `/login`.
Además, la landing se añade a la lista de páginas de `responsive-audit.mjs` y `visual-audit.mjs` como
página pública, sin paso de login.
Los tests de API (`test:e2e`, `test:admin`, `test:booking`, `test:unit`) no se tocan: golpean `/api`
directo y no dependen del ruteo del cliente.
## Seguridad
La landing no cambia el modelo de seguridad, pero conviene dejarlo escrito porque el cambio lo hace más
visible: `/login` expone contraseñas de cuentas **existentes** en el cliente. Está autorizado
explícitamente para esta fase demo y ya es el comportamiento actual del repo. La bandera `DEMO` es lo
único que separa eso de una fuga de credenciales si el proyecto se publica en un dominio real. Antes de
cualquier despliegue no-demo hace falta lo que ya lista `CLAUDE.md`: hashing, JWT firmados, validación
estricta y rate-limiting.
## Riesgos
| Riesgo | Mitigación |
|---|---|
| `framer-motion` se filtra al bundle del panel | Chunk manual `motion` + `React.lazy` en la landing. Verificar en la salida de `npm run build` que el chunk existe y que el panel no lo importa. |
| Las secciones sticky descuadran en iPad portrait (1024px) | El repo ya tiene el conflicto documentado entre el corte tablet/desktop y el `lg:` de Tailwind. La landing usa un layout de una columna por debajo de 1024px, evitando el borde. |
| `prefers-reduced-motion` deja contenido invisible | Los `initial` resuelven al estado final; se verifica emulando la preferencia en el audit. |
| Los contadores animados disparan reflow en móvil | Se animan con `transform`/`opacity` y el número se interpola en un `<span>` de ancho tabular (`font-variant-numeric: tabular-nums`) para no relayoutear en cada frame. |
+16 -4
View File
@@ -24,13 +24,25 @@ console.log("\n=== HEALTH & AUTH ===");
const h = await req("/api/health"); check("health", h.json?.ok === true);
const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8);
const owner = await login("owner@agendapro.demo"); check("owner login", !!owner.token && owner.user.role === "owner");
const owner = await login("owner@agendamax.demo"); check("owner login", !!owner.token && owner.user.role === "owner");
const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id);
const adminLogin = await login("admin@agendapro.demo"); check("admin login", adminLogin.user.role === "admin");
const adminLogin = await login("admin@agendamax.demo"); check("admin login", adminLogin.user.role === "admin");
const legacyAdmin = await req("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "demo1234" }),
});
check("[email protected] legacy login rejected", legacyAdmin.status === 401, `status=${legacyAdmin.status}`);
const legacyOwner = await req("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "demo1234" }),
});
check("[email protected] legacy login rejected", legacyOwner.status === 401, `status=${legacyOwner.status}`);
const t = owner.token, eT = empLogin.token, aT = adminLogin.token;
// /me fix
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendapro.demo");
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendamax.demo");
console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ===");
const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa");
@@ -211,4 +223,4 @@ if (fail) {
failures.forEach((f) => console.log(" •", f));
process.exit(1);
}
process.exit(0);
process.exit(0);
+2 -2
View File
@@ -27,11 +27,11 @@ async function findSlot(slug, serviceId, maxDays = 30) {
}
// /me now works (the bug we fixed) — login first to get a real token
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" });
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
const t = login.token;
const { json: me } = await req("GET", "/auth/me", null, t);
check("/api/auth/me returns user", me.user?.email === "owner@agendapro.demo", JSON.stringify(me).slice(0, 100));
check("/api/auth/me returns user", me.user?.email === "owner@agendamax.demo", JSON.stringify(me).slice(0, 100));
// Slug del negocio (para consultar slots públicos)
const { json: settings } = await req("GET", "/settings", null, t);
+14 -2
View File
@@ -3,12 +3,24 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, viewport-fit=cover" />
<meta name="theme-color" content="#3b66ff" />
<meta name="description" content="AgendaMax — Gestión visual de citas, empleados e ingresos para tu negocio." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Inter es la voz de la plataforma y también el cuerpo de la landing: misma
tipografía dentro y fuera del producto. Fraunces (display cálido, con eje
WONK) se reserva para los titulares de la landing, que es lo único que
necesita personalidad de marketing. -->
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Fraunces:opsz,[email protected],600..900&display=swap"
rel="stylesheet"
/>
<title>AgendaMax — Gestión visual de tu negocio</title>
</head>
<body>
+202
View File
@@ -0,0 +1,202 @@
// 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 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 () => {
await page.waitForSelector("[data-magic-login]", { state: "visible" });
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 });
});
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 calmPage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
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,
sample: invisibles[0].textContent.trim().slice(0, 50),
});
}
return out;
});
assert.deepEqual(hidden, []);
});
await calm.close();
console.log(`\n${passed.length} comprobaciones OK`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(`\nlanding-e2e falló: ${error.message}`);
process.exitCode = 1;
});
+43 -14
View File
@@ -20,6 +20,7 @@
"clsx": "^2.1.1",
"cors": "^2.8.5",
"express": "^4.21.0",
"framer-motion": "^11.18.2",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -91,7 +92,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -369,7 +369,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -997,7 +996,6 @@
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
@@ -1844,7 +1842,6 @@
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1933,7 +1930,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2188,7 +2184,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806",
@@ -2894,7 +2889,6 @@
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3309,6 +3303,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^11.18.1",
"motion-utils": "^11.18.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -3649,7 +3670,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -3940,6 +3960,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^11.18.1"
}
},
"node_modules/motion-utils": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4268,7 +4303,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
@@ -4538,7 +4572,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4551,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5260,7 +5292,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5319,7 +5350,6 @@
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -5483,7 +5513,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+6 -1
View File
@@ -22,7 +22,11 @@
"test:admin": "node admin-test.mjs",
"test:unit": "node --import tsx --test server/lib/scheduling.test.ts server/lib/time.test.ts server/lib/metrics.test.ts",
"test:booking": "node server/scripts/booking-e2e.mjs",
"audit:visual": "node visual-audit.mjs"
"test:landing": "node landing-e2e.mjs",
"audit:visual": "node visual-audit.mjs",
"audit:responsive": "node responsive-audit.mjs",
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
"test:pwa": "node pwa-e2e.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@@ -37,6 +41,7 @@
"clsx": "^2.1.1",
"cors": "^2.8.5",
"express": "^4.21.0",
"framer-motion": "^11.18.2",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "AgendaMax",
"short_name": "AgendaMax",
"description": "Gestión visual de citas, empleados e ingresos para tu negocio.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f7fb",
"theme_color": "#3b66ff",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+61
View File
@@ -0,0 +1,61 @@
// v2: la landing pública cambió el app shell y partió el bundle en chunks nuevos. Los
// assets viejos quedarían huérfanos en el caché de quien ya visitó el sitio, porque las
// peticiones de assets son cache-first por nombre con hash. Subir el sufijo hace que el
// `activate` los purgue.
const CACHE_NAME = "agendamax-shell-v2";
const SHELL_URLS = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key.startsWith("agendamax-shell-") && key !== CACHE_NAME)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (request.credentials === "omit" && response.ok) {
const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => caches.match("/"))
);
return;
}
const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]);
if (request.credentials !== "omit" || !cacheableDestination.has(request.destination)) return;
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
});
})
);
});
+314
View File
@@ -0,0 +1,314 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const BASE = process.env.PWA_BASE_URL || "http://localhost:3000";
const baseUrl = BASE.endsWith("/") ? BASE : `${BASE}/`;
// `/` sirve la landing pública, que no monta InstallAppPrompt (vive en el login y
// dentro del panel). Todos los contextos que prueban el prompt aterrizan aquí.
const loginUrl = new URL("login", baseUrl).href;
const OWNER_EMAIL = process.env.PWA_OWNER_EMAIL || "[email protected]";
const ADMIN_EMAIL = process.env.PWA_ADMIN_EMAIL || "[email protected]";
const PWA_PASSWORD = process.env.PWA_PASSWORD || "demo1234";
const NETWORK_TIMEOUT_MS = 8000;
const UI_TIMEOUT_MS = 8000;
function url(pathname) {
return new URL(pathname.replace(/^\//, ""), baseUrl).href;
}
async function fetchWithTimeout(pathname, timeoutMs = NETWORK_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url(pathname), { signal: controller.signal });
const body = await response.text();
return { response, body };
} finally {
clearTimeout(timeout);
}
}
async function assertEndpoint(pathname) {
const { response, body } = await fetchWithTimeout(pathname);
assert.equal(response.status, 200, `${pathname} returned ${response.status}`);
return { response, body };
}
async function waitForServiceWorker(page) {
await page.evaluate(async () => {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timed out waiting for service worker readiness")), 5000);
});
await Promise.race([navigator.serviceWorker.ready, timeout]);
});
}
async function assertServiceWorkerApiBypass(page, context) {
await page.reload({ waitUntil: "networkidle" });
await waitForServiceWorker(page);
await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, { timeout: UI_TIMEOUT_MS });
const apiRequests = async () => page.evaluate(async (timeoutMs) => {
async function request(pathname, init) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(pathname, { ...init, signal: controller.signal });
return { status: response.status, body: await response.text() };
} catch (error) {
return { networkError: true, errorName: error instanceof Error ? error.name : String(error) };
} finally {
clearTimeout(timeout);
}
}
return {
get: await request("/api/health"),
post: await request("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "invalid" }),
}),
};
}, NETWORK_TIMEOUT_MS);
const online = await apiRequests();
assert.equal(online.get.status, 200, "Online API GET should reach /api/health");
assert.equal(online.post.status, 401, "Online API POST should reach invalid login");
await context.setOffline(true);
try {
const offline = await apiRequests();
assert.equal(offline.get.networkError, true, "Offline API GET returned a response instead of failing");
assert.equal(offline.post.networkError, true, "Offline API POST returned a response instead of failing");
} finally {
await context.setOffline(false);
}
}
async function assertInstallAction(page, expectedVisible, surface = "page") {
const action = page.getByRole("button", { name: "Instalar AgendaMax" });
if (expectedVisible) {
await page.waitForFunction(
() => document.querySelectorAll('button[aria-label="Instalar AgendaMax"]').length === 1,
undefined,
{ timeout: UI_TIMEOUT_MS }
);
assert.equal(await action.count(), 1, `Expected exactly one install action on ${surface}`);
await action.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
} else {
await page.waitForFunction(
() => document.querySelectorAll('button[aria-label="Instalar AgendaMax"]').length === 0,
undefined,
{ timeout: UI_TIMEOUT_MS }
);
assert.equal(await action.count(), 0, `Install action should not be visible on ${surface}`);
}
return action;
}
async function dispatchInstallPrompt(page) {
await page.waitForFunction(() => {
const selector = 'button[aria-label="Instalar AgendaMax"]';
if (document.querySelectorAll(selector).length === 1) return true;
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = async () => { window.__installPromptCalls += 1; };
event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" });
window.dispatchEvent(event);
return false;
}, undefined, { timeout: UI_TIMEOUT_MS });
}
async function dispatchDismissedInstallPrompt(page) {
await page.evaluate(() => {
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = async () => { window.__installPromptCalls += 1; };
event.userChoice = Promise.resolve({ outcome: "dismissed", platform: "web" });
window.dispatchEvent(event);
});
}
async function login(page, email, expectedPath) {
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', PWA_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(expectedPath, { timeout: UI_TIMEOUT_MS });
await page.waitForSelector("header", { state: "attached", timeout: UI_TIMEOUT_MS });
}
async function assertVisibleText(page, text) {
const locator = page.getByText(text, { exact: true });
await locator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.equal(await locator.count(), 1, `Expected one visible ${text} instruction`);
}
async function main() {
for (const pathname of ["/manifest.webmanifest", "/sw.js", "/icon-192.png", "/icon-512.png"]) {
await assertEndpoint(pathname);
}
const manifestResult = await assertEndpoint("/manifest.webmanifest");
const manifest = JSON.parse(manifestResult.body);
assert.equal(manifest.display, "standalone");
assert.equal(manifest.start_url, "/");
assert.equal(manifest.scope, "/");
assert.ok(manifest.icons?.some((icon) => icon.sizes === "192x192"), "Manifest lacks 192x192 icon");
assert.ok(manifest.icons?.some((icon) => icon.sizes === "512x512"), "Manifest lacks 512x512 icon");
const spaResult = await assertEndpoint("/calendar");
const spaHtml = spaResult.body;
assert.match(spaHtml, /<div id="root"><\/div>/, "SPA route did not return built HTML");
assert.doesNotMatch(spaHtml, /Cannot GET|404 Not Found/i, "SPA route returned a 404 page");
const healthResult = await assertEndpoint("/api/health");
const healthResponse = healthResult.response;
assert.match(healthResponse.headers.get("content-type") || "", /application\/json/);
const health = JSON.parse(healthResult.body);
assert.equal(health.ok, true, "Health endpoint did not return ok=true");
const browser = await chromium.launch();
const contexts = [];
try {
const chromiumContext = await browser.newContext();
contexts.push(chromiumContext);
chromiumContext.setDefaultTimeout(UI_TIMEOUT_MS);
chromiumContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS);
await chromiumContext.addInitScript(() => {
window.__installPromptCalls = 0;
window.addEventListener("load", () => setTimeout(() => {
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = async () => { window.__installPromptCalls += 1; };
event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" });
window.dispatchEvent(event);
}, 100), { once: true });
});
const chromiumPage = await chromiumContext.newPage();
const apiRequests = [];
chromiumPage.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/health") apiRequests.push(request);
});
await chromiumPage.goto(loginUrl, { waitUntil: "networkidle" });
await waitForServiceWorker(chromiumPage);
const action = await assertInstallAction(chromiumPage, true, "login");
await action.click();
await chromiumPage.waitForFunction(() => window.__installPromptCalls === 1);
assert.equal(await chromiumPage.evaluate(() => window.__installPromptCalls), 1);
const dismissalContext = await browser.newContext();
contexts.push(dismissalContext);
dismissalContext.setDefaultTimeout(UI_TIMEOUT_MS);
dismissalContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS);
await dismissalContext.addInitScript(() => { window.__installPromptCalls = 0; });
const dismissalPage = await dismissalContext.newPage();
await dismissalPage.goto(loginUrl, { waitUntil: "networkidle" });
await dispatchDismissedInstallPrompt(dismissalPage);
const dismissalAction = await assertInstallAction(dismissalPage, true, "dismissal");
await dismissalAction.click();
await assertInstallAction(dismissalPage, false, "dismissed prompt");
assert.equal(await dismissalPage.evaluate(() => sessionStorage.getItem("agendamax-install-dismissed")), "1");
await dismissalPage.reload({ waitUntil: "networkidle" });
assert.equal(await dismissalPage.evaluate(() => sessionStorage.getItem("agendamax-install-dismissed")), "1");
await dispatchDismissedInstallPrompt(dismissalPage);
await assertInstallAction(dismissalPage, false, "dismissed prompt after reload");
const apiResult = await chromiumPage.evaluate(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch("/api/health", { signal: controller.signal });
return { status: response.status, contentType: response.headers.get("content-type"), body: await response.json() };
} finally {
clearTimeout(timeout);
}
});
assert.equal(apiResult.status, 200);
assert.match(apiResult.contentType || "", /application\/json/);
assert.equal(apiResult.body.ok, true);
assert.ok(apiRequests.length > 0, "Browser did not reach the real /api/health endpoint");
await assertServiceWorkerApiBypass(chromiumPage, chromiumContext);
const businessPage = await chromiumContext.newPage();
await businessPage.setViewportSize({ width: 375, height: 720 });
await businessPage.goto(loginUrl, { waitUntil: "networkidle" });
await login(businessPage, OWNER_EMAIL, /\/dashboard/);
await dispatchInstallPrompt(businessPage);
const businessAction = await assertInstallAction(businessPage, true, "business shell");
await businessAction.click();
await businessPage.waitForFunction(() => window.__installPromptCalls === 1);
const iosContext = await browser.newContext({
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
contexts.push(iosContext);
iosContext.setDefaultTimeout(UI_TIMEOUT_MS);
iosContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS);
await iosContext.addInitScript(() => {
Object.defineProperty(Navigator.prototype, "standalone", { configurable: true, get: () => false });
});
const iosPage = await iosContext.newPage();
await iosPage.goto(loginUrl, { waitUntil: "networkidle" });
const iosAction = await assertInstallAction(iosPage, true, "iOS");
await iosAction.click();
await assertVisibleText(iosPage, "Compartir");
await assertVisibleText(iosPage, "Añadir a pantalla de inicio");
const adminContext = await browser.newContext({ viewport: { width: 375, height: 720 } });
contexts.push(adminContext);
adminContext.setDefaultTimeout(UI_TIMEOUT_MS);
adminContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS);
await adminContext.addInitScript(() => { window.__installPromptCalls = 0; });
const adminPage = await adminContext.newPage();
await adminPage.goto(loginUrl, { waitUntil: "networkidle" });
await login(adminPage, ADMIN_EMAIL, /\/admin/);
await dispatchInstallPrompt(adminPage);
const adminAction = await assertInstallAction(adminPage, true, "admin shell");
await adminAction.click();
await adminPage.waitForFunction(() => window.__installPromptCalls === 1);
const standaloneContext = await browser.newContext();
contexts.push(standaloneContext);
standaloneContext.setDefaultTimeout(UI_TIMEOUT_MS);
standaloneContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS);
await standaloneContext.addInitScript(() => {
const originalMatchMedia = window.matchMedia.bind(window);
window.matchMedia = (query) => query === "(display-mode: standalone)"
? { matches: true, media: query, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false; } }
: originalMatchMedia(query);
});
const standalonePage = await standaloneContext.newPage();
// Sobre la landing la ausencia del prompt no probaría nada: hay que mirarla
// donde el prompt sí debería estar.
await standalonePage.goto(loginUrl, { waitUntil: "networkidle" });
await assertInstallAction(standalonePage, false, "standalone");
const unsupportedContext = await browser.newContext();
contexts.push(unsupportedContext);
unsupportedContext.setDefaultTimeout(UI_TIMEOUT_MS);
unsupportedContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS);
const unsupportedPage = await unsupportedContext.newPage();
await unsupportedPage.goto(loginUrl, { waitUntil: "networkidle" });
await assertInstallAction(unsupportedPage, false, "unsupported");
const swSource = (await assertEndpoint("/sw.js")).body;
assert.match(swSource, /pathname\s*\.\s*startsWith\s*\(\s*["']\/api\/["']\s*\)/, "Service worker lacks API bypass");
assert.match(swSource, /request\s*\.\s*method\s*!==\s*["']GET["']/, "Service worker lacks non-GET guard");
assert.match(swSource, /request\.credentials\s*!==\s*["']omit["']/, "Service worker lacks credential bypass");
assert.match(swSource, /key\.startsWith\(["']agendamax-shell-["']\)/, "Service worker cleanup is not scoped to AgendaMax caches");
console.log("PWA checks passed");
} finally {
try {
for (const context of contexts.reverse()) await context.close();
} finally {
await browser.close();
}
}
}
main().catch((error) => {
console.error(`PWA checks failed: ${error.message}`);
process.exitCode = 1;
});
+654
View File
@@ -0,0 +1,654 @@
// responsive-audit.mjs — auditoría multi-dispositivo (iPhone 16 Pro Max / iPhone 12 / iPad).
//
// Qué mide y por qué:
//
// 1. ZOOM-AL-ENFOCAR (iOS). Mobile Safari hace auto-zoom cuando enfocas un campo
// con font-size < 16px, y NO revierte al desenfocar: la página queda escalada y
// corrida. Ese comportamiento es exclusivo de Mobile Safari y no se reproduce en
// un navegador de escritorio, así que no lo "actuamos": medimos su predictor
// exacto y determinista — el font-size computado de cada campo de formulario.
// < 16px en un viewport táctil = zoom garantizado en el dispositivo real.
//
// 2. OVERFLOW HORIZONTAL. scrollWidth > clientWidth en la raíz: la causa de que la
// página "aparezca desplazada".
//
// 3. ALTURA DE VIEWPORT. 100vh en iOS no descuenta la barra de URL dinámica; el shell
// queda más alto que el área visible. Comparamos la altura del shell contra la
// altura del viewport.
//
// 4. SAFE AREAS. Con viewport-fit=cover el contenido se mete bajo la Dynamic Island y
// el home indicator. Playwright no puede inyectar insets reales, así que se verifica
// de forma estática que el CSS servido declare env(safe-area-inset-*) en los
// elementos de chrome (header/sidebar). Se reporta como check de código, no de píxel.
//
// 5. CALENDARIO. Overflow propio, altura fija vs proporcional al viewport, y que la
// vista elegida corresponda al tamaño de pantalla.
//
// Uso: npm run audit:responsive (requiere `npm run dev` corriendo)
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.RESPONSIVE_BASE_URL || "http://localhost:5173";
const OUT = process.env.RESPONSIVE_OUT || "screenshots/responsive";
const SHOOT = process.env.RESPONSIVE_SCREENSHOTS !== "0";
// CSS px reales de cada dispositivo objetivo.
const DEVICES = [
{ name: "iphone-12", label: "iPhone 12", width: 390, height: 844, dpr: 3, touch: true, tier: "phone" },
{ name: "iphone-16-pro-max", label: "iPhone 16 Pro Max", width: 440, height: 956, dpr: 3, touch: true, tier: "phone" },
{ name: "iphone-16-pro-max-land", label: "iPhone 16 Pro Max (landscape)", width: 956, height: 440, dpr: 3, touch: true, tier: "phone-land" },
{ name: "ipad-portrait", label: "iPad 10.9\" (portrait)", width: 820, height: 1180, dpr: 2, touch: true, tier: "tablet" },
{ name: "ipad-landscape", label: "iPad 10.9\" (landscape)", width: 1180, height: 820, dpr: 2, touch: true, tier: "tablet-land" },
{ name: "ipad-mini", label: "iPad mini (portrait)", width: 744, height: 1133, dpr: 2, touch: true, tier: "tablet" },
// `tier` describe qué vista de calendario corresponde; `sidebar` marca si hay
// barra lateral fija (Tailwind `lg:` = 1024px). A 1024 se cumplen las dos cosas:
// tier tablet (3 días) Y barra lateral, que es lo buscado en iPad Pro portrait.
{ name: "ipad-pro-portrait", label: "iPad Pro 12.9\" (portrait)", width: 1024, height: 1366, dpr: 2, touch: true, tier: "tablet", sidebar: true },
{ name: "ipad-pro-landscape", label: "iPad Pro 12.9\" (landscape)", width: 1366, height: 1024, dpr: 2, touch: true, tier: "desktop", sidebar: true },
{ name: "desktop", label: "Desktop", width: 1440, height: 900, dpr: 1, touch: false, tier: "desktop", sidebar: true },
];
const PAGES = [
// La landing es pública y no lleva sesión; se audita como cualquier otra página.
// `/` dejó de servir el login, que ahora vive en `/login`.
{ name: "landing", path: "/", role: null },
{ name: "login", path: "/login", role: null },
{ name: "calendar", path: "/calendar", role: "owner", waitFor: ".fc" },
{ name: "dashboard", path: "/dashboard", role: "owner" },
{ name: "clients", path: "/clients", role: "owner" },
{ name: "settings", path: "/settings", role: "owner" },
{ name: "employees", path: "/employees", role: "owner" },
{ name: "emp-calendar", path: "/calendar", role: "employee", waitFor: ".fc" },
{ name: "booking", path: "/b/negocio", role: null },
];
const MIN_TOUCH_FONT_PX = 16; // umbral de Mobile Safari para NO hacer auto-zoom
const MIN_TAP_TARGET_PX = 40; // guía de Apple: 44pt; 40 tolera padding/borde
const MIN_GUTTER_PX = 12; // margen lateral mínimo del texto en las páginas públicas
const findings = [];
function fail(device, page, kind, detail) {
findings.push({ device, page, kind, detail });
}
/** Campos de formulario cuyo font-size dispararía el auto-zoom de iOS. */
async function measureFormFields(page) {
return page.evaluate((min) => {
const sel = 'input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="hidden"]),select,textarea';
const bad = [];
let total = 0;
for (const el of document.querySelectorAll(sel)) {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") continue;
total++;
const fs = parseFloat(cs.fontSize);
if (fs < min - 0.01) {
bad.push({
tag: el.tagName.toLowerCase(),
type: el.getAttribute("type") || "",
cls: (el.className || "").toString().slice(0, 70),
fontSize: Math.round(fs * 100) / 100,
});
}
}
return { total, bad };
}, MIN_TOUCH_FONT_PX);
}
async function measureOverflow(page) {
return page.evaluate(() => {
const de = document.documentElement;
const docWidth = de.clientWidth;
const scrollWidth = Math.max(de.scrollWidth, document.body.scrollWidth);
const offenders = [];
for (const el of document.querySelectorAll("*")) {
const r = el.getBoundingClientRect();
if (r.width < 40 || r.height < 8) continue;
if (r.right > docWidth + 2) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" && r.width <= docWidth + 2) continue;
offenders.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || "").toString().slice(0, 70),
right: Math.round(r.right),
width: Math.round(r.width),
});
}
}
return { docWidth, scrollWidth, overflow: scrollWidth - docWidth, offenders: offenders.slice(0, 6) };
});
}
/** El shell no debe ser más alto que el viewport (síntoma de 100vh en iOS). */
async function measureShellHeight(page) {
return page.evaluate(() => {
const vh = window.innerHeight;
// El defecto que se busca es un shell de ALTURA FIJA más alto que el área
// visible: el clásico 100vh que en iOS no descuenta la barra de URL. Ese shell
// se marca con [data-app-shell] (AppShell y AdminShell).
//
// Una página que scrollea a propósito —la landing, el login, el booking
// público— es legítimamente más alta que el viewport, así que comparar su alto
// contra vh no mide un defecto: mide que existe scroll. Antes se colaba por el
// fallback a body.firstElementChild, que en esas páginas apunta a #root.
const fixed = document.querySelector("[data-app-shell]");
const shell = fixed || document.body.firstElementChild;
if (!shell) return null;
const h = Math.round(shell.getBoundingClientRect().height);
return { viewportHeight: vh, shellHeight: h, delta: h - vh, hasFixedShell: !!fixed };
});
}
/**
* Canaleta lateral del texto en las páginas públicas.
*
* Existe por un fallo concreto: `.safe-x` está fuera de `@layer` en index.css, así
* que gana a las utilidades `px-*` de Tailwind y deja el padding lateral en 0 cuando
* el inset del sistema es 0 o sea, en todo lo que no sea un iPhone en landscape. El
* texto acaba pegado al borde y ni el check de overflow ni el de zoom lo detectan,
* porque no hay desborde: hay cero margen.
*/
async function measureGutter(page) {
return page.evaluate(() => {
const vw = document.documentElement.clientWidth;
let min = Infinity;
let culpable = null;
for (const el of document.querySelectorAll("h1,h2,h3,p,label,button,a,li")) {
// Solo nodos con texto propio y visibles: los contenedores a sangre (fondos,
// lavados de color) sí pueden y deben tocar el borde.
const propio = [...el.childNodes].some(
(n) => n.nodeType === 3 && n.textContent.trim().length > 1
);
if (!propio) continue;
const cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) < 0.05) continue;
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue;
const holgura = Math.min(r.left, vw - r.right);
if (holgura < min) {
min = holgura;
culpable = `${el.tagName.toLowerCase()} "${(el.textContent || "").trim().slice(0, 30)}"`;
}
}
return { min: Number.isFinite(min) ? Math.round(min) : null, culpable };
});
}
async function measureCalendar(page) {
return page.evaluate(() => {
const fc = document.querySelector(".fc");
if (!fc) return null;
const r = fc.getBoundingClientRect();
const scroller = fc.querySelector(".fc-scroller-harness") || fc;
// La clase de vista real es del tipo `fc-timeGridDay-view`; hay que excluir
// `fc-media-screen` y compañía, que también viven en el nodo raíz.
let viewName = "";
for (const el of fc.querySelectorAll("*")) {
const m = /\bfc-([A-Za-z]+)-view\b/.exec(el.className || "");
if (m) { viewName = m[1]; break; }
}
// ¿el calendario desborda su tarjeta contenedora?
const card = fc.closest(".card");
const cardR = card ? card.getBoundingClientRect() : null;
return {
width: Math.round(r.width),
height: Math.round(r.height),
viewportHeight: window.innerHeight,
heightRatio: Math.round((r.height / window.innerHeight) * 100) / 100,
view: viewName,
innerScrollWidth: scroller.scrollWidth,
innerClientWidth: scroller.clientWidth,
cardOverflowX: cardR ? Math.round(r.width - cardR.width) : 0,
};
});
}
/**
* El menú lateral debe ser una única columna vertical. Se comprueba de verdad y
* no por inspección de clases: una regla CSS que cambie el `display` de los
* enlaces a inline-flex los reparte en dos por fila, que es exactamente el fallo
* que se vio en iPad Pro.
*/
async function measureSidebarNav(page) {
return page.evaluate(() => {
// La barra visible en lg+ (la del panel móvil está fuera de flujo).
const aside = [...document.querySelectorAll("aside")].find((a) => {
const r = a.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(a).position !== "absolute";
});
if (!aside) return null;
const nav = aside.querySelector("nav");
if (!nav) return null;
const links = [...nav.querySelectorAll("a,button")].filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
if (links.length < 2) return null;
const rects = links.map((el) => el.getBoundingClientRect());
// Dos entradas comparten fila si sus rangos verticales se solapan.
let sameRowPairs = 0;
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
const vOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (vOverlap > 4) sameRowPairs++;
}
}
const lefts = new Set(rects.map((r) => Math.round(r.left)));
return {
count: links.length,
sameRowPairs,
distinctLefts: lefts.size,
asideWidth: Math.round(aside.getBoundingClientRect().width),
displays: [...new Set(links.map((el) => getComputedStyle(el).display))],
};
});
}
/**
* Los botones de solo icono se agrandan a 40px en táctil; el icono debe quedar
* centrado en la caja nueva y no pegado a un borde.
*/
async function measureIconCentering(page) {
return page.evaluate(() => {
const bad = [];
for (const btn of document.querySelectorAll("button")) {
if (btn.querySelectorAll(":scope > *").length !== 1) continue;
const svg = btn.querySelector(":scope > svg");
if (!svg) continue;
// Sin texto visible: en un botón con etiqueta ("<Plus/> Nueva cita") el icono
// va a la izquierda por diseño. Contar hijos elemento no basta para
// distinguirlos, porque el label es un nodo de texto y no cuenta.
if ((btn.textContent || "").trim().length > 0) continue;
const br = btn.getBoundingClientRect();
const sr = svg.getBoundingClientRect();
if (br.width === 0 || sr.width === 0) continue;
const dx = sr.left + sr.width / 2 - (br.left + br.width / 2);
const dy = sr.top + sr.height / 2 - (br.top + br.height / 2);
if (Math.abs(dx) > 2.5 || Math.abs(dy) > 2.5) {
bad.push({
label: (btn.getAttribute("aria-label") || btn.getAttribute("title") || "?").slice(0, 24),
dx: Math.round(dx),
dy: Math.round(dy),
});
}
}
return bad.slice(0, 6);
});
}
/** Objetivos táctiles demasiado pequeños en pantallas touch. */
async function measureTapTargets(page) {
return page.evaluate((min) => {
const small = [];
for (const el of document.querySelectorAll('button,a[href],[role="button"],select')) {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || cs.pointerEvents === "none") continue;
const r = el.getBoundingClientRect();
// Descarta lo oculto visualmente (sr-only mide 1px y no se toca).
if (r.width < 8 || r.height < 8) continue;
if (r.height < min) {
small.push({
tag: el.tagName.toLowerCase(),
text: (el.textContent || "").trim().slice(0, 28),
cls: (el.className || "").toString().slice(0, 50),
h: Math.round(r.height),
});
}
}
return small.slice(0, 8);
}, MIN_TAP_TARGET_PX);
}
async function login(browser, role) {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const email =
role === "admin" ? "[email protected]"
: role === "owner" ? "[email protected]"
: "[email protected]";
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "demo1234");
await page.click('button[type="submit"]');
await page.waitForURL(/\/(dashboard|calendar|admin)/, { timeout: 15000 });
await page.waitForTimeout(1500);
const state = await context.storageState();
await context.close();
return state;
}
/** Check estático: el CSS servido debe declarar safe-area en el chrome de la app. */
async function auditSafeAreaCss() {
const css = fs.readFileSync("src/index.css", "utf8");
const shells = ["src/components/AppShell.tsx", "src/components/AdminShell.tsx"];
const out = { insetsInCss: /env\(\s*safe-area-inset-/.test(css), shellUsage: {} };
for (const f of shells) {
const src = fs.readFileSync(f, "utf8");
out.shellUsage[path.basename(f)] = {
top: /\bsafe-top\b/.test(src),
bottom: /\bsafe-area-bottom\b/.test(src),
// La altura dinámica llega vía la clase .h-screen-safe, que resuelve
// 100dvh con fallback a 100vh en index.css.
dvh: /\bh-screen-safe\b/.test(src) && /100dvh/.test(css),
noRawScreen: !/\bh-screen\b(?!-safe)/.test(src),
};
}
const html = fs.readFileSync("index.html", "utf8");
const vp = html.match(/<meta\s+name="viewport"\s+content="([^"]+)"/i);
out.viewport = vp ? vp[1] : null;
out.viewportFitCover = !!vp && /viewport-fit\s*=\s*cover/.test(vp[1]);
// La landing y el login scrollean a propósito, así que el check dinámico de
// altura de shell no aplica ahí (ver measureShellHeight). El defecto real —usar
// `100vh` o `h-screen` crudos en vez de las utilidades `dvh`— se caza en
// estático sobre las fuentes: es determinista y no necesita navegador.
const publicFiles = [
"src/pages/LandingPage.tsx",
"src/pages/LoginPage.tsx",
...fs
.readdirSync("src/components/landing")
.filter((n) => n.endsWith(".tsx"))
.map((n) => `src/components/landing/${n}`),
];
out.rawViewportUnits = [];
for (const f of publicFiles) {
const src = fs.readFileSync(f, "utf8");
// `h-screen`/`min-h-screen` sin el sufijo `-safe`, o un `100vh` literal.
if (/\b(?:min-|max-)?h-screen\b(?!-safe)/.test(src) || /(^|[^d])100vh/.test(src)) {
out.rawViewportUnits.push(path.basename(f));
}
}
return out;
}
async function pickBrowser() {
const pw = await import("playwright");
for (const name of ["webkit", "chromium"]) {
try {
const b = await pw[name].launch();
return { browser: b, engine: name };
} catch {
/* try next */
}
}
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
}
async function run() {
if (SHOOT) fs.mkdirSync(OUT, { recursive: true });
const { browser, engine } = await pickBrowser();
console.log(`Motor: ${engine}${engine === "chromium" ? " (webkit no instalado; layout válido, sin gestos de Safari)" : " (Safari real)"}`);
console.log(`Base: ${BASE}\n`);
const staticAudit = await auditSafeAreaCss();
for (const f of staticAudit.rawViewportUnits) {
fail("estático", "landing", "raw-viewport-unit", `${f} usa h-screen/100vh crudo (usa las utilidades -safe con dvh)`);
}
const states = {
owner: await login(browser, "owner"),
employee: await login(browser, "employee"),
};
const rows = [];
for (const dev of DEVICES) {
for (const p of PAGES) {
const tag = `${dev.name}/${p.name}`;
const context = await browser.newContext({
viewport: { width: dev.width, height: dev.height },
deviceScaleFactor: dev.dpr,
hasTouch: dev.touch,
isMobile: engine === "chromium" ? dev.touch : undefined,
storageState: p.role ? states[p.role] : undefined,
});
const page = await context.newPage();
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text().slice(0, 160)));
page.on("pageerror", (e) => errors.push("PAGEERROR: " + e.message.slice(0, 160)));
try {
await page.goto(`${BASE}${p.path}`, { waitUntil: "networkidle", timeout: 25000 });
if (p.waitFor) await page.waitForSelector(p.waitFor, { timeout: 20000 });
await page.waitForTimeout(1200);
const fields = await measureFormFields(page);
const overflow = await measureOverflow(page);
const shell = await measureShellHeight(page);
const cal = await measureCalendar(page);
const taps = dev.touch ? await measureTapTargets(page) : [];
// Solo en las páginas públicas de marketing: el panel usa contenedores a
// sangre a propósito y ahí la medida daría falsos positivos.
const gutter =
p.name === "landing" || p.name === "login" ? await measureGutter(page) : null;
if (gutter && gutter.min !== null && gutter.min < MIN_GUTTER_PX) {
fail(dev.name, p.name, "gutter", `texto a ${gutter.min}px del borde (${gutter.culpable})`);
}
const nav = p.role ? await measureSidebarNav(page) : null;
const icons = dev.touch ? await measureIconCentering(page) : [];
if (icons.length) {
fail(dev.name, p.name, "icon-center", `${icons.length} iconos descentrados (ej. "${icons[0].label}" dx=${icons[0].dx} dy=${icons[0].dy})`);
}
if (nav && nav.sameRowPairs > 0) {
fail(dev.name, p.name, "nav-columns", `${nav.sameRowPairs} pares de entradas comparten fila (menú en ${nav.distinctLefts} columnas, display=${nav.displays.join("/")})`);
}
if (dev.touch && fields.bad.length) {
fail(dev.name, p.name, "ios-zoom", `${fields.bad.length}/${fields.total} campos < ${MIN_TOUCH_FONT_PX}px (min ${Math.min(...fields.bad.map((b) => b.fontSize))}px)`);
}
if (overflow.overflow > 2) {
const first = overflow.offenders[0];
fail(dev.name, p.name, "overflow-x", `+${overflow.overflow}px; primer culpable: ${first ? `${first.tag}.${first.cls.split(" ")[0]}` : "?"}`);
}
if (shell && shell.hasFixedShell && shell.delta > 2) {
fail(dev.name, p.name, "shell-height", `shell ${shell.shellHeight}px > viewport ${shell.viewportHeight}px (+${shell.delta})`);
}
if (cal && cal.innerScrollWidth - cal.innerClientWidth > 2) {
fail(dev.name, p.name, "calendar-scroll-x", `scroll interno +${cal.innerScrollWidth - cal.innerClientWidth}px`);
}
if (cal && dev.tier !== "desktop" && cal.heightRatio > 1.15) {
fail(dev.name, p.name, "calendar-height", `alto ${cal.height}px = ${cal.heightRatio}× viewport`);
}
// El otro extremo: una grilla aplastada es tan inservible como una que desborda.
if (cal && cal.height < 320) {
fail(dev.name, p.name, "calendar-too-short", `alto ${cal.height}px (< 320px mínimo usable)`);
}
// La vista por defecto debe corresponder al tamaño de pantalla.
if (cal && p.name === "calendar") {
// En landscape un teléfono tiene ancho de tablet (956px en el 16 Pro Max),
// así que la vista Semana sí corresponde: son 7 columnas de ~120px, no las
// columnas aplastadas que se querían evitar en portrait.
// El tier se decide por ancho, que es lo que condiciona el layout: un
// teléfono en landscape (956px) recibe la misma vista que una tablet.
const expected =
dev.tier === "phone" ? ["timeGridDay", "listWeek"]
: dev.tier === "tablet" || dev.tier === "phone-land"
? ["timeGridThreeDay", "timeGridWeek", "timeGridDay", "listWeek"]
: ["timeGridWeek", "timeGridDay", "listWeek", "dayGridMonth"];
if (cal.view && !expected.includes(cal.view)) {
fail(dev.name, p.name, "calendar-view", `vista "${cal.view}" inesperada para ${dev.tier}`);
}
}
if (taps.length) {
fail(dev.name, p.name, "tap-target", `${taps.length} objetivos < ${MIN_TAP_TARGET_PX}px alto (ej. "${taps[0].text || taps[0].tag}" ${taps[0].h}px)`);
}
if (errors.length) fail(dev.name, p.name, "console", errors[0]);
rows.push({ tag, device: dev.label, page: p.name, fields, overflow, shell, cal, nav, taps: taps.length, errors });
const flags = [
dev.touch && fields.bad.length ? `zoom:${fields.bad.length}` : "",
overflow.overflow > 2 ? `overflow:+${overflow.overflow}` : "",
shell && shell.hasFixedShell && shell.delta > 2 ? `alto:+${shell.delta}` : "",
cal ? `cal:${cal.view}@${cal.width}x${cal.height}` : "",
].filter(Boolean).join(" ");
console.log(`${flags.includes("zoom") || flags.includes("overflow") || flags.includes("alto") ? "✗" : "✓"} ${tag.padEnd(38)} ${flags}`);
if (SHOOT) await page.screenshot({ path: `${OUT}/${p.name}--${dev.name}.png` });
} catch (e) {
fail(dev.name, p.name, "load", e.message.split("\n")[0]);
console.log(`${tag.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 70)}`);
} finally {
await context.close();
}
}
}
// ---- Modal de cita ----
// Es el formulario con más campos (donde el auto-zoom de iOS pegaría más
// fuerte) y un bottom-sheet que debe caber en el área visible sin quedar bajo
// el home indicator. No se cubre en el barrido de páginas porque hay que
// abrirlo interactuando.
for (const dev of DEVICES.filter((d) => d.touch && d.tier !== "phone-land")) {
const context = await browser.newContext({
viewport: { width: dev.width, height: dev.height },
deviceScaleFactor: dev.dpr,
hasTouch: true,
isMobile: engine === "chromium" ? true : undefined,
storageState: states.owner,
});
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle", timeout: 25000 });
await page.waitForSelector(".fc", { timeout: 20000 });
await page.waitForTimeout(1200);
await page.getByRole("button", { name: /Nueva cita/ }).first().click();
await page.waitForSelector('[role="dialog"]', { timeout: 10000 });
await page.waitForTimeout(1000);
const fields = await measureFormFields(page);
const overflow = await measureOverflow(page);
const sheet = await page.evaluate(() => {
const d = document.querySelector('[role="dialog"]');
const r = d.getBoundingClientRect();
return { h: Math.round(r.height), bottom: Math.round(r.bottom), vh: window.innerHeight };
});
if (fields.bad.length) fail(dev.name, "modal", "ios-zoom", `${fields.bad.length} campos < ${MIN_TOUCH_FONT_PX}px`);
if (overflow.overflow > 2) fail(dev.name, "modal", "overflow-x", `+${overflow.overflow}px`);
if (sheet.h > sheet.vh || sheet.bottom > sheet.vh + 1) {
fail(dev.name, "modal", "modal-fit", `sheet ${sheet.h}px / viewport ${sheet.vh}px (bottom ${sheet.bottom})`);
}
console.log(`${fields.bad.length || overflow.overflow > 2 || sheet.h > sheet.vh ? "✗" : "✓"} ${`${dev.name}/modal`.padEnd(38)} campos=${fields.total} sheet=${sheet.h}/${sheet.vh}`);
if (SHOOT) await page.screenshot({ path: `${OUT}/modal--${dev.name}.png` });
} catch (e) {
fail(dev.name, "modal", "load", e.message.split("\n")[0]);
console.log(`${`${dev.name}/modal`.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 60)}`);
} finally {
await context.close();
}
}
// ---- Barra lateral colapsable (lg+) ----
// Se comprueba el comportamiento completo: que colapse, que el contenido gane
// el ancho liberado, que el menú siga en una columna en modo mini, que las
// entradas conserven etiqueta accesible al perder el texto, y que la
// preferencia sobreviva a una recarga.
for (const dev of DEVICES.filter((d) => d.sidebar)) {
const context = await browser.newContext({
viewport: { width: dev.width, height: dev.height },
deviceScaleFactor: dev.dpr,
hasTouch: dev.touch,
storageState: states.owner,
});
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle", timeout: 25000 });
await page.waitForSelector(".fc", { timeout: 20000 });
await page.waitForTimeout(1000);
const metrics = () =>
page.evaluate(() => {
const aside = [...document.querySelectorAll("aside")].find(
(a) => a.getBoundingClientRect().width > 0 && getComputedStyle(a).position !== "absolute"
);
const main = document.querySelector("main");
return {
aside: aside ? Math.round(aside.getBoundingClientRect().width) : 0,
main: main ? Math.round(main.getBoundingClientRect().width) : 0,
};
});
const before = await metrics();
const btn = page.getByRole("button", { name: /Colapsar menú/ });
if (!(await btn.count())) throw new Error("no se encontró el botón de colapsar");
await btn.first().click();
await page.waitForTimeout(500);
const after = await metrics();
const navMini = await measureSidebarNav(page);
// Al quedarse sin texto, cada entrada debe seguir teniendo nombre accesible.
const labelled = await page.evaluate(() => {
const aside = [...document.querySelectorAll("aside")].find(
(a) => a.getBoundingClientRect().width > 0 && getComputedStyle(a).position !== "absolute"
);
const links = [...(aside?.querySelectorAll("nav a") ?? [])];
return {
total: links.length,
named: links.filter((a) => (a.getAttribute("title") || a.getAttribute("aria-label") || a.textContent || "").trim().length > 0).length,
};
});
if (SHOOT) await page.screenshot({ path: `${OUT}/sidebar-collapsed--${dev.name}.png` });
// La preferencia debe persistir tras recargar.
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 20000 });
await page.waitForTimeout(800);
const afterReload = await metrics();
const shrank = after.aside < before.aside - 40;
const mainGrew = after.main > before.main + 40;
const oneColumn = !navMini || navMini.sameRowPairs === 0;
const persisted = Math.abs(afterReload.aside - after.aside) <= 2;
const allNamed = labelled.total > 0 && labelled.named === labelled.total;
if (!shrank) fail(dev.name, "sidebar", "collapse", `no se estrechó: ${before.aside}px → ${after.aside}px`);
if (!mainGrew) fail(dev.name, "sidebar", "collapse", `el contenido no ganó ancho: ${before.main}px → ${after.main}px`);
if (!oneColumn) fail(dev.name, "sidebar", "nav-columns", `menú mini en ${navMini.distinctLefts} columnas`);
if (!persisted) fail(dev.name, "sidebar", "collapse", `no persistió tras recargar: ${afterReload.aside}px`);
if (!allNamed) fail(dev.name, "sidebar", "collapse", `${labelled.total - labelled.named} entradas sin nombre accesible en modo mini`);
const ok = shrank && mainGrew && oneColumn && persisted && allNamed;
console.log(`${ok ? "✓" : "✗"} ${`${dev.name}/sidebar`.padEnd(38)} aside ${before.aside}${after.aside} main ${before.main}${after.main} (+${after.main - before.main}) 1col=${oneColumn ? "sí" : "NO"} persiste=${persisted ? "sí" : "NO"}`);
// Se restaura expandida para no contaminar el estado guardado del perfil.
const openBtn = page.getByRole("button", { name: /Expandir menú/ });
if (await openBtn.count()) await openBtn.first().click();
await page.waitForTimeout(200);
} catch (e) {
fail(dev.name, "sidebar", "load", e.message.split("\n")[0]);
console.log(`${`${dev.name}/sidebar`.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 60)}`);
} finally {
await context.close();
}
}
await browser.close();
fs.mkdirSync("screenshots", { recursive: true });
fs.writeFileSync("screenshots/responsive-report.json", JSON.stringify({ engine, staticAudit, rows, findings }, null, 2));
console.log("\n=============== RESUMEN ===============");
console.log(`viewport meta: ${staticAudit.viewport}`);
console.log(`env(safe-area-inset-*) en index.css: ${staticAudit.insetsInCss ? "sí" : "NO"}`);
for (const [f, u] of Object.entries(staticAudit.shellUsage)) {
console.log(` ${f}: safe-top=${u.top ? "sí" : "NO"} safe-bottom=${u.bottom ? "sí" : "NO"} dvh=${u.dvh ? "sí" : "NO"} sin-100vh-crudo=${u.noRawScreen ? "sí" : "NO"}`);
}
const byKind = {};
for (const f of findings) byKind[f.kind] = (byKind[f.kind] || 0) + 1;
console.log(`\nHallazgos: ${findings.length}`);
for (const [k, n] of Object.entries(byKind).sort((a, b) => b[1] - a[1])) console.log(` ${k}: ${n}`);
if (findings.length) {
console.log("\nDetalle (primeros 20):");
for (const f of findings.slice(0, 20)) console.log(` • [${f.kind}] ${f.device}/${f.page}${f.detail}`);
}
console.log(`\nReporte → screenshots/responsive-report.json`);
const blocking = findings.filter((f) =>
["ios-zoom", "overflow-x", "shell-height", "raw-viewport-unit", "gutter", "load", "calendar-scroll-x", "calendar-height", "calendar-too-short", "calendar-view", "modal-fit", "nav-columns", "collapse", "icon-center"].includes(f.kind)
);
console.log(blocking.length ? `\n${blocking.length} hallazgos bloqueantes.` : "\nSin hallazgos bloqueantes.");
process.exitCode = blocking.length ? 1 : 0;
}
run().catch((e) => {
console.error(e);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const svg = await fs.readFile(path.join(root, "public", "favicon.svg"), "utf8");
const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
const browser = await chromium.launch({ headless: true });
try {
for (const size of [192, 512]) {
const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
await page.setContent(`<!doctype html><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#fff}img{display:block;width:100%;height:100%}</style><img src="${svgDataUrl}" alt="">`);
await page.screenshot({ path: path.join(root, "public", `icon-${size}.png`), type: "png" });
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -38,7 +38,7 @@ function ensureSeed() {
seedBusiness({
businessId: biz.id,
template: getTemplate(DEFAULT_TEMPLATE_KEY)!,
ownerEmail: "owner@agendapro.demo",
ownerEmail: "owner@agendamax.demo",
ownerName: "Daniela Reyes",
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ function check(name, cond, extra = "") {
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
}
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" });
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
const t = login.token;
check("login owner", !!t);
+2 -2
View File
@@ -317,7 +317,7 @@ export function ensurePlatformAdmin() {
if (exists) return;
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, avatar_color)
VALUES (NULL, 'admin@agendapro.demo', 'demo1234', 'Administrador', 'admin', '#0f172a')`
VALUES (NULL, 'admin@agendamax.demo', 'demo1234', 'Administrador', 'admin', '#0f172a')`
).run();
}
@@ -330,7 +330,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
biz = db
.prepare(`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`)
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "owner@agendapro.demo", ownerName: "Daniela Reyes" });
seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "owner@agendamax.demo", ownerName: "Daniela Reyes" });
console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`);
} else {
console.log(`[seed] Default business #${biz.id} already exists — data preserved.`);
+95 -27
View File
@@ -1,10 +1,9 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./lib/auth";
import { LoginPage } from "./pages/LoginPage";
import type { User } from "../shared/types";
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";
@@ -19,28 +18,90 @@ import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
import BookingPage from "./pages/public/BookingPage";
function Protected() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span className="text-sm font-medium">Cargando AgendaMax</span>
</div>
</div>
);
}
if (!user) return <LoginPage />;
// Las dos páginas públicas van aparte del bundle principal por dos razones que
// apuntan en direcciones opuestas, y por eso importan las dos: quien ya tiene
// sesión nunca las ve y no debe descargarlas, y quien llega a la landing no debe
// descargar FullCalendar ni recharts para leer una página de venta.
//
// El login tiene que ser lazy junto con la landing, no solo la landing: es el único
// consumidor eager de framer-motion, y con él en el chunk de entrada Rollup mete
// `lib/motion` ahí también. Eso obliga al chunk de la landing a importar el de
// entrada, que a su vez arrastra `charts` y `calendar`. Medido: 39 kB gzip de
// motion en cada carga del panel, más recharts y FullCalendar en la landing.
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() =>
import("./pages/LoginPage").then((m) => ({ default: m.LoginPage }))
);
const isAdmin = user.role === "admin";
// Las dos únicas páginas que importan librerías pesadas: recharts (~111 KB gzip) y
// FullCalendar (~76 KB gzip). Eager, viven en el chunk de entrada, que se descarga
// en TODA visita — incluida la landing, que no grafica ni agenda nada. Su frontera
// de Suspense está en el <Outlet> de AppShell.
const DashboardPage = lazy(() =>
import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))
);
const CalendarPage = lazy(() =>
import("./pages/CalendarPage").then((m) => ({ default: m.CalendarPage }))
);
/** Destino del usuario según su rol. Única definición de la regla. */
export function homePathFor(user: User): string {
if (user.role === "admin") return "/admin";
return user.role === "owner" ? "/dashboard" : "/calendar";
}
function Splash() {
return (
<div className="flex h-screen-safe items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span className="text-sm font-medium">Cargando AgendaMax</span>
</div>
</div>
);
}
function AppRoutes() {
const { user, loading } = useAuth();
if (loading) return <Splash />;
const home = user ? homePathFor(user) : "/login";
const isAdmin = user?.role === "admin";
return (
<Routes>
{/* Admin routes */}
{isAdmin && (
{/* ---- Públicas ---- */}
<Route
path="/"
element={
user ? (
<Navigate to={home} replace />
) : (
<Suspense fallback={<Splash />}>
<LandingPage />
</Suspense>
)
}
/>
<Route
path="/login"
element={
user ? (
<Navigate to={home} replace />
) : (
<Suspense fallback={<Splash />}>
<LoginPage />
</Suspense>
)
}
/>
{/* Sin sesión, cualquier otra ruta manda al login. */}
{!user && <Route path="*" element={<Navigate to="/login" replace />} />}
{/* ---- Admin de plataforma ---- */}
{user && isAdmin && (
<Route element={<AdminShell />}>
<Route path="/" element={<Navigate to="/admin" replace />} />
<Route path="/admin" element={<AdminOverviewPage />} />
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
@@ -48,10 +109,9 @@ function Protected() {
</Route>
)}
{/* Business routes */}
{!isAdmin && (
{/* ---- Negocio ---- */}
{user && !isAdmin && (
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to={user.role === "owner" ? "/dashboard" : "/calendar"} replace />} />
{user.role === "owner" ? (
<Route path="/dashboard" element={<DashboardPage />} />
) : (
@@ -67,9 +127,9 @@ function Protected() {
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
{/* Block business users from admin */}
<Route path="/admin/*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to={user.role === "owner" ? "/dashboard" : "/calendar"} replace />} />
{/* La autorización real vive en los middlewares del servidor; esto es UX. */}
<Route path="/admin/*" element={<Navigate to={home} replace />} />
<Route path="*" element={<Navigate to={home} replace />} />
</Route>
)}
</Routes>
@@ -79,8 +139,16 @@ function Protected() {
export default function App() {
return (
<Routes>
{/* Reservas públicas: fuera del AuthProvider a propósito, no asumas usuario. */}
<Route path="/b/:slug" element={<BookingPage />} />
<Route path="*" element={<AuthProvider><Protected /></AuthProvider>} />
<Route
path="*"
element={
<AuthProvider>
<AppRoutes />
</AuthProvider>
}
/>
</Routes>
);
}
+84 -36
View File
@@ -8,10 +8,14 @@ import {
X,
ShieldCheck,
Plus,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
import { InstallAppPrompt } from "./InstallAppPrompt";
import { useSidebarCollapsed } from "../lib/useSidebarCollapsed";
interface NavItem {
to: string;
@@ -30,19 +34,35 @@ export function AdminShell() {
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const { collapsed, toggle } = useSidebarCollapsed();
if (!user) return null;
const SidebarContent = (
/** `mini` = solo iconos. El panel móvil siempre se muestra completo. */
const sidebarContent = (mini: boolean) => (
<>
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-slate-800 to-slate-950 text-white shadow-soft">
<div className={cn("flex items-center px-2 py-1", mini ? "flex-col gap-2" : "gap-2.5")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-slate-800 to-slate-950 text-white shadow-soft">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="text-[11px] font-medium text-slate-500">Consola Admin</div>
</div>
{!mini && (
<div className="min-w-0 leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="text-[11px] font-medium text-slate-500">Consola Admin</div>
</div>
)}
<button
onClick={toggle}
aria-label={mini ? "Expandir menú" : "Colapsar menú"}
title={mini ? "Expandir menú" : "Colapsar menú"}
aria-expanded={!mini}
className={cn(
"icon-btn hidden rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 lg:block",
mini ? "" : "ml-auto"
)}
>
{mini ? <PanelLeftOpen className="h-[18px] w-[18px]" /> : <PanelLeftClose className="h-[18px] w-[18px]" />}
</button>
</div>
<nav className="mt-5 flex-1 space-y-1 px-2">
@@ -52,15 +72,17 @@ export function AdminShell() {
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
title={mini ? item.label : undefined}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors",
"group flex w-full items-center rounded-xl py-2.5 text-sm font-semibold transition-colors",
mini ? "justify-center px-0" : "gap-3 px-3",
isActive ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)
}
>
<item.icon className="h-[18px] w-[18px]" />
{item.label}
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!mini && <span className="truncate">{item.label}</span>}
</NavLink>
))}
<button
@@ -68,29 +90,42 @@ export function AdminShell() {
navigate("/admin/businesses?new=1");
setMobileOpen(false);
}}
className="group mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-slate-300 px-3 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700"
title={mini ? "Nuevo negocio" : undefined}
aria-label={mini ? "Nuevo negocio" : undefined}
className={cn(
"group mt-2 flex w-full items-center rounded-xl border border-dashed border-slate-300 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700",
mini ? "justify-center px-0" : "gap-3 px-3"
)}
>
<Plus className="h-[18px] w-[18px]" />
Nuevo negocio
<Plus className="h-[18px] w-[18px] shrink-0" />
{!mini && <span className="truncate">Nuevo negocio</span>}
</button>
</nav>
<div className="mt-auto px-2 pb-3">
<div className="rounded-xl bg-amber-50 px-3 py-2 text-[11px] font-medium text-amber-700">
Modo administrador de plataforma. Puedes crear y gestionar todos los negocios.
</div>
<div className="mt-2 flex items-center gap-3 rounded-xl p-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-xs font-bold text-white">
<div className="safe-area-bottom mt-auto px-2">
{!mini && (
<div className="rounded-xl bg-amber-50 px-3 py-2 text-[11px] font-medium text-amber-700">
Modo administrador de plataforma. Puedes crear y gestionar todos los negocios.
</div>
)}
<div className={cn("mt-2 flex items-center rounded-xl p-2", mini ? "flex-col gap-2" : "gap-3")}>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-xs font-bold text-white"
title={mini ? user.name : undefined}
>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium text-slate-500">Admin · {user.email}</div>
</div>
{!mini && (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="truncate text-[11px] font-medium text-slate-500">Admin · {user.email}</div>
</div>
)}
<button
onClick={logout}
title="Cerrar sesión"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
aria-label="Cerrar sesión"
className="icon-btn shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-[18px] w-[18px]" />
</button>
@@ -100,39 +135,52 @@ export function AdminShell() {
);
return (
<div className="flex h-screen overflow-hidden bg-[#f6f7fb]">
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white py-4 lg:flex">
{SidebarContent}
<div data-app-shell className="flex h-screen-safe overflow-hidden bg-[#f6f7fb]">
<aside
className={cn(
"hidden shrink-0 flex-col border-r border-slate-200 bg-white py-4 transition-[width] duration-200 lg:flex",
collapsed ? "w-[68px]" : "w-64"
)}
>
{sidebarContent(collapsed)}
</aside>
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-white py-4 shadow-2xl animate-slide-up">
<aside className="safe-top absolute left-0 top-0 flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto bg-white py-4 pl-[var(--safe-left)] shadow-2xl animate-slide-up">
<button
onClick={() => setMobileOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
aria-label="Cerrar menú"
className="icon-btn absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X className="h-5 w-5" />
</button>
{SidebarContent}
{sidebarContent(false)}
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button onClick={() => setMobileOpen(true)} className="rounded-lg p-2 text-slate-600 hover:bg-slate-100">
<header className="safe-top safe-x flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button
onClick={() => setMobileOpen(true)}
aria-label="Abrir menú"
className="icon-btn -ml-1 rounded-lg p-2.5 text-slate-600 hover:bg-slate-100"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-900 text-white">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-slate-900 text-white">
<ShieldCheck className="h-4 w-4" />
</div>
<span className="text-sm font-extrabold">Admin</span>
<span className="truncate text-sm font-extrabold">Admin</span>
</div>
<div className="ml-auto w-auto shrink-0">
<InstallAppPrompt compact />
</div>
</header>
<main className="flex-1 overflow-y-auto">
<main className="safe-x flex min-h-0 flex-1 flex-col overflow-y-auto">
<Outlet key={location.pathname} />
</main>
</div>
+80 -34
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { Suspense, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
CalendarDays,
@@ -15,10 +15,15 @@ import {
Bell,
Settings,
BarChart3,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
import { DemoSwitcher } from "./DemoSwitcher";
import { InstallAppPrompt } from "./InstallAppPrompt";
import { RouteSpinner } from "./ui";
import { useSidebarCollapsed } from "../lib/useSidebarCollapsed";
interface NavItem {
to: string;
@@ -47,6 +52,7 @@ export function AppShell() {
const { user, business, logout } = useAuth();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const { collapsed, toggle } = useSidebarCollapsed();
if (!user) return null;
const isOwner = user.role === "owner";
@@ -56,16 +62,32 @@ export function AppShell() {
return true;
});
const SidebarContent = (
/** `mini` = solo iconos. El panel móvil siempre se muestra completo. */
const sidebarContent = (mini: boolean) => (
<>
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<div className={cn("flex items-center px-2 py-1", mini ? "flex-col gap-2" : "gap-2.5")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
</div>
{!mini && (
<div className="min-w-0 leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
<div className="truncate text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
</div>
)}
{/* Solo en lg+: en móvil se cierra con el backdrop o la X. */}
<button
onClick={toggle}
aria-label={mini ? "Expandir menú" : "Colapsar menú"}
title={mini ? "Expandir menú" : "Colapsar menú"}
aria-expanded={!mini}
className={cn(
"icon-btn hidden rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 lg:block",
mini ? "" : "ml-auto"
)}
>
{mini ? <PanelLeftOpen className="h-[18px] w-[18px]" /> : <PanelLeftClose className="h-[18px] w-[18px]" />}
</button>
</div>
<nav className="mt-5 flex-1 space-y-1 px-2">
@@ -74,9 +96,12 @@ export function AppShell() {
key={item.to}
to={item.to}
onClick={() => setMobileOpen(false)}
title={mini ? item.label : undefined}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors",
// `flex` explícito y una entrada por fila: es un menú vertical.
"group flex w-full items-center rounded-xl py-2.5 text-sm font-semibold transition-colors",
mini ? "justify-center px-0" : "gap-3 px-3",
isActive
? "bg-brand-50 text-brand-700"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
@@ -86,38 +111,45 @@ export function AppShell() {
{({ isActive }) => (
<>
<item.icon
className={cn("h-[18px] w-[18px]", isActive ? "text-brand-600" : "text-slate-400 group-hover:text-slate-600")}
className={cn(
"h-[18px] w-[18px] shrink-0",
isActive ? "text-brand-600" : "text-slate-400 group-hover:text-slate-600"
)}
/>
{item.label}
{!mini && <span className="truncate">{item.label}</span>}
</>
)}
</NavLink>
))}
</nav>
<div className="mt-auto space-y-2 px-2 pb-3">
{DEMO && (
<div className="safe-area-bottom mt-auto space-y-2 px-2">
{DEMO && !mini && (
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
<DemoSwitcher />
</div>
)}
<div className="flex items-center gap-3 rounded-xl p-2">
<div className={cn("flex items-center rounded-xl p-2", mini ? "flex-col gap-2" : "gap-3")}>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ background: user.avatar_color }}
title={mini ? user.name : undefined}
>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium capitalize text-slate-500">
{isOwner ? "Dueño" : "Empleado"}
{!mini && (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium capitalize text-slate-500">
{isOwner ? "Dueño" : "Empleado"}
</div>
</div>
</div>
)}
<button
onClick={logout}
title="Cerrar sesión"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
aria-label="Cerrar sesión"
className="icon-btn shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-[18px] w-[18px]" />
</button>
@@ -127,46 +159,60 @@ export function AppShell() {
);
return (
<div className="flex h-screen overflow-hidden bg-[#f6f7fb]">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white py-4 lg:flex">
{SidebarContent}
<div data-app-shell className="flex h-screen-safe overflow-hidden bg-[#f6f7fb]">
{/* Barra lateral en lg+, colapsable a solo iconos */}
<aside
className={cn(
"hidden shrink-0 flex-col border-r border-slate-200 bg-white py-4 transition-[width] duration-200 lg:flex",
collapsed ? "w-[68px]" : "w-64"
)}
>
{sidebarContent(collapsed)}
</aside>
{/* Mobile sidebar */}
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-white py-4 shadow-2xl animate-slide-up">
<aside className="safe-top absolute left-0 top-0 flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto bg-white py-4 pl-[var(--safe-left)] shadow-2xl animate-slide-up">
<button
onClick={() => setMobileOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
aria-label="Cerrar menú"
className="icon-btn absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X className="h-5 w-5" />
</button>
{SidebarContent}
{sidebarContent(false)}
</aside>
</div>
)}
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<header className="safe-top safe-x flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button
onClick={() => setMobileOpen(true)}
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100"
aria-label="Abrir menú"
className="icon-btn -ml-1 rounded-lg p-2.5 text-slate-600 hover:bg-slate-100"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-4 w-4" />
</div>
<span className="text-sm font-extrabold">AgendaMax</span>
<span className="truncate text-sm font-extrabold">AgendaMax</span>
</div>
<div className="ml-auto w-auto shrink-0">
<InstallAppPrompt compact />
</div>
</header>
<main className="flex-1 overflow-y-auto">
<Outlet key={location.pathname} />
<main className="safe-x flex min-h-0 flex-1 flex-col overflow-y-auto">
{/* El calendario (FullCalendar) y el tablero (recharts) se cargan por
demanda, así que el Outlet necesita su propia frontera de Suspense. */}
<Suspense fallback={<RouteSpinner />}>
<Outlet key={location.pathname} />
</Suspense>
</main>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
/**
* La marca de AgendaMax, en un solo lugar.
*
* Es el mismo dibujo que `public/favicon.svg` y que los iconos de la PWA: cuadrado
* redondeado en el azul primario `#3b66ff`, tres renglones de agenda en blanco y el
* punto naranja `#f17616` sobre el último. Se replica aquí como SVG en línea para
* que herede el tamaño del texto y no dependa de una petición de red.
*
* Los colores están fijos a propósito: si cambian, tienen que cambiar también en
* `public/favicon.svg` y en los iconos generados por `npm run generate:pwa-icons`,
* o la pestaña del navegador deja de coincidir con la página.
*/
export function BrandMark({ className = "h-8 w-8" }: { className?: string }) {
return (
<svg viewBox="0 0 32 32" className={className} role="img" aria-label="AgendaMax">
<rect width="32" height="32" rx="7" fill="#3b66ff" />
<path d="M9 11h14M9 16h14M9 21h9" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
<circle cx="23" cy="21" r="3" fill="#f17616" />
</svg>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function DemoSwitcher() {
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
className="tap-target flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
disabled={loading}
>
<UserCog className="h-3.5 w-3.5 text-slate-400" />
+50
View File
@@ -0,0 +1,50 @@
import { useState } from "react";
import { Download, PlusSquare, Share } from "lucide-react";
import { Modal } from "./Modal";
import { useInstallPrompt } from "../lib/useInstallPrompt";
export function InstallAppPrompt({ compact = false }: { compact?: boolean }) {
const { state, install } = useInstallPrompt();
const [iosOpen, setIosOpen] = useState(false);
const buttonClassName = compact
? "btn btn-secondary h-9 w-9 shrink-0 justify-center p-0"
: "btn btn-secondary w-full justify-start";
if (state === "unsupported" || state === "installed") return null;
if (state === "ios-instructions") {
return (
<>
<button
type="button"
className={buttonClassName}
aria-label="Instalar AgendaMax"
title={compact ? "Instalar AgendaMax" : undefined}
onClick={() => setIosOpen(true)}
>
<Download className="h-4 w-4" />
<span className={compact ? "sr-only" : undefined}>Instalar AgendaMax</span>
</button>
<Modal open={iosOpen} onClose={() => setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
<div className="safe-area-bottom">
<ol className="space-y-3 text-sm text-slate-600">
<li className="flex gap-3"><Share className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Toca <strong>Compartir</strong> en la barra de Safari.</span></li>
<li className="flex gap-3"><PlusSquare className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Elige <strong>Añadir a pantalla de inicio</strong> y confirma.</span></li>
</ol>
</div>
</Modal>
</>
);
}
return (
<button
type="button"
className={buttonClassName}
aria-label="Instalar AgendaMax"
title={compact ? "Instalar AgendaMax" : undefined}
onClick={() => void install()}
>
<Download className="h-4 w-4" />
<span className={compact ? "sr-only" : undefined}>Instalar AgendaMax</span>
</button>
);
}
+18 -6
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useId } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "../lib/format";
@@ -14,6 +14,7 @@ interface ModalProps {
}
export function Modal({ open, onClose, title, subtitle, children, size = "md", footer }: ModalProps) {
const titleId = useId();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -35,27 +36,38 @@ export function Modal({ open, onClose, title, subtitle, children, size = "md", f
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
className={cn(
"relative z-10 flex max-h-[92vh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl",
// max-h en dvh: con 92vh el sheet queda más alto que el área visible de
// Safari y el footer se esconde bajo la barra de URL.
"relative z-10 flex max-h-[92dvh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl",
maxW
)}
>
{(title || subtitle) && (
<div className="flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4">
<div className="min-w-0">
{title && <h2 className="text-lg font-bold text-slate-900">{title}</h2>}
{title && <h2 id={titleId} className="text-lg font-bold text-slate-900">{title}</h2>}
{subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
aria-label="Cerrar"
className="icon-btn rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-5 w-5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="border-t border-slate-100 bg-slate-50/60 px-5 py-3">{footer}</div>}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="safe-area-bottom shrink-0 border-t border-slate-100 bg-slate-50/60 px-5 pt-3">{footer}</div>
) : (
/* Sin footer, el sheet a ras de pantalla queda bajo el home indicator. */
<div className="h-[var(--safe-bottom)] shrink-0 sm:hidden" />
)}
</div>
</div>,
document.body
+109
View File
@@ -0,0 +1,109 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { BrandMark } from "../BrandMark";
// Las ocho muestras del abanico, ya en fila: `PIE_COLORS` del panel, mismo orden.
const TINTES = [
"#3b66ff",
"#f17616",
"#10b981",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f59e0b",
"#6366f1",
];
export function ClosingSection() {
const reduced = useReducedMotion();
return (
// El degradado va en la sección misma y no en un hijo con `-z-10`: ahí quedaba
// detrás del fondo opaco de `.landing` y no se veía nada.
<section
data-ld-section
className="relative overflow-hidden"
// Lavados del propio panel: orange-50 → brand-50. Nada inventado.
style={{ background: "linear-gradient(170deg, #fff7ed 0%, #eef4ff 60%, #f6f7fb 100%)" }}
>
<motion.div
className="ld-wrap ld-section text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
{/* Cinta de tintes: las ocho muestras del hero, ya en fila. */}
<motion.div
className="mx-auto mb-10 flex w-fit gap-1.5"
variants={revealUp(reduced, 14)}
aria-hidden="true"
>
{TINTES.map((c, i) => (
<motion.span
key={c}
className="h-11 w-4 rounded-full sm:h-14 sm:w-5"
style={{ background: c }}
animate={reduced ? undefined : { scaleY: [1, 0.78, 1] }}
transition={{
duration: 2.6,
repeat: Infinity,
delay: i * 0.11,
ease: "easeInOut",
}}
/>
))}
</motion.div>
<motion.h2 className="ld-display mx-auto max-w-3xl" variants={revealUp(reduced, 28)}>
Deja de <span className="ld-gradient-text">adivinar</span>.
</motion.h2>
<motion.p
className="ld-lead mx-auto mt-7 max-w-lg"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Ábrelo con una cuenta que ya está lista y mira tu salón como se ve cuando alguien te
lleva la cuenta.
</motion.p>
<motion.div className="mt-11" variants={revealUp(reduced)}>
<Link data-ld-closing-cta to="/login" className="ld-cta">
Entrar y probarlo
<ArrowRight className="h-4 w-4" />
</Link>
</motion.div>
<motion.p
className="mt-6 text-[0.9rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 8)}
>
No pide tarjeta. No pide registro.
</motion.p>
</motion.div>
<footer
className="ld-wrap flex flex-col items-center justify-between gap-4 border-t px-5 py-8 text-[0.82rem] font-semibold sm:flex-row"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-muted)" }}
>
<div className="flex items-center gap-2" style={{ color: "var(--ld-tinta)" }}>
<BrandMark className="h-6 w-6" />
<span className="font-extrabold">AgendaMax</span>
</div>
<span>© {new Date().getFullYear()} AgendaMax · Salón de práctica</span>
{/* `inline-flex` no es decorativo: `.tap-target` solo fija min-height, y en un
enlace inline la altura mínima no aplica. */}
<Link
to="/login"
className="tap-target inline-flex items-center underline-offset-4 hover:underline"
>
Entrar
</Link>
</footer>
</section>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Cuatro escenas de un martes cualquiera, no cuatro estadísticas. Cada una arranca
* con la frase que la dueña diría en voz alta; el número viene después, como
* confirmación de algo que ya sospechaba.
*
* Ninguna menciona el producto todavía: vender antes de haber dolido no funciona.
*/
const ESCENAS = [
{
color: "var(--ld-rosa)",
escena: "«Se me hizo un hueco a las 3»",
cifra: 34,
sufijo: "%",
remate: "de tus horas se quedan solas",
detalle:
"Cuarenta minutos entre una clienta y otra. Nadie los llena porque nadie los ve a tiempo. Y la renta se paga igual.",
},
{
color: "var(--ld-naranja)",
escena: "«¿Y doña Carmen? Ya ni viene»",
cifra: 4,
sufijo: " meses",
remate: "sin aparecer, y sigue en tu libreta",
detalle:
"Antes venía cada tres semanas. Un día dejó de venir y nadie te avisó. Traerla de vuelta cuesta una llamada; conseguir una nueva cuesta mucho más.",
},
{
color: "var(--ld-purpura)",
escena: "«Ese servicio sí deja… ¿o no?»",
cifra: 1,
sufijo: " de 6",
remate: "servicios te cuesta más de lo que cobra",
detalle:
"Entre producto, la hora de silla y la comisión, hay uno que estás regalando. En la libreta se ve igual que los demás.",
},
{
color: "var(--ld-esmeralda)",
escena: "«El domingo cuadro las cuentas»",
cifra: 3,
sufijo: " horas",
remate: "de calculadora, y aun así no sabes",
detalle:
"Tu día de descanso se va sumando tickets. Y al final del rato sigues sin saber si la semana fue mejor o peor que la anterior.",
},
];
function Cifra({ valor, sufijo, color }: { valor: number; sufijo: string; color: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-15% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1100;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num text-5xl font-extrabold sm:text-6xl" style={{ color }}>
{n}
{sufijo}
</span>
);
}
export function CostSection() {
const reduced = useReducedMotion();
return (
// `scroll-mt-24` no es decorativo: el nav es fijo, y sin margen de scroll el
// salto desde «Ver cómo se ve» deja el titular escondido debajo de él.
<section id="dolor" data-ld-section className="landing-nube ld-section scroll-mt-24">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-rosa)" }}
variants={revealUp(reduced, 12)}
>
Esto te va a sonar
</motion.p>
<motion.h2 className="ld-title max-w-3xl" variants={revealUp(reduced, 24)}>
No es que trabajes poco. Es que nadie te está llevando la cuenta.
</motion.h2>
<motion.p
className="ld-lead mt-6 max-w-xl"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Cuatro cosas que pasan en tu salón cada semana. Ninguna aparece en el banco con nombre
y apellido. Todas salen de ahí.
</motion.p>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2">
{ESCENAS.map((e, i) => (
<motion.div
key={e.escena}
className="grid grid-cols-1 gap-3 rounded-3xl bg-white/85 p-6 ring-1 ring-black/[0.04] sm:p-7"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 26 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.7, delay: reduced ? 0 : i * 0.09 }}
whileHover={reduced ? undefined : { y: -6 }}
>
<span
className="ld-body font-extrabold italic"
style={{ color: e.color, fontFamily: "Fraunces, Georgia, serif" }}
>
{e.escena}
</span>
<p className="flex flex-wrap items-baseline gap-x-2.5">
<Cifra valor={e.cifra} sufijo={e.sufijo} color={e.color} />
<span className="text-lg font-extrabold leading-snug">{e.remate}</span>
</p>
<p className="ld-body" style={{ color: "var(--ld-muted)" }}>
{e.detalle}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
+119
View File
@@ -0,0 +1,119 @@
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 { SwatchFanVisual } from "./visuals/SwatchFanVisual";
import { FloatingMomentsLayer, MomentsStack } from "./visuals/FloatingMomentsVisual";
export function HeroSection() {
const reduced = useReducedMotion();
return (
<section
data-ld-section
className="landing-papel relative isolate overflow-hidden pt-24 ld-section sm:pt-28"
>
{/* Lavados de color. Los tres tonos son los que el panel ya usa como fondo suave:
brand-50 (#eef4ff, el "hoy" del calendario), orange-50 (#fff7ed) y
emerald-50 (#ecfdf5). Van detrás de todo y no llevan texto, así que no
compiten con la lectura. */}
<div className="pointer-events-none absolute inset-0 -z-10">
<div
className="absolute -left-24 -top-24 h-[26rem] w-[26rem] rounded-full opacity-90 blur-3xl"
style={{ background: "radial-gradient(circle, #eef4ff 0%, transparent 70%)" }}
/>
<div
className="absolute -right-20 top-24 h-[30rem] w-[30rem] rounded-full opacity-85 blur-3xl"
style={{ background: "radial-gradient(circle, #fff7ed 0%, transparent 70%)" }}
/>
<div
className="absolute bottom-0 left-1/3 h-[24rem] w-[24rem] rounded-full opacity-80 blur-3xl"
style={{ background: "radial-gradient(circle, #ecfdf5 0%, transparent 70%)" }}
/>
</div>
<div className="ld-wrap grid grid-cols-1 items-center gap-14 lg:grid-cols-[1.05fr_1fr] lg:gap-10">
{/* ---- Columna de texto ---- */}
<motion.div initial="hidden" animate="show" variants={revealStagger(reduced, 0.09)}>
<motion.p
className="ld-eyebrow mb-5 inline-flex items-center gap-2 rounded-full bg-white/80 py-2 pl-2 pr-4 ring-1 ring-black/[0.05]"
variants={revealUp(reduced, 12)}
>
{/* Los cuatro primeros de la paleta del panel, en su orden. */}
<span className="flex gap-1" aria-hidden="true">
{["var(--ld-azul)", "var(--ld-naranja)", "var(--ld-esmeralda)", "var(--ld-purpura)"].map(
(c) => (
<span key={c} className="h-3 w-3 rounded-full" style={{ background: c }} />
)
)}
</span>
Salones · Barberías · Spas
</motion.p>
<h1 data-ld-hero-title className="ld-display">
<motion.span className="block" variants={revealUp(reduced, 26)}>
Tu agenda llena.
</motion.span>
<motion.span className="ld-gradient-text block" variants={revealUp(reduced, 26)}>
Tu domingo libre.
</motion.span>
</h1>
<motion.p className="ld-lead mt-7 max-w-lg" variants={revealUp(reduced)}>
Guarda la libreta y la calculadora. AgendaMax te dice quién viene hoy, quién dejó de
venir y cuánto llevas ganando este mes. Todo en una pantalla.
</motion.p>
<motion.div
className="mt-9 grid grid-cols-1 gap-3 sm:flex sm:items-center"
variants={revealUp(reduced)}
>
<Link to="/login" className="ld-cta">
Entrar y probarlo
<ArrowRight className="h-4 w-4" />
</Link>
<a href="#dolor" className="ld-cta-ghost">
Ver cómo se ve
<ChevronDown className="h-4 w-4" />
</a>
</motion.div>
<motion.p
className="mt-5 text-[0.9rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 8)}
>
No pide tarjeta. No pide registro. Entras y ya está adentro.
</motion.p>
</motion.div>
{/* ---- Columna del muestrario ----
El padding vertical en lg no es decorativo: reserva la banda superior e
inferior donde se posan los avisos flotantes. Sin él se estrellan contra
el abanico, que es la parte más ancha del dibujo. */}
<div className="relative lg:py-16">
<SwatchFanVisual />
{/* Los avisos flotan solo cuando hay espacio real alrededor del abanico.
Por debajo de lg se apilan bajo él: superpuestos en 390px taparían el
muestrario completo. */}
<FloatingMomentsLayer className="hidden lg:block" />
<MomentsStack className="mt-8 lg:hidden" />
</div>
</div>
{/* Indicador de scroll: respira, y se apaga con movimiento reducido. */}
{!reduced && (
<motion.div
className="mx-auto mt-14 flex w-fit items-center gap-2 text-[0.8rem] font-bold uppercase tracking-widest"
style={{ color: "var(--ld-muted)" }}
animate={{ y: [0, 7, 0], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2.6, repeat: Infinity, ease: EASE_EXPO }}
>
Sigue bajando
<ChevronDown className="h-4 w-4" />
</motion.div>
)}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import { motion, useScroll, useTransform } from "framer-motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { BrandMark } from "../BrandMark";
/**
* Nav flotante. Arranca casi transparente sobre el hero y se condensa al bajar:
* fondo y desenfoque suben juntos, que es lo que evita el salto brusco de un
* `position: fixed` con fondo sólido desde el primer píxel.
*/
export function LandingNav() {
const reduced = useReducedMotion();
const { scrollY } = useScroll();
const fondo = useTransform(scrollY, [0, 140], ["rgba(255,251,247,0)", "rgba(255,251,247,0.86)"]);
const desenfoque = useTransform(scrollY, [0, 140], ["blur(0px)", "blur(16px)"]);
const borde = useTransform(scrollY, [0, 140], ["rgba(27,21,51,0)", "rgba(27,21,51,0.1)"]);
return (
<motion.header
className="safe-top fixed inset-x-0 top-0 z-50 border-b"
style={
reduced
? { background: "rgba(255,251,247,0.92)", borderColor: "rgba(27,21,51,0.1)" }
: { background: fondo, backdropFilter: desenfoque, borderColor: borde }
}
>
<nav className="ld-wrap flex items-center justify-between gap-4 px-5 py-3">
<Link
to="/"
className="tap-target flex items-center gap-2.5 text-[1.05rem] font-extrabold tracking-tight"
>
<BrandMark className="h-8 w-8" />
AgendaMax
</Link>
<Link
to="/login"
className="ld-cta"
style={{ minHeight: 46, paddingInline: "1.35rem", fontSize: "0.9rem" }}
>
Entrar
</Link>
</nav>
</motion.header>
);
}
@@ -0,0 +1,80 @@
import { motion } from "framer-motion";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Las tres frases que una dueña dice de verdad cuando le enseñas un sistema. Están
* en su registro, no en el nuestro: «no le a la tecnología», no «tengo baja
* alfabetización digital». La respuesta también: corta y sin tecnicismos.
*/
const OBJECIONES = [
{
color: "var(--ld-rosa)",
dice: "No le sé a la tecnología.",
responde:
"Si contestas WhatsApp, ya sabes usarlo. Se toca y se arrastra, nada más. No hay que instalar ni configurar nada, y no hay curso que tomar.",
},
{
color: "var(--ld-naranja)",
dice: "Mi gente no lo va a usar.",
responde:
"Cada quien ve nomás su día y sus propias cuentas. No administran nada. Es menos trabajo que preguntarte a ti a qué hora entran.",
},
{
color: "var(--ld-purpura)",
dice: "Mi libreta me funciona bien.",
responde:
"Tu libreta te dice la hora de la cita, y eso lo hace bien. Lo que no te dice es que doña Carmen lleva cuatro meses sin venir, ni cuál de tus servicios estás regalando.",
},
];
export function ObjectionsSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-papel ld-section">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 12)}
>
Lo que estás pensando
</motion.p>
<motion.h2 className="ld-title max-w-2xl" variants={revealUp(reduced, 24)}>
Tres motivos para no hacerlo. Y por qué ninguno aguanta.
</motion.h2>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-4">
{OBJECIONES.map((o, i) => (
<motion.div
key={o.dice}
className="grid grid-cols-1 items-start gap-4 rounded-3xl bg-white p-7 ring-1 ring-black/[0.05] sm:grid-cols-[1fr_1.45fr] sm:gap-10 sm:p-9"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 22 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.65, delay: reduced ? 0 : i * 0.1 }}
>
<p
className="text-2xl font-extrabold italic leading-snug sm:text-[1.75rem]"
style={{ fontFamily: "Fraunces, Georgia, serif", color: o.color }}
>
«{o.dice}»
</p>
<p className="ld-body" style={{ color: "var(--ld-muted)" }}>
{o.responde}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useRef, useState } from "react";
import { motion, useMotionValueEvent, useScroll } from "framer-motion";
import { CalendarDays, HandCoins, Users } from "lucide-react";
import { EASE_EXPO } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
import { RevenueLineVisual } from "./visuals/RevenueLineVisual";
import { TeamLoadVisual } from "./visuals/TeamLoadVisual";
/**
* Tres actos, escritos como se los explicarías a una amiga que abre su salón. Cero
* palabras de sistema: ni «datos», ni «métricas», ni «panel». Lo que ella gana,
* dicho con sus palabras.
*/
const ACTOS = [
{
icono: CalendarDays,
rotulo: "Tu día",
color: "var(--ld-rosa)",
titulo: "Quién viene, a qué hora y con quién.",
copy: "Se mueve una cita con el dedo, como arrastrar una foto. Y no te deja poner a dos clientas en la misma silla a la misma hora, aunque se lo pidas.",
visual: <CalendarGridVisual density={0.48} />,
},
{
icono: HandCoins,
rotulo: "Tu dinero",
color: "var(--ld-naranja)",
titulo: "Cuánto entró hoy. Ya sumado.",
copy: "Sin calculadora y sin esperar al domingo. Cuánto llevas hoy, qué servicio te deja más y si esta semana va mejor que la pasada.",
visual: <RevenueLineVisual />,
},
{
icono: Users,
rotulo: "Tu gente",
color: "var(--ld-purpura)",
titulo: "Quién está hasta el tope y quién tiene hueco.",
copy: "De un golpe, sin preguntarle a nadie ni revisar tres libretas. Así reparte el trabajo sin que nadie se sienta cargada de más.",
visual: <TeamLoadVisual />,
},
];
function Encabezado({ a }: { a: (typeof ACTOS)[number] }) {
const Icono = a.icono;
return (
<>
<div className="mb-4 inline-flex items-center gap-2.5">
<span
className="flex h-9 w-9 items-center justify-center rounded-xl"
style={{ background: a.color }}
>
<Icono className="h-4 w-4 text-white" strokeWidth={2.6} />
</span>
<span className="ld-eyebrow" style={{ color: a.color }}>
{a.rotulo}
</span>
</div>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5 max-w-lg" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</>
);
}
function Panel({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl bg-white p-5 shadow-[0_30px_70px_-30px_rgba(27,21,51,0.25)] ring-1 ring-black/[0.05] sm:p-7">
{children}
</div>
);
}
export function ProductSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState(0);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
useMotionValueEvent(scrollYProgress, "change", (p) => {
// Tres tramos iguales, acotados a los extremos para que el final del recorrido
// no devuelva un índice fuera de rango.
setActivo(Math.min(ACTOS.length - 1, Math.max(0, Math.floor(p * ACTOS.length))));
});
// Con movimiento reducido no hay panel fijo: los tres actos se apilan enteros.
if (reduced) {
return (
<section data-ld-section className="landing-papel ld-section">
<div className="ld-wrap grid grid-cols-1 gap-20">
{ACTOS.map((a) => (
<div key={a.rotulo} className="grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
<div>
<Encabezado a={a} />
</div>
<Panel>{a.visual}</Panel>
</div>
))}
</div>
</section>
);
}
return (
<section data-ld-section className="landing-papel">
<div ref={ref} className="relative" style={{ height: `${ACTOS.length * 100}vh` }}>
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
{/* Texto: los tres actos ocupan la misma caja y se relevan. */}
<div className="relative min-h-[20rem]">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{ opacity: activo === i ? 1 : 0, y: activo === i ? 0 : 20 }}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<Encabezado a={a} />
</motion.div>
))}
</div>
{/* Panel: cambia de visual con el acto. */}
<div className="relative min-h-[21rem] sm:min-h-[25rem]">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{ opacity: activo === i ? 1 : 0, scale: activo === i ? 1 : 0.96 }}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<Panel>{a.visual}</Panel>
</motion.div>
))}
</div>
</div>
</div>
</div>
{/* Avance entre actos: la píldora activa toma el color del acto. */}
<div className="ld-wrap flex items-center justify-center gap-2 pb-8">
{ACTOS.map((a, i) => (
<motion.span
key={a.rotulo}
className="h-1.5 rounded-full"
animate={{
width: activo === i ? 34 : 12,
backgroundColor: activo === i ? a.color : "rgba(27,21,51,0.15)",
}}
transition={{ duration: 0.4, ease: EASE_EXPO }}
/>
))}
</div>
</section>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { motion } from "framer-motion";
import { MousePointerClick } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { DashboardMockVisual } from "./visuals/DashboardMockVisual";
export function ProofSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-nube ld-section">
<div className="ld-wrap">
<motion.div
className="mx-auto max-w-2xl text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-esmeralda)" }}
variants={revealUp(reduced, 12)}
>
Esto es de verdad
</motion.p>
<motion.h2 className="ld-title" variants={revealUp(reduced, 24)}>
No es un dibujito. Es el salón que te presta la demo.
</motion.h2>
<motion.p
className="ld-lead mt-6"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Entra, mueve una cita y mira cómo cambian las cuentas. No vas a romper nada: es un
salón de práctica que se puede volver a llenar cuando quieras.
</motion.p>
</motion.div>
<DashboardMockVisual className="mt-14" />
<motion.p
className="ld-body mx-auto mt-8 flex max-w-xl items-center justify-center gap-2.5 text-center font-semibold"
style={{ color: "var(--ld-muted)" }}
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.6, delay: reduced ? 0 : 0.3 }}
>
<MousePointerClick className="h-5 w-5 shrink-0" style={{ color: "var(--ld-esmeralda)" }} />
Un clic y estás dentro. No hay formulario que llenar.
</motion.p>
</div>
</section>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { NotebookVisual } from "./visuals/NotebookVisual";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
function Texto() {
return (
<>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-naranja)" }}>
El cambio
</p>
<h2 className="ld-title">
Tu libreta de siempre,
<br />
<span className="ld-gradient-text">pero que te contesta.</span>
</h2>
<p className="ld-lead mt-6 max-w-md" style={{ color: "var(--ld-muted)" }}>
El mismo día, las mismas clientas, las mismas muchachas. La diferencia es que ahora puedes
verlo todo de un golpe, y ella te avisa antes de que se te pase.
</p>
<p
className="ld-body mt-6 flex items-center gap-2 font-bold"
style={{ color: "var(--ld-tinta)" }}
>
Libreta
<ArrowRight className="h-4 w-4" style={{ color: "var(--ld-naranja)" }} />
Pantalla
</p>
</>
);
}
/** El tablero al que se transforma la libreta. */
function Tablero() {
return (
<div className="rounded-3xl bg-white p-5 shadow-[0_30px_70px_-30px_rgba(27,21,51,0.28)] ring-1 ring-black/[0.05] sm:p-6">
<div
className="mb-3 text-[0.72rem] font-extrabold uppercase tracking-widest"
style={{ color: "var(--ld-muted)" }}
>
Tu semana, completa
</div>
<CalendarGridVisual />
</div>
);
}
/**
* El cambio, contado con el scroll: la libreta se va y el tablero llega. Es la única
* sección con scrub (el avance ligado a la posición), y por eso es la que hace que
* bajar por la página se sienta como una película y no como una lista.
*
* Con movimiento reducido se colapsa a una pila estática con las dos piezas visibles.
*/
export function ShiftSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
const notaOpacidad = useTransform(scrollYProgress, [0.05, 0.42], [1, 0]);
const notaEscala = useTransform(scrollYProgress, [0.05, 0.42], [1, 0.9]);
const notaGiro = useTransform(scrollYProgress, [0.05, 0.42], [-3, -9]);
const panelOpacidad = useTransform(scrollYProgress, [0.32, 0.62], [0, 1]);
const panelY = useTransform(scrollYProgress, [0.32, 0.62], [44, 0]);
const panelEscala = useTransform(scrollYProgress, [0.32, 0.62], [0.94, 1]);
if (reduced) {
return (
<section data-ld-section className="landing-crema ld-section">
<div className="ld-wrap grid grid-cols-1 gap-12">
<div>
<Texto />
</div>
<NotebookVisual className="max-w-md" />
<Tablero />
</div>
</section>
);
}
return (
<section data-ld-section className="landing-crema">
<div ref={ref} className="relative h-[250vh]">
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={revealUp(false)}
>
<Texto />
</motion.div>
{/* Escenario: las dos piezas ocupan la misma caja y se relevan. */}
<div className="relative min-h-[23rem] sm:min-h-[27rem]">
<motion.div
className="absolute inset-0"
style={{ opacity: notaOpacidad, scale: notaEscala, rotate: notaGiro }}
>
<NotebookVisual />
</motion.div>
<motion.div
className="absolute inset-0"
style={{ opacity: panelOpacidad, y: panelY, scale: panelEscala }}
>
<Tablero />
</motion.div>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,84 @@
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"];
/**
* Los colores de las citas son los del muestrario del hero, que a su vez son
* `PIE_COLORS` del panel. No es una coincidencia estética: es el remate del hilo de la
* página los colores con los que trabaja son los colores con los que lee su semana,
* y son los mismos que verá dentro del producto.
*/
const TINTES = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899"];
/**
* 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] };
});
return (
<div className={`select-none ${className}`} aria-hidden="true">
<div className="mb-2 grid grid-cols-7 gap-1.5">
{DIAS.map((d, i) => (
<div
key={`${d}-${i}`}
className="text-center text-[0.6rem] font-bold uppercase tracking-widest opacity-40"
>
{d}
</div>
))}
</div>
<motion.div
className="grid grid-cols-7 gap-1.5"
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={{
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.012 } },
}}
>
{celdas.map((c, i) => (
<motion.div
key={i}
className="h-5 rounded-[0.3rem] sm:h-6"
style={{ background: c.ocupada ? c.tinte : "currentColor" }}
variants={{
hidden: reduced
? { scaleY: 1, opacity: c.ocupada ? 1 : 0.07 }
: { scaleY: 0.25, opacity: 0 },
show: {
scaleY: 1,
opacity: c.ocupada ? 1 : 0.07,
transition: { duration: 0.45, ease: EASE_EXPO },
},
}}
/>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,142 @@
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
import { RevenueLineVisual } from "./RevenueLineVisual";
// Etiquetas en el idioma del salón: «lo que entró», no «ingresos brutos». Los colores
// son los que el panel ya asigna a estas mismas tarjetas de resumen.
const KPIS = [
{ etiqueta: "Lo que entró este mes", valor: 184300, prefijo: "$", sufijo: "", color: "#10b981" },
{ etiqueta: "Clientas atendidas", valor: 412, prefijo: "", sufijo: "", color: "#3b66ff" },
{ etiqueta: "Sillas ocupadas", valor: 78, prefijo: "", sufijo: "%", color: "#a855f7" },
];
// Los tres primeros de PIE_COLORS, en el mismo orden en que el panel colorea la dona
// de servicios.
const TOP = [
{ nombre: "Tinte completo", monto: 42800, color: "#3b66ff" },
{ nombre: "Corte y peinado", monto: 38150, color: "#f17616" },
{ nombre: "Uñas de gel", monto: 24600, color: "#10b981" },
];
/** Contador que interpola de 0 al valor. Se salta la animación si hay reduced motion. */
function Contador({ valor, prefijo, sufijo }: { valor: number; prefijo: string; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-10% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1400;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
// ease-out cúbico: llega rápido y frena, igual que EASE_EXPO en CSS.
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num">
{prefijo}
{n.toLocaleString("es-MX")}
{sufijo}
</span>
);
}
/** El panel real, a escala. Es la prueba: datos de la cuenta demo. */
export function DashboardMockVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<motion.div
className={`grid grid-cols-1 gap-4 rounded-[1.75rem] bg-white p-5 shadow-[0_44px_100px_-40px_rgba(27,21,51,0.32)] ring-1 ring-black/[0.05] sm:p-7 ${className}`}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.8, ease: EASE_EXPO }}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{KPIS.map((k) => (
<div
key={k.etiqueta}
className="rounded-2xl p-4"
style={{ background: "var(--ld-nube)" }}
>
<div
className="text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
{k.etiqueta}
</div>
<div
className="mt-1.5 text-2xl font-extrabold tracking-tight sm:text-[1.8rem]"
style={{ color: k.color }}
>
<Contador valor={k.valor} prefijo={k.prefijo} sufijo={k.sufijo} />
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.6fr_1fr]">
<div className="rounded-2xl p-4" style={{ background: "var(--ld-crema)" }}>
<div
className="mb-2 text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
Lo que entró · últimos 12 meses
</div>
<RevenueLineVisual />
</div>
<div className="rounded-2xl p-4" style={{ background: "var(--ld-crema)" }}>
<div
className="mb-3 text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
Lo que más te deja
</div>
<div className="grid grid-cols-1 gap-2.5">
{TOP.map((s, i) => (
<motion.div
key={s.nombre}
className="flex items-baseline justify-between gap-3"
initial={{ opacity: reduced ? 1 : 0, x: reduced ? 0 : -12 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={inView}
transition={{
duration: 0.5,
delay: reduced ? 0 : 0.4 + i * 0.1,
ease: EASE_EXPO,
}}
>
<span className="flex min-w-0 items-center gap-2">
<span
className="h-4 w-1.5 shrink-0 rounded-full"
style={{ background: s.color }}
/>
<span className="truncate text-[0.92rem] font-bold">{s.nombre}</span>
</span>
<span className="ld-num shrink-0 text-[0.92rem] font-extrabold">
${s.monto.toLocaleString("es-MX")}
</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,122 @@
import { motion } from "framer-motion";
import { BellRing, Check, HandCoins } from "lucide-react";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
/**
* Los tres momentos que la dueña recibe de vuelta. No son features: son avisos
* concretos, escritos como los leería en su teléfono. Flotan sobre el muestrario
* porque eso es lo que el producto pone encima de su día de trabajo.
*
* `pos` los coloca en las tres zonas libres alrededor del abanico: el abanico es un
* semicírculo que abre hacia arriba, así que el hueco está en las dos esquinas
* superiores y en la inferior izquierda, junto al pivote. Ponerlos a media altura
* los estrella contra la parte más ancha del abanico.
*
* El detalle se mantiene corto a propósito: la tarjeta es `truncate`, y una frase
* larga se corta con puntos suspensivos, que se lee como un error.
*
* En móvil no se usa nada de esto: los avisos se apilan (`MomentsStack`).
*/
const MOMENTOS = [
{
icono: Check,
color: "var(--ld-esmeralda)",
titulo: "Valentina confirmó",
detalle: "11:30 · Tinte y corte",
pos: "left-0 top-0",
fase: 0,
},
{
icono: HandCoins,
color: "var(--ld-naranja)",
titulo: "Llevas $4,280 hoy",
detalle: "9 servicios cobrados",
pos: "right-0 top-[13%]",
fase: 1.4,
},
{
icono: BellRing,
color: "var(--ld-rosa)",
titulo: "Carmen no ha venido",
detalle: "4 meses sin aparecer",
pos: "left-0 bottom-0",
fase: 2.6,
},
];
/** Tarjeta de aviso. La posición la decide quien la monta, no la tarjeta. */
function Momento({ m, indice }: { m: (typeof MOMENTOS)[number]; indice: number }) {
const reduced = useReducedMotion();
const Icono = m.icono;
return (
<motion.div
className="flex w-full items-center gap-3 rounded-2xl bg-white/95 px-3.5 py-3 shadow-[0_16px_38px_-16px_rgba(27,21,51,0.3)] ring-1 ring-black/[0.04] backdrop-blur-sm"
initial={reduced ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: reduced ? 0 : 0.75,
delay: reduced ? 0 : 0.75 + indice * 0.22,
ease: EASE_EXPO,
}}
>
<motion.span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{ background: m.color }}
animate={reduced ? undefined : { rotate: [0, -6, 0, 6, 0] }}
transition={{ duration: 6, repeat: Infinity, delay: m.fase, ease: "easeInOut" }}
>
<Icono className="h-4 w-4 text-white" strokeWidth={2.6} />
</motion.span>
<span className="min-w-0">
<span className="block truncate text-[0.9rem] font-extrabold leading-tight">
{m.titulo}
</span>
<span
className="block truncate text-[0.78rem] leading-tight"
style={{ color: "var(--ld-muted)" }}
>
{m.detalle}
</span>
</span>
</motion.div>
);
}
/**
* Capa flotante para pantallas grandes: se superpone al muestrario. La deriva vive
* en este envoltorio y no en la tarjeta, porque dos `animate` sobre la misma `y` se
* sobrescriben y la entrada se perdería.
*/
export function FloatingMomentsLayer({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
{MOMENTOS.map((m, i) => (
<motion.div
key={m.titulo}
className={`absolute w-[14.5rem] ${m.pos}`}
// Fases y duraciones distintas por tarjeta: si suben y bajan en bloque, se
// nota que es un bucle y deja de leerse como avisos que llegan solos.
animate={reduced ? undefined : { y: [0, -13, 0] }}
transition={{ duration: 5.5 + i, repeat: Infinity, delay: m.fase, ease: "easeInOut" }}
>
<Momento m={m} indice={i} />
</motion.div>
))}
</div>
);
}
/** Pila para móvil: los mismos avisos, en columna y sin flotar. */
export function MomentsStack({ className = "" }: { className?: string }) {
return (
<div className={`grid grid-cols-1 gap-2.5 ${className}`}>
{MOMENTOS.map((m, i) => (
<Momento key={m.titulo} m={m} indice={i} />
))}
</div>
);
}
@@ -0,0 +1,69 @@
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": la libreta. Se dibuja con tipografía manuscrita del sistema y tachones
* que se trazan, porque un icono de libreta no transmite el desorden.
*
* Es el ÚNICO componente de la landing que sale a propósito de la paleta de la
* plataforma: papel crema, renglones, margen rojo y grafito. Precisamente porque es
* lo que el producto viene a reemplazar si se pareciera al producto, la sección
* dejaría de contar un cambio. No unifiques estos colores con los tokens `--ld-*`.
*/
export function NotebookVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div
className={`relative overflow-hidden rounded-2xl bg-[#faf7ef] p-6 text-[#3b3a35] shadow-[0_24px_60px_-24px_rgba(0,0,0,0.55)] ${className}`}
aria-hidden="true"
>
{/* Renglones del cuaderno */}
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "repeating-linear-gradient(#faf7ef 0 30px, #ddd6c4 30px 31px)",
}}
/>
{/* Margen rojo */}
<div className="pointer-events-none absolute inset-y-0 left-10 w-px bg-[#d98b8b]" />
<div className="relative grid grid-cols-1 gap-[0.72rem] pl-8">
{APUNTES.map((a, i) => (
<div key={a.texto} className="relative w-fit max-w-full">
<span
className="text-[0.92rem] leading-[1.6]"
style={{ fontFamily: "'Bradley Hand', 'Segoe Script', cursive" }}
>
{a.texto}
</span>
{a.tachado && (
<motion.span
className="absolute left-0 top-1/2 h-[1.5px] w-full bg-[#3b3a35]"
style={{ originX: 0 }}
initial={{ scaleX: reduced ? 1 : 0 }}
whileInView={{ scaleX: 1 }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.4,
delay: reduced ? 0 : 0.3 + i * 0.14,
ease: EASE_EXPO,
}}
/>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import { useRef } from "react";
import { useInView } from "framer-motion";
import { inView } from "../../../lib/motion";
// 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;
const MAX = Math.max(...SERIE);
const MIN = Math.min(...SERIE);
function yDe(v: number): number {
return H - PAD - ((v - MIN) / (MAX - MIN)) * (H - PAD * 2);
}
function pathFrom(serie: number[]): string {
const dx = (W - PAD * 2) / (serie.length - 1);
return serie
.map((v, i) => `${i === 0 ? "M" : "L"}${(PAD + i * dx).toFixed(1)} ${yDe(v).toFixed(1)}`)
.join(" ");
}
/**
* Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve.
*
* El trazo lo hacen animaciones CSS (`.ld-rev-*` en index.css) y no `motion.path`. No es
* una preferencia de estilo: iOS Safari suspende `requestAnimationFrame` durante el scroll
* por inercia, y framer-motion interpola contra el reloj de pared, así que la animación
* gastaba su duración sin pintar nada y saltaba al final. En un iPhone se veía como si
* nunca hubiera animado. Este componente solo decide CUÁNDO empieza; el CÓMO es del
* compositor, que sigue dibujando.
*
* `prefers-reduced-motion` también se resuelve en CSS, así que ya no hace falta el hook.
*/
export function RevenueLineVisual({ className = "" }: { className?: string }) {
const ref = useRef<SVGSVGElement>(null);
// Mismo umbral que el resto de la landing, y `once` para que no se repita al volver.
const visible = useInView(ref, inView);
const d = pathFrom(SERIE);
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
return (
<svg
ref={ref}
data-ld-rev-visible={visible ? "true" : "false"}
viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`}
role="img"
aria-label="Ingresos mensuales en tendencia ascendente"
>
<defs>
<linearGradient id="ld-rev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b66ff" stopOpacity="0.35" />
<stop offset="100%" stopColor="#3b66ff" stopOpacity="0" />
</linearGradient>
</defs>
{/* Rejilla base: tres guías en #eef0f4, el mismo `CartesianGrid` que usa la
gráfica de ingresos del panel. Dan escala sin competir. */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD}
x2={W - PAD}
y1={PAD + f * (H - PAD * 2)}
y2={PAD + f * (H - PAD * 2)}
stroke="#eef0f4"
strokeWidth="1"
/>
))}
<path className="ld-rev-area" d={area} fill="url(#ld-rev-fill)" />
{/* `pathLength={1}` normaliza la longitud: deja que dasharray y dashoffset se
expresen como fracción del trazo, que es lo que animan los keyframes. */}
<path
className="ld-rev-linea"
d={d}
pathLength={1}
fill="none"
stroke="#3b66ff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
<circle
className="ld-rev-punto"
cx={W - PAD}
cy={yDe(SERIE[SERIE.length - 1])}
r="5"
fill="#3b66ff"
/>
</svg>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
/**
* Muestrario: el abanico de coloración que hay sobre el mostrador de cualquier salón.
* Es el objeto más característico de este mundo y ya es vívido de por , así que hace
* de firma de la página sin necesidad de inventar un gradiente.
*
* Los ocho colores NO son decorativos ni inventados: son `PIE_COLORS` de
* [DashboardPage.tsx](../../../pages/DashboardPage.tsx), la paleta categórica que la
* app ya usa para los servicios en la gráfica de dona, y los mismos que el seed
* reparte entre los avatares de los empleados. Son ocho, igual que las muestras.
*
* De ahí que el hilo de la página sea literal y no una metáfora: el abanico del hero
* trae los mismos hex que la dueña va a ver en sus fichas cuando entre.
*
* Va sin etiquetas: las muestras se solapan como en un abanico real, así que solo la
* última quedaría a la vista, y una única etiqueta girada se lee como un error en vez
* de como un detalle. La forma y el color dicen lo que hay que decir.
*/
const TINTES = [
"#3b66ff",
"#f17616",
"#10b981",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f59e0b",
"#6366f1",
];
// El abanico abre 92° repartidos entre las nueve muestras. Más apertura y las puntas
// exteriores se salen de la columna y chocan con los avisos flotantes.
const SPREAD = 92;
const anguloDe = (i: number) => -SPREAD / 2 + (SPREAD / (TINTES.length - 1)) * i;
export function SwatchFanVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
// Puntero normalizado a 1..1. Con `useSpring` el abanico persigue al cursor con
// inercia en lugar de pegarse a él, que es lo que lo hace sentir como un objeto.
const px = useMotionValue(0);
const py = useMotionValue(0);
const sx = useSpring(px, { stiffness: 60, damping: 18, mass: 0.6 });
const sy = useSpring(py, { stiffness: 60, damping: 18, mass: 0.6 });
const rotateZ = useTransform(sx, [-1, 1], [7, -7]);
const rotateY = useTransform(sx, [-1, 1], [-11, 11]);
const rotateX = useTransform(sy, [-1, 1], [7, -7]);
useEffect(() => {
if (reduced) return;
// `pointerFine` decide si hay cursor que perseguir. En táctil no hay puntero,
// así que el abanico se queda con su vaivén propio (animate del contenedor).
const fino = window.matchMedia("(pointer: fine)").matches;
if (!fino) return;
const onMove = (e: PointerEvent) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
px.set(((e.clientX - (r.left + r.width / 2)) / (r.width / 2)) * 0.8);
py.set(((e.clientY - (r.top + r.height / 2)) / (r.height / 2)) * 0.8);
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => window.removeEventListener("pointermove", onMove);
}, [px, py, reduced]);
return (
<div
ref={ref}
className={`relative select-none ${className}`}
style={{ perspective: 1100 }}
aria-hidden="true"
>
<motion.div
className="relative mx-auto h-[16.5rem] w-full max-w-[27rem] sm:h-[20rem]"
style={
reduced
? undefined
: { rotateZ, rotateY, rotateX, transformStyle: "preserve-3d" }
}
// Vaivén propio: sostiene la vida del abanico en táctil, donde no hay cursor.
animate={reduced ? undefined : { y: [0, -10, 0] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
>
{TINTES.map((color, i) => {
const ang = anguloDe(i);
return (
<motion.div
key={color}
// Se centra con `inset-x-0 mx-auto` y NO con `left-1/2` + margen
// negativo: el ancho cambia en `sm:` y un margen fijo solo puede
// centrar uno de los dos. `translateX(-50%)` tampoco sirve aquí,
// porque framer-motion es dueña del `transform` de este nodo.
className="absolute inset-x-0 bottom-0 mx-auto h-[12rem] w-[4.15rem] origin-[50%_112%] rounded-[1rem] sm:h-[14.5rem] sm:w-[4.85rem]"
style={{
background: color,
boxShadow: "0 18px 40px -18px rgba(15,23,42,0.42)",
}}
// Abre desde el abanico cerrado: todas apiladas en 0° y se despliegan.
// Es el momento orquestado de la carga, no un fade genérico.
initial={reduced ? { rotate: ang, opacity: 1 } : { rotate: 0, opacity: 0 }}
animate={{ rotate: ang, opacity: 1 }}
transition={{
duration: reduced ? 0 : 1.15,
delay: reduced ? 0 : 0.15 + i * 0.055,
ease: EASE_EXPO,
}}
whileHover={reduced ? undefined : { y: -16, scale: 1.05 }}
/>
);
})}
</motion.div>
</div>
);
}
@@ -0,0 +1,54 @@
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) y los colores que el
// seed reparte a los avatares: cada persona llega con el suyo, igual que dentro
// de la app.
const EQUIPO = [
{ nombre: "Valentina", carga: 0.92, color: "#3b66ff", citas: 34, nota: "hasta el tope" },
{ nombre: "Mateo", carga: 0.74, color: "#f17616", citas: 27, nota: "bien" },
{ nombre: "Camila", carga: 0.61, color: "#10b981", citas: 22, nota: "bien" },
{ nombre: "Diego", carga: 0.38, color: "#a855f7", citas: 14, nota: "le caben más" },
{ nombre: "Renata", carga: 0.22, color: "#ec4899", citas: 8, nota: "casi libre" },
];
/** Carga real por especialista. El punto: la diferencia se ve de un golpe. */
export function TeamLoadVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`grid grid-cols-1 gap-3.5 ${className}`}>
{EQUIPO.map((p, i) => (
<div key={p.nombre} className="grid grid-cols-1 gap-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="font-extrabold">{p.nombre}</span>
<span className="shrink-0 text-xs" style={{ color: "var(--ld-muted)" }}>
<span className="ld-num font-bold">{p.citas}</span> citas · {p.nota}
</span>
</div>
{/* La pista es un div propio con opacidad en línea: el modificador de
opacidad de Tailwind no aplica sobre currentColor. */}
<div className="relative h-2.5 overflow-hidden rounded-full">
<div
className="absolute inset-0 rounded-full"
style={{ background: "currentColor", opacity: 0.12 }}
/>
<motion.div
className="absolute inset-y-0 left-0 w-full rounded-full"
style={{ background: p.color, originX: 0 }}
initial={{ scaleX: reduced ? p.carga : 0 }}
whileInView={{ scaleX: p.carga }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.9,
delay: reduced ? 0 : i * 0.09,
ease: EASE_EXPO,
}}
/>
</div>
</div>
))}
</div>
);
}
+19 -2
View File
@@ -4,6 +4,19 @@ export function Spinner({ className = "" }: { className?: string }) {
return <Loader2 className={`h-4 w-4 animate-spin ${className}`} />;
}
/**
* Fallback de Suspense para las rutas que se cargan por demanda (calendario y
* tablero, que arrastran FullCalendar y recharts). Ocupa el alto disponible para
* que el shell no un salto de layout al resolverse el chunk.
*/
export function RouteSpinner() {
return (
<div className="flex flex-1 items-center justify-center py-16 text-slate-400">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
export function EmptyState({
icon: Icon,
title,
@@ -41,10 +54,14 @@ export function PageHeader({
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 px-5 pt-5 sm:px-7 sm:pt-7 md:flex-row md:items-end md:justify-between">
<div className="flex shrink-0 flex-col gap-3 px-5 pt-5 sm:px-7 sm:pt-7 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-xl font-extrabold tracking-tight text-slate-900 sm:text-2xl">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
{/* El subtítulo se oculta en teléfono: son 2 líneas de texto descriptivo
(~70px) que en una pantalla de 390px le hacen falta al contenido, y
en el calendario además describe gestos de ratón ("arrastra",
"redimensiona") que no aplican al tacto. */}
{subtitle && <p className="mt-1 hidden text-sm text-slate-500 sm:block">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
+476 -25
View File
@@ -4,18 +4,39 @@
:root {
color-scheme: light;
/* Insets del sistema (Dynamic Island, notch, home indicator). Con
viewport-fit=cover el contenido se dibuja bajo esas zonas, así que el
chrome de la app tiene que descontarlas explícitamente. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
* {
-webkit-tap-highlight-color: transparent;
}
html {
/* iOS reescala el texto al rotar a landscape si no se fija. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
html,
body,
#root {
height: 100%;
}
/* 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%;
}
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
@@ -23,25 +44,41 @@ body {
color: #0f172a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Red de seguridad: ningún hijo debe poder desplazar la página en horizontal. */
overflow-x: hidden;
overscroll-behavior-y: none;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
/* Evita el zoom por doble toque y el retardo de 300 ms en controles. */
button,
a,
[role="button"],
label,
summary {
touch-action: manipulation;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
/* Scrollbars visibles solo con puntero fino. En táctil deben ser overlay
(como en iOS nativo): un scrollbar de 10 px roba ancho al layout y
descuadra el cálculo de 100% en pantallas estrechas. */
@media (pointer: fine) {
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
}
}
/* FullCalendar overrides */
@@ -161,6 +198,24 @@ body {
.fc .fc-timegrid-slot {
height: 2.4rem;
}
/* En táctil los slots se ajustan por tamaño de pantalla: la grilla de 08:00 a
21:00 son 26 slots, así que una altura fija de 2.4rem produce ~1000 px y
desborda cualquier teléfono. El calendario scrollea internamente (como
Google Calendar) en lugar de estirar la página. */
@media (max-width: 640px) {
.fc .fc-timegrid-slot {
height: 2.1rem;
}
}
@media (min-width: 641px) and (max-width: 1024px) {
.fc .fc-timegrid-slot {
height: 2.25rem;
}
}
/* El scroller interno del timegrid necesita arrastre fluido en iOS. */
.fc .fc-scroller {
-webkit-overflow-scrolling: touch;
}
.fc-timegrid-event {
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
@@ -204,24 +259,67 @@ body {
font-weight: 500;
}
/* Mobile: prevent squished week/month columns by giving them a min-width.
The card wrapper has overflow-x-auto, so wider grids scroll horizontally. */
/* Móvil: la vista Semana necesita un ancho mínimo o las 7 columnas quedan
ilegibles; el card la envuelve en overflow-auto, así que scrollea dentro de
la tarjeta sin arrastrar la página. La vista Mes cabe en un teléfono
(7 columnas de ~50 px), así que no se le fuerza ancho mínimo. */
@media (max-width: 640px) {
.fc-timeGridWeek-view .fc-scrollgrid-section > table,
.fc-timeGridWeek-view .fc-scrollgrid {
min-width: 640px;
}
.fc-dayGridMonth-view .fc-scrollgrid-section > table,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 560px;
}
/* bigger touch targets for events */
/* Objetivos táctiles más grandes para los eventos */
.fc-event {
min-height: 28px;
min-height: 30px;
padding: 3px 6px !important;
}
.fc .fc-button {
padding: 0.4rem 0.6rem;
padding: 0.45rem 0.6rem;
}
/* Etiquetas de hora más estrechas: en 390 px cada píxel del eje cuenta. */
.fc .fc-timegrid-axis,
.fc .fc-timegrid-slot-label {
font-size: 0.65rem;
}
.fc .fc-timegrid-axis-frame,
.fc .fc-timegrid-slot-label-frame {
padding-right: 2px;
}
.fc .fc-col-header-cell-cushion {
padding: 0.45rem 0;
font-size: 0.65rem;
}
/* La vista Lista es la más cómoda en teléfono: más aire al tocar. */
.fc .fc-list-event {
font-size: 0.85rem;
}
.fc .fc-list-event td {
padding: 0.7rem 0.6rem;
}
}
/* Tablet (iPad portrait y mini): la semana completa cabe sin scroll lateral,
solo hay que evitar que herede el ancho mínimo del teléfono. */
@media (min-width: 641px) and (max-width: 1024px) {
.fc-timeGridWeek-view .fc-scrollgrid,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 0;
}
.fc .fc-toolbar-title {
font-size: 1.05rem;
}
.fc .fc-button {
font-size: 0.75rem;
}
}
/* Toolbar del calendario tocable en cualquier pantalla táctil. Va por tipo de
puntero y no por ancho: un iPad en landscape mide 1180px (tier desktop) pero
se sigue tocando con el dedo. No se hereda el min-height de .btn (44px) para
no disparar el alto de la barra de navegación del calendario. */
@media (pointer: coarse) {
.fc .fc-button {
min-height: 40px;
}
}
@@ -281,6 +379,46 @@ body {
.btn-danger:hover:not(:disabled) {
background: #fecaca;
}
/* ---- Safe areas y alturas de viewport ---- */
.safe-area-bottom {
padding-bottom: max(0.75rem, var(--safe-bottom));
}
/* Chrome superior (headers fijos): descuenta la Dynamic Island / notch. */
.safe-top {
padding-top: var(--safe-top);
}
/* Landscape con notch: el contenido no debe quedar bajo la esquina redondeada. */
.safe-x {
padding-left: var(--safe-left);
padding-right: var(--safe-right);
}
/* Altura de viewport estable en iOS. 100vh NO descuenta la barra de URL
dinámica de Safari, así que el shell queda más alto que el área visible y
la página se puede desplazar. dvh la descuenta; vh queda como fallback. */
.h-screen-safe {
height: 100vh;
height: 100dvh;
}
.max-h-screen-safe {
max-height: 100vh;
max-height: 100dvh;
}
.min-h-screen-safe {
min-height: 100vh;
min-height: 100dvh;
}
/* Franjas con scroll lateral (chips de filtro) sin barra visible: en móvil la
barra roba alto y el gesto de arrastre ya comunica que hay más contenido. */
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
@layer components {
.input,
@@ -302,6 +440,319 @@ body {
border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
/* ---- Landing pública ----
Scope propio porque el panel vive en #f6f7fb y la landing tiene su propia
identidad. Va DENTRO de la capa (el default del repo) para que cualquier
utilidad de Tailwind aplicada en el JSX gane por especificidad de capa.
La paleta NO se inventa: son los colores que la plataforma ya usa. `--ld-azul` y
`--ld-naranja` son exactamente el cuadrado y el punto del logo
(`public/favicon.svg`), la tinta y los fondos son los del panel, y los seis tonos
de acento son los que el seed asigna a los avatares de empleados y a los
servicios (`server/lib/templates.ts`).
Por eso el hilo de la página «sus colores se vuelven sus números» es literal:
el abanico del hero trae los mismos hex que la dueña verá en sus fichas. */
.landing {
/* Marca */
--ld-azul: #3b66ff; /* brand-500: primario del logo y de .btn-primary */
--ld-azul-hondo: #1c36dc; /* brand-700: el hover que ya usa el panel */
--ld-naranja: #f17616; /* accent-500: el punto del logo */
/* Acentos: los colores de avatares y servicios del panel, sin cambiar un dígito */
--ld-indigo: #6366f1;
--ld-purpura: #a855f7;
--ld-rosa: #ec4899;
--ld-ambar: #f59e0b;
--ld-esmeralda: #10b981;
--ld-turquesa: #14b8a6;
/* Estructura: idéntica a la del panel */
--ld-tinta: #0f172a;
--ld-papel: #ffffff;
--ld-lienzo: #f6f7fb; /* el fondo del body de la app */
--ld-nube: #eef4ff; /* brand-50: el mismo lavado del "hoy" en el calendario */
--ld-crema: #fff7ed; /* orange-50, ya presente en el panel */
--ld-muted: #64748b;
--ld-hairline: #eef0f4;
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
font-family: Inter, system-ui, sans-serif;
color: var(--ld-tinta);
background: var(--ld-papel);
}
/* Lavados de sección. Los tres salen del panel, así que pasar de la landing al
producto no se siente como cambiar de sitio. */
.landing-papel {
background: var(--ld-papel);
}
.landing-nube {
background: var(--ld-nube);
}
.landing-crema {
background: var(--ld-lienzo);
}
/* Display: Fraunces con el eje WONK abierto. Es un serif suave y con carácter,
no una Didone de alto contraste: a 56px+ se lee cálido en vez de editorial-frío,
que es lo que este público necesita. */
.ld-display {
font-family: Fraunces, Georgia, "Times New Roman", serif;
font-variation-settings: "SOFT" 40, "WONK" 1;
font-size: clamp(2.5rem, 7.2vw, 5.5rem);
line-height: 1;
letter-spacing: -0.022em;
font-weight: 800;
}
.ld-title {
font-family: Fraunces, Georgia, "Times New Roman", serif;
font-variation-settings: "SOFT" 40, "WONK" 1;
font-size: clamp(1.875rem, 4.4vw, 3.15rem);
line-height: 1.08;
letter-spacing: -0.015em;
font-weight: 800;
}
/* Cuerpo en Inter, la misma tipografía del panel, a 17px mínimo y 1.6 de
interlínea: el público incluye personas de 50+ y la legibilidad pesa más que la
densidad. */
.ld-lead {
font-size: clamp(1.0625rem, 1.9vw, 1.3125rem);
line-height: 1.6;
font-weight: 500;
}
.ld-body {
font-size: 1.0625rem;
line-height: 1.65;
}
.ld-eyebrow {
font-size: 0.8125rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.ld-section {
padding-block: clamp(4.5rem, 10vh, 9rem);
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
.ld-wrap {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}
/* Canaleta lateral que además descuenta el notch en landscape.
NO combines `.safe-x` con `px-*` en el mismo elemento: `.safe-x` vive fuera de
`@layer` y por tanto gana a las utilidades de Tailwind, así que deja el padding
en 0 siempre que el inset del sistema sea 0 es decir, en todo lo que no sea un
iPhone en landscape. El resultado es contenido pegado al borde en móvil. Esta
clase resuelve las dos cosas en una sola declaración, como ya hace `.ld-section`. */
.ld-gutter {
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
}
/* 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. Inter porque Fraunces no
tiene cifras tabulares. */
.ld-num {
font-family: Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* Botón principal: el mismo azul de `.btn-primary` del panel, con el degradado
brand-500 brand-700 (los dos tonos que la app ya usa en normal y hover). El
naranja del logo no se usa como fondo de acción: en el favicon es el punto que
marca, no la superficie que trabaja, y en la app tampoco es un color de botón. */
.ld-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 56px;
padding-inline: 2rem;
border-radius: 9999px;
font-weight: 800;
font-size: 1rem;
color: #fff;
background: linear-gradient(100deg, var(--ld-azul), var(--ld-azul-hondo));
box-shadow: 0 12px 30px -10px rgba(59, 102, 255, 0.5);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s;
}
.ld-cta:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 40px -12px rgba(59, 102, 255, 0.58);
}
.ld-cta:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ld-cta-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 56px;
padding-inline: 1.75rem;
border-radius: 9999px;
font-weight: 700;
font-size: 1rem;
color: var(--ld-tinta);
background: rgba(255, 255, 255, 0.8);
border: 1.5px solid #e5e7eb;
transition: background 0.2s, border-color 0.2s;
}
.ld-cta-ghost:hover {
background: #fff;
border-color: #d1d5db;
}
/* Foco visible propio: el degradado del CTA tapa el outline por defecto. */
.landing a:focus-visible,
.landing button:focus-visible {
outline: 3px solid var(--ld-azul);
outline-offset: 3px;
}
/* Titular con degradado: naranjaámbar. Es el papel que el naranja tiene en el
logo el punto que marca el renglón importante trasladado a la frase que carga
la promesa. El color sólido va primero como fallback: `color: transparent` sin
`background-clip: text` deja el texto invisible, y ese fallo se llevaría media
frase del hero. */
.ld-gradient-text {
color: var(--ld-naranja);
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.ld-gradient-text {
background-image: linear-gradient(100deg, var(--ld-naranja), var(--ld-ambar));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
/* ---- Gráfica de ingresos de la landing ----
Se anima con CSS y NO con framer-motion a propósito. iOS Safari suspende
`requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion
interpola contra el reloj de pared: la animación consume su duración sin pintar un
solo fotograma y al reanudarse salta al estado final. En un iPhone se ve exactamente
como si nunca hubiera animado, que es el defecto que se reportó. Una animación CSS
lleva su propia línea de tiempo y se dibuja.
El easing es el mismo `EASE_EXPO` de src/lib/motion.ts; si cambia uno, cambia el otro. */
.ld-rev-linea {
stroke-dasharray: 1px 1px; /* con pathLength="1" las unidades son la longitud total */
stroke-dashoffset: 1px;
}
.ld-rev-area {
opacity: 0;
}
.ld-rev-punto {
/* `fill-box` para que el centro del círculo sea el origen y no el del lienzo SVG. */
transform-box: fill-box;
transform-origin: center;
transform: scale(0);
}
[data-ld-rev-visible="true"] .ld-rev-linea {
animation: ld-rev-trazo 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
[data-ld-rev-visible="true"] .ld-rev-area {
animation: ld-rev-aparecer 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
}
[data-ld-rev-visible="true"] .ld-rev-punto {
animation: ld-rev-aterrizar 0.5s cubic-bezier(0.16, 1, 0.3, 1) 1.3s forwards;
}
}
@keyframes ld-rev-trazo {
from {
stroke-dashoffset: 1px;
}
to {
stroke-dashoffset: 0px;
}
}
@keyframes ld-rev-aparecer {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ld-rev-aterrizar {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
/* Con movimiento reducido la gráfica sale ya dibujada. Igual que el resto de la landing,
el estado inicial resuelve al FINAL y no se acorta la transición: si dejara la línea en
`dashoffset: 1px` sin animación, quedaría invisible para siempre. */
@media (prefers-reduced-motion: reduce) {
.ld-rev-linea,
[data-ld-rev-visible="true"] .ld-rev-linea {
stroke-dashoffset: 0px;
animation: none;
}
.ld-rev-area,
[data-ld-rev-visible="true"] .ld-rev-area {
opacity: 1;
animation: none;
}
.ld-rev-punto,
[data-ld-rev-visible="true"] .ld-rev-punto {
transform: scale(1);
animation: none;
}
}
/* ---- Anti auto-zoom de iOS (fuera de @layer para ganar a las utilidades) ----
Mobile Safari hace zoom al enfocar cualquier campo con font-size < 16px y no
revierte al desenfocar: la vista queda escalada y corrida. Ese es el origen
del "arranca con zoom y desplazada" al tocar el campo de correo en el login.
Se fuerzan 16px en todo puntero grueso (iPhone y iPad) en lugar de bloquear
el zoom con maximum-scale=1, que rompería el pinch-zoom de accesibilidad.
`select` incluido: iOS también zoomea al abrir un desplegable pequeño. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select,
textarea,
.input,
.select,
.textarea {
font-size: 16px;
}
/* Objetivos táctiles cómodos (Apple recomienda 44 pt). */
.btn,
.input,
.select,
.textarea {
min-height: 44px;
}
/* Para controles compactos (chips de filtro, grupos segmentados) donde 44px
rompería la densidad de la barra. Se decide por tipo de puntero y no por
ancho de pantalla: un iPad se toca con el dedo igual que un iPhone, así que
un breakpoint `sm:` dejaría fuera justamente a las tablets. */
.tap-target {
min-height: 40px;
}
/* Botones de solo icono (editar, borrar, cerrar, menú): con p-1.5 miden 28px,
por debajo de lo tocable con el pulgar. Se marcan con `.icon-btn` en lugar de
detectarlos por selector: se intentó `button:has(> svg:only-child)` y es
engañoso, porque `:only-child` solo mira hijos ELEMENTO y un botón con
etiqueta ("<Plus/> Nueva cita") también encaja el texto es un nodo de
texto. Esa regla, al fijar `display`, convirtió los NavLink del menú lateral
en inline-flex y los repartió en dos columnas. */
.icon-btn {
min-height: 40px;
min-width: 40px;
}
/* Se centra el icono, no se cambia el `display` del botón. El preflight de
Tailwind pone `svg { display: block }`, así que el icono ignora `text-align`
y `margin-inline: auto` es lo que lo centra en la caja ampliada. Tocar el
display aquí sería contraproducente: esta regla está fuera de @layer y
ganaría a las utilidades, de modo que un `hidden`/`lg:block` dejaría de
funcionar y el botón de colapsar aparecería en el móvil. */
.icon-btn > svg {
margin-inline: auto;
}
}
.label {
display: block;
+82
View File
@@ -0,0 +1,82 @@
/**
* Captura de `beforeinstallprompt` a nivel de módulo, no dentro de un hook.
*
* El navegador dispara `beforeinstallprompt` **una sola vez** por carga de página, poco
* después del `load`, y no lo repite: no hay forma de volver a pedirlo. Un listener que
* se registra al montar un componente sencillamente se lo pierde si el componente aparece
* más tarde.
*
* Y aparece más tarde: `LoginPage` se carga con `React.lazy`, así que `InstallAppPrompt`
* monta después de una ida y vuelta de red extra para traer su chunk. En local eso es un
* milisegundo y no se nota; sobre una conexión real el evento ya pasó y el botón de
* instalar no aparecía nunca. `pwa-e2e.mjs` contra el dominio HTTPS lo detecta; contra
* `localhost` no, porque ahí el chunk llega antes que el evento.
*
* Por eso el listener se registra al evaluar este módulo, que `main.tsx` importa antes de
* montar React, y el evento queda guardado hasta que alguien lo pida.
*/
export interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
}
let capturado: BeforeInstallPromptEvent | null = null;
let instalado = false;
const suscriptores = new Set<() => void>();
function avisar() {
for (const notificar of suscriptores) notificar();
}
if (typeof window !== "undefined") {
window.addEventListener("beforeinstallprompt", (event) => {
// `preventDefault` evita el mini-infobar de Chrome; el botón propio queda a cargo.
event.preventDefault();
capturado = event as BeforeInstallPromptEvent;
avisar();
});
window.addEventListener("appinstalled", () => {
capturado = null;
instalado = true;
avisar();
});
}
/** El evento pendiente, o null si el navegador nunca lo disparó. */
export function eventoDisponible(): BeforeInstallPromptEvent | null {
return capturado;
}
export function yaInstalado(): boolean {
return instalado;
}
/** Lo saca del almacén: un `BeforeInstallPromptEvent` solo se puede usar una vez. */
export function tomarEvento(): BeforeInstallPromptEvent | null {
const evento = capturado;
if (evento) {
capturado = null;
avisar();
}
return evento;
}
/** Lo devuelve si `prompt()` falló, para no perder la única oportunidad de instalar. */
export function devolverEvento(evento: BeforeInstallPromptEvent) {
capturado = evento;
avisar();
}
export function marcarInstalado() {
capturado = null;
instalado = true;
avisar();
}
export function suscribir(notificar: () => void): () => void {
suscriptores.add(notificar);
return () => {
suscriptores.delete(notificar);
};
}
+38
View File
@@ -0,0 +1,38 @@
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;
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
/**
* Tres tamaños, alineados con los breakpoints del CSS del calendario:
* phone <= 640px (iPhone 12: 390, iPhone 16 Pro Max: 440)
* tablet 641-1024px (iPad mini: 744, iPad 10.9": 820, iPad Pro portrait: 1024)
* desktop >= 1025px (iPad landscape: 1180, iPad Pro landscape: 1366)
*
* El límite de tablet incluye 1024 a propósito: un iPad Pro en portrait mide justo
* 1024px y, descontando la barra lateral, deja ~712px para el calendario. Con 7
* columnas son ~100px por día, y con dos citas solapadas los títulos quedan en
* "A...". No coincide con el `lg:` de Tailwind (también 1024), así que a 1024px hay
* barra lateral fija Y vista de 3 días: es justo lo que se busca en ese equipo.
*/
export type Breakpoint = "phone" | "tablet" | "desktop";
const PHONE = "(max-width: 640px)";
const TABLET = "(min-width: 641px) and (max-width: 1024px)";
function currentBreakpoint(): Breakpoint {
if (typeof window === "undefined") return "desktop";
if (window.matchMedia(PHONE).matches) return "phone";
if (window.matchMedia(TABLET).matches) return "tablet";
return "desktop";
}
export function useBreakpoint(): Breakpoint {
const [bp, setBp] = useState<Breakpoint>(currentBreakpoint);
useEffect(() => {
const lists = [window.matchMedia(PHONE), window.matchMedia(TABLET)];
const onChange = () => setBp(currentBreakpoint());
lists.forEach((l) => l.addEventListener("change", onChange));
// Al rotar un iPad se cruza de 820 a 1180 y los media queries ya lo cubren,
// pero en iOS el evento de orientación llega antes de que se reasienten las
// medidas; re-evaluamos para no quedar con el layout de la orientación previa.
window.addEventListener("orientationchange", onChange);
return () => {
lists.forEach((l) => l.removeEventListener("change", onChange));
window.removeEventListener("orientationchange", onChange);
};
}, []);
return bp;
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from "react";
import {
devolverEvento,
eventoDisponible,
marcarInstalado,
suscribir,
tomarEvento,
yaInstalado,
} from "./installPrompt";
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
const DISMISSED_STORAGE_KEY = "agendamax-install-dismissed";
function hasDismissedPrompt() {
try {
return window.sessionStorage.getItem(DISMISSED_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function clearDismissedPrompt() {
try {
window.sessionStorage.removeItem(DISMISSED_STORAGE_KEY);
} catch {
// Storage can be unavailable in privacy-restricted browsers.
}
}
function persistDismissedPrompt() {
try {
window.sessionStorage.setItem(DISMISSED_STORAGE_KEY, "1");
} catch {
// Storage can be unavailable in privacy-restricted browsers.
}
}
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone));
}
function isIOSSafari() {
const ua = navigator.userAgent;
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua);
}
/**
* El estado inicial ya consulta el almacén de `installPrompt`: cuando este hook corre, el
* evento puede haberse disparado hace rato. No se registra un listener propio aquí a
* propósito ver el comentario de `src/lib/installPrompt.ts`.
*/
export function useInstallPrompt() {
const [state, setState] = useState<InstallPromptState>(() => {
if (typeof window === "undefined") return "unsupported";
if (isStandalone() || yaInstalado()) return "installed";
if (eventoDisponible()) return hasDismissedPrompt() ? "unsupported" : "available";
return isIOSSafari() ? "ios-instructions" : "unsupported";
});
const [dismissed, setDismissed] = useState(() => typeof window !== "undefined" && hasDismissedPrompt());
const installInFlight = useRef(false);
useEffect(() => {
if (isStandalone()) {
setState("installed");
return;
}
const sincronizar = () => {
if (yaInstalado()) {
// Instalar borra el descarte: el aviso ya cumplió su función.
clearDismissedPrompt();
setDismissed(false);
setState("installed");
return;
}
if (eventoDisponible()) {
if (!hasDismissedPrompt()) setState("available");
return;
}
// Sin evento pendiente: se cae al comportamiento de iOS, que nunca lo dispara.
setState(isIOSSafari() ? "ios-instructions" : "unsupported");
};
sincronizar();
return suscribir(sincronizar);
}, []);
const install = async () => {
if (installInFlight.current) return;
const event = tomarEvento();
if (!event) return;
installInFlight.current = true;
try {
await event.prompt();
const choice = await event.userChoice;
if (choice.outcome === "accepted") {
marcarInstalado();
setState("installed");
} else {
persistDismissedPrompt();
setDismissed(true);
}
} catch {
// Devolver el evento al almacén: es la única oportunidad de instalar de esta carga.
devolverEvento(event);
setState("available");
} finally {
installInFlight.current = false;
}
};
return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) };
}
+23
View File
@@ -0,0 +1,23 @@
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;
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
const KEY = "ap_sidebar_collapsed";
function read(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(KEY) === "1";
} catch {
return false; // Safari en modo privado puede lanzar al leer localStorage.
}
}
/**
* Colapso de la barra lateral en pantallas grandes (lg+), recordado entre
* sesiones. En iPad Pro portrait (1024px) la barra expandida se lleva 256px de
* los 1024 disponibles, así que poder colapsarla a solo iconos es lo que más
* ancho devuelve al contenido.
*/
export function useSidebarCollapsed() {
const [collapsed, setCollapsed] = useState(read);
const toggle = useCallback(() => setCollapsed((c) => !c), []);
useEffect(() => {
try {
window.localStorage.setItem(KEY, collapsed ? "1" : "0");
} catch {
/* sin persistencia si el almacenamiento está bloqueado */
}
}, [collapsed]);
// Mantiene ambos shells (app y admin) en sincronía si se abren en dos pestañas.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === KEY && e.newValue !== null) setCollapsed(e.newValue === "1");
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
return { collapsed, toggle };
}
+14
View File
@@ -2,6 +2,10 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Importado por su efecto y antes de montar React a propósito: registra el listener de
// `beforeinstallprompt`, que el navegador dispara una sola vez y muy temprano. Si esperara
// a que monte el componente del botón —que vive en una ruta lazy— llegaría tarde.
import "./lib/installPrompt";
import App from "./App.tsx";
import "./index.css";
@@ -24,3 +28,13 @@ createRoot(document.getElementById("root")!).render(
</QueryClientProvider>
</StrictMode>
);
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
void navigator.serviceWorker
.register("/sw.js", { updateViaCache: "none" })
.catch((error: unknown) => {
if (import.meta.env.DEV) console.warn("AgendaMax service worker registration failed", error);
});
});
}
+109 -36
View File
@@ -12,13 +12,19 @@ import type {
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import esLocale from "@fullcalendar/core/locales/es";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react";
import { Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { PageHeader, Spinner } from "../components/ui";
import { AppointmentModal } from "../components/AppointmentModal";
import { statusColor } from "../lib/format";
import { useBreakpoint } from "../lib/useBreakpoint";
/** Aire bajo el calendario (padding inferior del contenedor + respiro visual). */
const CAL_BOTTOM_GAP = 20;
/** Por debajo de esto la grilla es inservible: mejor que la página scrollee. */
const CAL_MIN_HEIGHT = 360;
const STATUS_FILTERS = [
{ value: "all", label: "Todas" },
@@ -38,15 +44,13 @@ export function CalendarPage() {
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
const [employeeFilter, setEmployeeFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [isMobile, setIsMobile] = useState(
typeof window !== "undefined" ? window.matchMedia("(max-width: 640px)").matches : false
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 640px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const bp = useBreakpoint();
const isMobile = bp === "phone";
const isTablet = bp === "tablet";
const calWrapRef = useRef<HTMLDivElement>(null);
const chromeRef = useRef<HTMLDivElement>(null);
const [calHeight, setCalHeight] = useState<number | null>(null);
const isOwner = user?.role === "owner";
// Employees see only their own by default
@@ -75,6 +79,39 @@ export function CalendarPage() {
},
});
// La altura del calendario se le pasa como número en lugar de height="100%":
// FullCalendar mide su contenedor una sola vez al montar y solo re-mide en
// resize de ventana, así que se quedaba con una altura vieja cuando el layout
// cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor de
// 525px). Se mide contra el viewport —posición real del contenedor e
// innerHeight— y no contra el contenedor flex, cuya altura se resuelve más
// tarde que el primer ciclo de medición.
useEffect(() => {
const compute = () => {
const el = calWrapRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const available = Math.round(window.innerHeight - top - CAL_BOTTOM_GAP);
setCalHeight(Math.max(CAL_MIN_HEIGHT, available));
};
compute();
// Se observa la barra de filtros y no el body: el shell es overflow-hidden con
// altura fija, así que el body nunca cambia de tamaño y un observador sobre él
// no volvería a dispararse. Lo que sí reflowea es ese bloque (el filtro de
// empleados pasa a dos filas al llegar la lista), y eso mueve el `top` del
// calendario, que es justo lo que hay que recalcular.
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(compute) : null;
if (chromeRef.current) ro?.observe(chromeRef.current);
window.addEventListener("resize", compute);
window.addEventListener("orientationchange", compute);
return () => {
ro?.disconnect();
window.removeEventListener("resize", compute);
window.removeEventListener("orientationchange", compute);
};
// employeesData y moveError cambian el alto del chrome; bp cambia la toolbar.
}, [employeesData, moveError, bp]);
const events = useMemo(() => {
return (data?.appointments ?? []).map((a) => {
const empColor = a.employee?.color ?? "#3b66ff";
@@ -148,6 +185,14 @@ export function CalendarPage() {
const onToday = () => calRef.current?.getApi().today();
// La grilla empieza a las 08:00, así que en pantallas cortas la hora actual
// puede quedar fuera de vista al abrir. Arrancamos una hora antes de "ahora",
// acotado al rango visible de la grilla.
const scrollTime = useMemo(() => {
const h = Math.min(20, Math.max(8, new Date().getHours() - 1));
return `${String(h).padStart(2, "0")}:00:00`;
}, []);
return (
<div className="flex h-full flex-col">
<PageHeader
@@ -193,7 +238,7 @@ export function CalendarPage() {
</div>
)}
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
<div ref={chromeRef} className="flex shrink-0 flex-wrap items-center gap-2 px-5 pb-3 pt-3 sm:gap-3 sm:px-7 sm:pt-4">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
<Filter className="h-4 w-4 shrink-0 text-slate-400" />
@@ -211,12 +256,14 @@ export function CalendarPage() {
</select>
</div>
)}
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{/* Una sola fila con scroll lateral en móvil: envueltos en dos filas los
4 chips se llevaban ~150px de alto que necesita la grilla. */}
<div className="scrollbar-none flex max-w-full items-center gap-1 overflow-x-auto rounded-xl border border-slate-200 bg-white p-1 sm:flex-wrap sm:overflow-visible">
{STATUS_FILTERS.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
className={`tap-target shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${
statusFilter === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
@@ -226,24 +273,42 @@ export function CalendarPage() {
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
<div className="card flex h-full min-h-0 flex-col overflow-auto p-3 sm:p-4">
{/* min-h-0 permite que el calendario se encoja dentro del flex; min-h en el
card evita que se aplaste en landscape de teléfono (440px de alto), donde
es mejor que la página scrollee que comprimir la grilla. */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 sm:px-7 sm:pb-6">
{/* p-1.5 en móvil: el padding interno del card es ancho que le robábamos a
la grilla (antes p-3), y aquí no cuesta consistencia visual. */}
<div className="card flex h-full min-h-[420px] flex-col overflow-y-auto p-1.5 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
</div>
)}
{/* min-h aquí y no solo en el card: si aparece el aviso de "sin citas",
no debe robarle altura a la grilla hasta dejarla inservible. */}
<div ref={calWrapRef} className="min-h-[360px] flex-1 sm:min-h-[420px]">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
locale={esLocale}
initialView={isMobile ? "timeGridDay" : "timeGridWeek"}
/* En tablet, 3 días en vez de 7: en un iPad portrait (820px) la semana
completa con varios especialistas a la misma hora parte cada evento
en columnas de ~30px y el texto queda en "A..", "Fe...". Con 3 días
cada columna ronda los 250px y se lee. Semana sigue a un toque. */
initialView={isMobile ? "timeGridDay" : isTablet ? "timeGridThreeDay" : "timeGridWeek"}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "timeGridDay,listWeek" }
? // "today" va en la barra superior en lugar de un footerToolbar
// propio: esa fila extra costaba ~90px de grilla. El CSS móvil
// pone el título en su propia línea, así que los tres grupos
// caben en una sola fila por debajo.
{ left: "prev,next today", center: "title", right: "timeGridDay,listWeek" }
: isTablet
? { left: "prev,next today", center: "title", right: "timeGridThreeDay,timeGridWeek,timeGridDay,listWeek" }
: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }
}
footerToolbar={isMobile ? { center: "today" } : false}
footerToolbar={false}
buttonText={{
today: "Hoy",
month: "Mes",
@@ -259,20 +324,32 @@ export function CalendarPage() {
noEventsContent: "Sin citas esta semana",
},
timeGridDay: { buttonText: "Día" },
timeGridThreeDay: {
type: "timeGrid",
duration: { days: 3 },
buttonText: "3 días",
},
}}
height={isMobile ? "auto" : "auto"}
contentHeight={isMobile ? 560 : 680}
/* Antes: contentHeight fijo (560/680px). Con .fc-timegrid-slot a 2.4rem
la grilla de 08:00-21:00 mide ~1000px, así que el calendario crecía
a 1.4× la pantalla del iPhone 12 y arrastraba la página. Ahora ocupa
exactamente su contenedor y scrollea dentro. */
height={calHeight ?? "100%"}
expandRows
stickyHeaderDates
firstDay={1}
slotMinTime="08:00:00"
slotMaxTime="21:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
/* En teléfono el eje horario se lleva ancho que necesitan las
columnas: "8 a.m." en vez de "08:00 a. m.". */
slotLabelFormat={
isMobile
? { hour: "numeric", hour12: true }
: { hour: "2-digit", minute: "2-digit", hour12: true }
}
scrollTime={scrollTime}
nowIndicator
allDaySlot={false}
selectable
@@ -283,7 +360,7 @@ export function CalendarPage() {
eventStartEditable
slotEventOverlap={false}
eventMinHeight={28}
dayMaxEvents={isMobile ? 2 : 3}
dayMaxEvents={isMobile ? 2 : isTablet ? 3 : 4}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
@@ -297,15 +374,11 @@ export function CalendarPage() {
datesSet={() => refetch()}
eventContent={(arg) => <EventContent arg={arg} />}
/>
{!isLoading && events.length === 0 && (
<div className="mt-2">
<EmptyState
icon={CalendarDays}
title="Sin citas en este rango"
description="Selecciona un horario en el calendario o crea una nueva cita."
/>
</div>
)}
</div>
{/* Sin EmptyState debajo de la grilla: con la altura del calendario ya
fijada, ese bloque no cabía en el card y se solapaba con las horas.
El vacío ya se comunica por la propia grilla y, en la vista Lista,
por noEventsContent más el botón "Nueva cita" de la cabecera. */}
</div>
<button onClick={onToday} className="sr-only">Hoy</button>
</div>
+1 -1
View File
@@ -106,7 +106,7 @@ export function DashboardPage() {
key={r.value}
onClick={() => setRange(r.value)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
"tap-target rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
range === r.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
>
+2 -2
View File
@@ -88,7 +88,7 @@ export function EmployeesPage() {
setEditing(e);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
@@ -96,7 +96,7 @@ export function EmployeesPage() {
onClick={() => {
if (confirm(`¿Eliminar a ${e.name}?`)) removeMut.mutate(e.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
+31
View File
@@ -0,0 +1,31 @@
import { LandingNav } from "../components/landing/LandingNav";
import { HeroSection } from "../components/landing/HeroSection";
import { CostSection } from "../components/landing/CostSection";
import { ShiftSection } from "../components/landing/ShiftSection";
import { ProductSection } from "../components/landing/ProductSection";
import { ProofSection } from "../components/landing/ProofSection";
import { ObjectionsSection } from "../components/landing/ObjectionsSection";
import { ClosingSection } from "../components/landing/ClosingSection";
/**
* Orquestador de la landing. Solo compone: toda la lógica y el copy viven en las
* secciones, que no reciben props ni comparten estado. Export default porque
* `React.lazy` en App.tsx lo exige.
*
* Orden del funnel: problema costo mecanismo prueba objeción cierre.
* `LandingNav` es chrome fijo, no cuenta como sección.
*/
export default function LandingPage() {
return (
<div className="landing">
<LandingNav />
<HeroSection />
<CostSection />
<ShiftSection />
<ProductSection />
<ProofSection />
<ObjectionsSection />
<ClosingSection />
</div>
);
}
+208 -102
View File
@@ -1,20 +1,63 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, ArrowRight, Mail, Lock, AlertCircle, Wand2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AlertCircle, ArrowLeft, ArrowRight, Lock, Mail, Wand2 } from "lucide-react";
import { motion } from "framer-motion";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
import { InstallAppPrompt } from "../components/InstallAppPrompt";
import { revealStagger, revealUp } from "../lib/motion";
import { useReducedMotion } from "../lib/useReducedMotion";
import { BrandMark } from "../components/BrandMark";
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
type DemoUser = { email: string; name: string; role: string; avatar_color: string };
/**
* Qué ve cada quien. Está escrito en el idioma del salón y no en el del sistema:
* quien entra a probar no sabe qué es un rol ni un tenant, pero sabe
* perfectamente quién hace qué en su negocio.
*
* Los rótulos no asumen género. No es un tecnicismo de estilo: la cuenta demo de
* empleado incluye a Carolina y a Diego, así que «tus muchachas» dejaba fuera a una
* de las dos tarjetas que la propia pantalla muestra debajo.
*/
const GRUPOS = [
{
role: "owner",
titulo: "Como quien manda",
detalle: "Ves todo: la agenda, las cuentas del día, tu gente y tus clientas.",
// Azul primario: es el camino principal, y comparte color con el CTA y la marca.
color: "var(--ld-azul)",
limite: 1,
},
{
role: "employee",
titulo: "Como alguien de tu equipo",
detalle: "Ve nomás su día y sus propias cuentas. Nada que configurar.",
color: "var(--ld-naranja)",
limite: 2,
},
{
role: "admin",
titulo: "Como quien lleva varios salones",
detalle: "Da de alta negocios y los ve todos juntos.",
color: "var(--ld-purpura)",
limite: 1,
},
] as const;
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
const reduced = useReducedMotion();
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
const [password, setPassword] = useState(DEMO ? "demo1234" : "");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [demoUsers, setDemoUsers] = useState<{ email: string; name: string; role: string; avatar_color: string }[]>([]);
const [pendiente, setPendiente] = useState<string | null>(null);
const [demoUsers, setDemoUsers] = useState<DemoUser[]>([]);
useEffect(() => {
if (user) navigate("/", { replace: true });
@@ -22,9 +65,21 @@ export function LoginPage() {
useEffect(() => {
if (!DEMO) return;
api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {});
api.auth
.demoUsers()
.then((r) => setDemoUsers(r.users))
.catch(() => {});
}, []);
const grupos = useMemo(
() =>
GRUPOS.map((g) => ({
...g,
cuentas: demoUsers.filter((u) => u.role === g.role).slice(0, g.limite),
})).filter((g) => g.cuentas.length > 0),
[demoUsers]
);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -39,80 +94,150 @@ export function LoginPage() {
}
};
// Demo-only quick login. Dead-code-eliminated from the prod bundle (DEMO is a
// build-time constant), which is why the demo password lives only inside this branch.
// Acceso de un clic, solo demo. `DEMO` es una constante de build, así que esta rama
// entera —y con ella la contraseña— se elimina del bundle de producción por
// dead-code elimination. Por eso la contraseña vive aquí dentro y no arriba.
const quickLogin = DEMO
? async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setPendiente(mail);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message);
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
setPendiente(null);
}
}
: undefined;
return (
<div className="grid min-h-screen lg:grid-cols-2">
{/* Left: brand panel */}
<div className="relative hidden flex-col justify-between overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800 p-10 text-white lg:flex">
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-96 w-96 rounded-full bg-accent-500/30 blur-3xl" />
<div className="relative z-10 flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
<Sparkles className="h-5 w-5" />
</div>
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
</div>
<div className="landing relative min-h-screen-safe overflow-hidden">
<div
className="pointer-events-none absolute inset-0"
// Los mismos lavados del panel: brand-50 → orange-50 → el lienzo de la app.
// Entrar al producto desde aquí no debe sentirse como cambiar de sitio.
style={{ background: "linear-gradient(160deg, #eef4ff 0%, #fff7ed 55%, #f6f7fb 100%)" }}
/>
<div className="relative z-10 max-w-md">
<h1 className="text-4xl font-extrabold leading-tight tracking-tight">
La gestión visual de tu negocio, en un solo lugar.
</h1>
<p className="mt-4 text-brand-100">
Calendario de citas con arrastrar y soltar, control de empleados, tickets e ingresos en tiempo real.
Diseñado para dueños y equipos.
</p>
<ul className="mt-8 space-y-3 text-sm">
{[
"Agenda citas en segundos y arrástralas cuando quieras",
"Tablero con tus ingresos, mejores empleados y clientes top",
"Gestiona servicios, empleados y clientes desde un panel",
].map((t) => (
<li key={t} className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent-300" />
<span className="text-brand-50">{t}</span>
</li>
))}
</ul>
</div>
{/* `ld-gutter` en lugar de `safe-x px-5`: ver el comentario de la clase en
index.css combinar las dos dejaba el padding lateral en 0. */}
<div className="safe-top ld-gutter relative mx-auto flex w-full max-w-5xl flex-col pb-12 pt-8 sm:pt-10">
<Link
to="/"
className="tap-target inline-flex w-fit items-center gap-2 text-[0.92rem] font-bold"
style={{ color: "var(--ld-muted)" }}
>
<ArrowLeft className="h-4 w-4" />
Volver
</Link>
<div className="relative z-10 text-xs text-brand-200">
© {new Date().getFullYear()} AgendaMax · Demo
</div>
</div>
{/* Right: form */}
<div className="flex items-center justify-center bg-[#f6f7fb] px-5 py-10">
<div className="w-full max-w-sm">
<div className="mb-7 text-center lg:hidden">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-6 w-6" />
<motion.div
className="mt-9 grid grid-cols-1 items-start gap-10 lg:mt-12 lg:grid-cols-[1.05fr_1fr] lg:gap-14"
initial="hidden"
animate="show"
variants={revealStagger(reduced)}
>
{/* ---- Columna izquierda: entrar de un clic ---- */}
<motion.div variants={revealUp(reduced)}>
<div className="flex items-center gap-2.5 text-[1.05rem] font-extrabold tracking-tight">
<BrandMark className="h-9 w-9" />
AgendaMax
</div>
<h1 className="text-xl font-extrabold">AgendaMax</h1>
</div>
<div className="card p-6 shadow-card">
<h2 className="text-lg font-extrabold text-slate-900">Bienvenido de nuevo</h2>
<p className="mt-1 text-sm text-slate-500">Inicia sesión para acceder a tu panel.</p>
<h1 className="ld-title mt-8 text-[clamp(1.75rem,4vw,2.6rem)]">
Entra sin llenar nada.
</h1>
<p className="ld-lead mt-4 max-w-md" style={{ color: "var(--ld-muted)" }}>
Escoge con qué ojos quieres verlo. Cada opción abre el mismo salón, pero desde el
lugar de una persona distinta.
</p>
<form onSubmit={submit} className="mt-5 space-y-4">
{DEMO && (
<div className="mt-8 grid grid-cols-1 gap-6">
{grupos.map((g) => (
<div key={g.role} className="grid grid-cols-1 gap-2.5">
<div className="flex items-start gap-2.5">
<span
className="mt-1 h-5 w-1.5 shrink-0 rounded-full"
style={{ background: g.color }}
aria-hidden="true"
/>
<div>
<div className="text-[0.95rem] font-extrabold">{g.titulo}</div>
<div className="text-[0.85rem]" style={{ color: "var(--ld-muted)" }}>
{g.detalle}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-2 pl-4">
{g.cuentas.map((u) => (
<button
key={u.email}
type="button"
data-magic-login
data-role={u.role}
onClick={() => quickLogin?.(u.email)}
disabled={loading}
className="group flex min-h-[58px] w-full items-center gap-3 rounded-2xl bg-white/85 px-4 py-2.5 text-left ring-1 ring-black/[0.05] transition-all hover:bg-white hover:shadow-[0_14px_32px_-14px_rgba(27,21,51,0.28)] disabled:opacity-60"
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name
.split(" ")
.map((p) => p[0])
.slice(0, 2)
.join("")}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.95rem] font-extrabold">
{u.name}
</span>
<span
className="block truncate text-[0.78rem]"
style={{ color: "var(--ld-muted)" }}
>
{u.email}
</span>
</span>
{pendiente === u.email ? (
<Spinner />
) : (
<Wand2
className="h-4 w-4 shrink-0 opacity-30 transition-opacity group-hover:opacity-100"
style={{ color: g.color }}
/>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</motion.div>
{/* ---- Columna derecha: formulario manual ----
Visible sin interacción a propósito: visual-audit, responsive-audit y
pwa-e2e rellenan estos campos justo después del goto. Colapsarlo tras
un toggle rompería los tres. */}
<motion.div
className="w-full rounded-3xl bg-white/85 p-6 ring-1 ring-black/[0.05] backdrop-blur-sm sm:p-7"
variants={revealUp(reduced)}
>
<h2 className="text-lg font-extrabold">Ya tengo mi cuenta</h2>
<p className="mt-1 text-[0.9rem]" style={{ color: "var(--ld-muted)" }}>
Entra con tu correo y tu contraseña.
</p>
<form onSubmit={submit} className="mt-6 grid grid-cols-1 gap-4">
<div>
<label className="label">Correo</label>
<div className="relative">
@@ -122,7 +247,7 @@ export function LoginPage() {
className="input pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tucorreo@negocio.com"
placeholder="tucorreo@salon.com"
autoComplete="email"
required
/>
@@ -145,59 +270,40 @@ export function LoginPage() {
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-rose-50 px-3 py-2 text-sm font-medium text-rose-700">
<div className="flex items-center gap-2 rounded-xl bg-rose-50 px-3 py-2.5 text-sm font-semibold text-rose-700">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
{loading ? <Spinner /> : <>Entrar <ArrowRight className="h-4 w-4" /></>}
<button type="submit" className="ld-cta w-full" disabled={loading}>
{loading && !pendiente ? (
<Spinner />
) : (
<>
Entrar <ArrowRight className="h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-5">
<InstallAppPrompt />
</div>
{DEMO && (
<>
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
<span className="h-px flex-1 bg-slate-200" />
cuentas demo
<span className="h-px flex-1 bg-slate-200" />
</div>
<div className="mt-3 space-y-1.5">
{demoUsers.slice(0, 4).map((u) => (
<button
key={u.email}
onClick={() => quickLogin?.(u.email)}
disabled={loading}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
>
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name.split(" ").map((p) => p[0]).slice(0, 2).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{u.name}</div>
<div className="truncate text-[11px] text-slate-500">
{u.role === "owner" ? "Dueño" : "Empleado"} · {u.email}
</div>
</div>
<Wand2 className="h-4 w-4 text-slate-300 transition-colors group-hover:text-brand-500" />
</button>
))}
</div>
</>
<p
className="mt-5 text-center text-[0.82rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
>
Salón de práctica · todas las cuentas usan{" "}
<code className="font-mono font-bold" style={{ color: "var(--ld-tinta)" }}>
demo1234
</code>
</p>
)}
</div>
{DEMO && (
<p className="mt-4 text-center text-xs text-slate-400">
Demo · cualquier cuenta usa la contraseña <code className="font-mono font-semibold text-slate-600">demo1234</code>
</p>
)}
</div>
</motion.div>
</motion.div>
</div>
</div>
);
+2 -2
View File
@@ -110,10 +110,10 @@ export function NotificationsPage() {
</div>
{n.status === "pending" && (
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={() => act(n.id, "send")} className="rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
<button onClick={() => act(n.id, "send")} className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
<Send className="h-3.5 w-3.5" />
</button>
<button onClick={() => act(n.id, "cancel")} className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
<button onClick={() => act(n.id, "cancel")} className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
+2 -2
View File
@@ -92,7 +92,7 @@ export function ServicesPage() {
setEditing(s);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
@@ -100,7 +100,7 @@ export function ServicesPage() {
onClick={() => {
if (confirm(`¿Eliminar "${s.name}"?`)) removeMut.mutate(s.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
+9 -5
View File
@@ -13,7 +13,9 @@ const VIEWPORTS = [
];
const PAGES = [
{ name: "login", path: "/", login: false },
// `/` ahora sirve la landing pública; el login vive en `/login`.
{ name: "landing", path: "/", login: false },
{ name: "login", path: "/login", login: false },
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
{ name: "calendar-week", path: "/calendar", login: true, role: "owner", waitFor: ".fc" },
{ name: "employees", path: "/employees", login: true, role: "owner" },
@@ -26,7 +28,9 @@ const PAGES = [
{ name: "emp-calendar", path: "/calendar", login: true, role: "employee", waitFor: ".fc" },
{ name: "admin-overview", path: "/admin", login: true, role: "admin" },
{ name: "admin-businesses", path: "/admin/businesses", login: true, role: "admin" },
{ name: "public-booking", path: "/b/lumiere-estetica-spa", login: false },
// El slug del negocio demo es "negocio" (businesses.slug); con el slug anterior
// la API devolvía 404 y esta página se auditaba en su estado de error.
{ name: "public-booking", path: "/b/negocio", login: false },
];
const results = [];
@@ -39,13 +43,13 @@ async function login(browser, viewport, role) {
if (m.type() === "error") consoleErrors.push({ where: "login", text: m.text() });
});
page.on("pageerror", (e) => pageErrors.push({ where: "login", text: e.message }));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await page.waitForTimeout(400);
const email =
role === "admin"
? "admin@agendapro.demo"
? "admin@agendamax.demo"
: role === "owner"
? "owner@agendapro.demo"
? "owner@agendamax.demo"
: "[email protected]";
// Fill the login form
await page.fill('input[type="email"]', email);
+14 -1
View File
@@ -31,7 +31,12 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
react: ["react", "react-dom", "react-router-dom"],
// Base vendor: se descarga en toda visita. `clsx` va aquí a propósito y
// no por afinidad temática: lo usan `lib/format.ts` y también recharts,
// así que sin fijarlo Rollup lo asigna al chunk `charts` y el chunk de
// entrada acaba importando 111 KB gzip de recharts para obtener una
// utilidad de 200 bytes. Eso lo pagaba incluso la landing.
react: ["react", "react-dom", "react-router-dom", "clsx"],
calendar: [
"@fullcalendar/core",
"@fullcalendar/react",
@@ -42,6 +47,14 @@ export default defineConfig({
charts: ["recharts"],
query: ["@tanstack/react-query"],
icons: ["lucide-react"],
// Solo las páginas públicas animan. Sin este chunk, Rollup mete
// framer-motion en el bundle compartido y sus ~40 KB gzip se cobran en
// cada carga del panel, que nunca lo usa.
//
// Se llama `framer` y no `motion` a propósito: `src/lib/motion.ts` genera
// su propio chunk compartido entre landing y login, y dos `motion-*.js`
// distintos en dist/ son una trampa al depurar.
framer: ["framer-motion"],
},
},
},