Compare commits
27
Commits
6b28b9f6c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0069d23744 | ||
|
|
4281567207 | ||
|
|
842a9ea07e | ||
|
|
4c19244df9 | ||
|
|
0b466f33f3 | ||
|
|
43f5d1374c | ||
|
|
a8becf07d6 | ||
|
|
b6dcad68fb | ||
|
|
9ded129478 | ||
|
|
0238dff5b1 | ||
|
|
65233f2e4d | ||
|
|
de947f9d35 | ||
|
|
9d2ab39d84 | ||
|
|
fe8b701e27 | ||
|
|
3e056a0dbf | ||
|
|
3f84371842 | ||
|
|
811b664405 | ||
|
|
50dea33728 | ||
|
|
6355d18a07 | ||
|
|
0e6195ce23 | ||
|
|
bec3e45573 | ||
|
|
d796538fd9 | ||
|
|
4227db0c8b | ||
|
|
5410e27c89 | ||
|
|
ed608656e0 | ||
|
|
811d2a24b2 | ||
|
|
9d78663cfe |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.dist
|
||||
*.md
|
||||
.opencode
|
||||
.superpowers
|
||||
graphify-out
|
||||
screenshots
|
||||
docs
|
||||
@@ -37,3 +37,6 @@ Thumbs.db
|
||||
|
||||
# OpenCode runtime state (machine-local: sessions, goals)
|
||||
.opencode/
|
||||
|
||||
# Grafo de conocimiento (regenerable con `graphify update`)
|
||||
graphify-out/
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,286 @@
|
||||
# SDD Progress Ledger — Auto-assign specialist + booking redesign
|
||||
|
||||
- **Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md`
|
||||
- **Spec:** `docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md`
|
||||
- **Branch:** `feat/auto-assign-specialist`
|
||||
- **Base commit:** `e8d2435`
|
||||
- **Commit policy:** NO commits during execution (repo policy: only on explicit request). Verify per task via typecheck + tests. Reviewer reads changed files + plan task.
|
||||
- **Pre-existing WIP (do NOT clobber):** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, FullCalendar section of `src/index.css` (~lines 136-160). Use targeted `edit` only.
|
||||
|
||||
## Tasks
|
||||
- Task 1: complete — db.ts (migrateV3ToV4 + runMigrations call), shared/types.ts (Business/Employee/WorkingHoursMap). Review: APPROVED, no findings. typecheck 0 errors, schema_version=4, backfill verified.
|
||||
- Task 2: complete — server/lib/scheduling.ts (pure helpers, no imports/DB), server/lib/scheduling.test.ts (15 tests), package.json test:unit. Review: APPROVED. test:unit 15/15. Minor (non-blocking): add direct test for hasConflict positive case, clamp01, parseWorkingHours malformed-shape, specialtyMatch substring branch — defer to final review.
|
||||
- Task 3: complete — appended DB fns to scheduling.ts (getCandidates, getExistingBusy, isAvailable, rankOne, pickBestSlotEmployee, autoAssign, runInTransaction). Plan typo `c.info.id`→`c.id` fixed & verified. Review: APPROVED. typecheck 0, test:unit 15/15, tx smoke ok. Low-note: getExistingBusy scopes by start_at within day (cross-midnight appts not caught — neutralized by working-hours guard; revisit if 24h scheduling added).
|
||||
- Task 4: complete — booking.ts publicBusiness (+auto_assign_specialist,working_hours) + slots endpoint uses real working_hours + pickBestSlotEmployee. POST /book untouched. Review: APPROVED. typecheck 0, slots smoke ok. Info-notes: local overlapsRange dup of overlaps (plan-permitted); per-slot SELECT name could be hoisted (pre-existing).
|
||||
- Task 5: complete — booking.ts POST /book rewritten: runInTransaction + autoAssign (when emp omitted or auto_assign_specialist=1) + isAvailable guard → 409 on conflict; 201 includes employee_id+reasons. Review: APPROVED. typecheck 0, test:unit 15/15, integration a)201 b)409 c)201-survivor d)409-autoAssign-null all pass. Sound tx guard.
|
||||
- Task 6: complete — appointments.ts POST / wrapped in runInTransaction, autoAssign fallback (replaced LIMIT 1), isAvailable guard → 409. Two plan-text deviations (move emp resolution inside tx; Number(business_id)) verified correct. Review: APPROVED. typecheck 0, test:unit 15/15, conflict smoke 409/201 pass. FOLLOW-UP: e2e-test.mjs fixtures (2026-09-15T11:00Z=05:00 local, 2026-09-20 Sun) now fail under working-hours enforcement — fixture-side, will fix in Task 7.
|
||||
- Task 7: complete — settings.ts (+auto_assign_specialist,working_hours, JSON/0-1 coercion), employees.ts (+specialties/working_hours/efficiency_score), booking-e2e.mjs (new), e2e-test.mjs slot-aware fixture fix (no guards weakened). Review: APPROVED. typecheck 0, test:unit 15/15, test:e2e 25/25, test:booking 9/9.
|
||||
|
||||
== BACKEND COMPLETE (Tasks 1-7). Algorithm + guards + APIs all verified green. ==
|
||||
- Task 8: complete — index.css wrapped .input/.select/.textarea in @layer components; BookingPage DetailsForm 3× pl-9→pl-10. Verified directly by controller (build green, @layer confirmed at index.css:272, WIP untouched). Icon/placeholder overlap fixed app-wide.
|
||||
- Task 9: complete — publicApi.ts types (PublicBusiness.auto_assign_specialist; BookPayload.employee_id optional; BookResponse +employee_id/reasons). Verified directly (typecheck 0, no consumer breakage).
|
||||
- Task 10: complete — src/components/MonthCalendar.tsx (42-cell month grid, nav bounded by min/max, brand selected, accent avail dot, brand today dot). Verified directly (typecheck 0, build green). Will be integration-tested in Task 11.
|
||||
- Task 11: complete — BookingPage DateTimePicker rewritten: md+ 2-col (MonthCalendar left + grouped slots right), SlotGroup Mañana/Tarde, mobile stacked, slot button styles unchanged, max optional. Review: APPROVED. typecheck 0, build ok. AM/PM noon boundary verified.
|
||||
- Task 12: complete — BookingPage wizard rewired to dynamic 3/4 steps via stepsFor(autoAssign)+currentOriginal; main max-w-5xl only on Horario; handleBook sends employee_id undefined when autoAssign; Confirmation shows reasons badges. Review: APPROVED. typecheck 0, build ok, HTTP smoke both flows + 409. NOTE: `npm run lint` can't run repo-wide (pre-existing ESLint9 flat-config gap, no eslint.config.js) — pre-existing, will report at Task 15.
|
||||
- Task 13: complete — src/lib/workingHours.ts (DEFAULT_WH/DAYS/parseWh) + SettingsPage auto-assign checkbox + 7-day working-hours editor. Review: APPROVED. typecheck 0, build ok, runtime e2e (PATCH→GET persist, Sat 10-14 slots appear, toggle 3/4 steps).
|
||||
- Task 14: complete — EmployeesPage EmployeeModal +specialties chips +efficiency slider +working-hours editor (inherit toggle). Payload specialties[]/efficiency Number/working_hours null|map. Review: APPROVED. typecheck 0, build ok. Low-notes: all-closed employee flips back to inherit (benign); case-sensitive dedup (acceptable). Runtime persistence smoke deferred to Task 15.
|
||||
|
||||
== ALL 14 IMPLEMENTATION TASKS COMPLETE + REVIEWED. ==
|
||||
|
||||
== TASK 15: FINAL VERIFICATION ==
|
||||
- typecheck: 0 errors
|
||||
- test:unit: 15/15
|
||||
- build: OK (2462 modules)
|
||||
- test:e2e: 25/25
|
||||
- test:booking: 9/9 (auto-assign 201+reasons, double-book 409, auto_assign_specialist exposed)
|
||||
- employee persistence runtime check (Task 14 deferred): specialties/efficiency=87/working_hours custom→null(inherit) all persist ✓
|
||||
- lint: PRE-EXISTING repo-wide failure (no eslint.config.js, ESLint 9.39 requires flat config) — NOT introduced by this feature.
|
||||
- FINAL WHOLE-BRANCH REVIEW: READY TO MERGE. Core guarantee (no double-booking from public flow) verified end-to-end (sync handler + BEGIN IMMEDIATE + hasConflict + same-tx INSERT). No cross-task integration issues.
|
||||
|
||||
== POST-MERGE FOLLOW-UPS (non-blocking, deferred) ==
|
||||
1. getExistingBusy: switch WHERE to overlap-based (end_at > dayStart AND start_at < dayEnd) — closes cross-midnight gap if 24h scheduling added.
|
||||
2. Add 4 unit-test cases (hasConflict positive, clamp01, parseWh malformed, specialtyMatch substring).
|
||||
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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Task 1 Implementation Report
|
||||
|
||||
## 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
|
||||
|
||||
- `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.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- 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
|
||||
|
||||
- `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.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task 10 Report — Componente `MonthCalendar`
|
||||
|
||||
## Status
|
||||
✅ DONE
|
||||
|
||||
## Files changed
|
||||
- **Created:** `src/components/MonthCalendar.tsx` (verbatim from plan, Task 10 Step 1).
|
||||
|
||||
No other files modified.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Command | Result |
|
||||
|---|---|---|
|
||||
| Typecheck | `npm run typecheck` | ✅ 0 errors |
|
||||
| Build | `npm run build` | ✅ Success (`vite v5.4.21`, `built in 5.40s`) |
|
||||
|
||||
## Self-review
|
||||
|
||||
**Props signature:** `MonthCalendar({ value, min, max, availableDates, onSelect })` — matches spec exactly.
|
||||
- `value: string` (ISO YYYY-MM-DD)
|
||||
- `min?: string`, `max?: string` (ISO bounds, optional)
|
||||
- `availableDates?: Set<string>` (dates with open slots)
|
||||
- `onSelect: (iso: string) => void`
|
||||
|
||||
**6-row / 42-cell grid:** ✅ Yes. `cells` is computed via `useMemo` with a fixed `for (let i = 0; i < 42; i++)` loop, starting from the Monday on/before the 1st of the cursor month (`lead = (first.getDay() + 6) % 7`). Always renders 42 buttons in a `grid-cols-7` → 6 rows × 7 cols.
|
||||
|
||||
**Past days disabled relative to `min`:** ✅ Yes. `selectable(d)` compares `atMidnight(d)` against `minDate` (also normalized to midnight) — strictly less → `false` → button `disabled`. Out-of-month past cells inherit the disabled state too.
|
||||
|
||||
**Selecting a day calls `onSelect` with ISO YYYY-MM-DD:** ✅ Yes. `onClick={() => onSelect(dIso)}` where `dIso = toIso(d)` returns `${yyyy}-${mm}-${dd}` zero-padded.
|
||||
|
||||
**Accent dot shows for `availableDates`:** ✅ Yes. When `availableDates?.has(dIso)` and the day isn't selected, an absolutely-positioned `bg-accent-500` 1.5×1.5 dot is rendered at `bottom-1`.
|
||||
|
||||
**Today dot (`brand-400`) under control:** ✅ Bonus check — when `dIso === todayIso` and not selected, a `bg-brand-400` dot is rendered at `top-1` (distinct position from the accent dot to avoid overlap).
|
||||
|
||||
**Selected-day highlight (`brand-500`):** ✅ Yes. When `dIso === value`, classes include `bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30`.
|
||||
|
||||
**Navigation bounded by min/max:** ✅ Yes.
|
||||
- `canPrev = !minDate || <last day of previous month> >= firstOfMonth` — disables ‹ when the entire previous month is before `min`.
|
||||
- `canNext = !maxDate || <last day of next+1 month> <= <last day of max month>` — disables › when the entire next month is beyond `max`.
|
||||
- The disabled attribute and `disabled:opacity-40` style visually communicate the bound.
|
||||
|
||||
**Weekend tint:** ✅ Yes. Sáb/Dom (`getDay() === 0 || 6`) in-month, non-selected, selectable cells get `text-slate-500` (vs `text-slate-700` for weekdays).
|
||||
|
||||
**Touch targets ≥44px:** ✅ Plausible. Each cell uses `aspect-square` inside a 7-col grid with `gap-1`; on the intended two-column desktop layout (parent column ~`minmax(0,1fr)` of `max-w-5xl`) cells exceed 44px. On narrow mobile (full width) they're well above 44px. The ‹ › nav buttons are `h-9 w-9` (36px) — slightly under 44px, but they're secondary controls; main interaction is the date grid. Flagging as a minor concern.
|
||||
|
||||
**Client-only / SSR-safe:** ✅ Yes. Uses `new Date()` at render and inside `useState`/`useMemo` initializers — fine for a Vite SPA (no SSR).
|
||||
|
||||
**Named export:** ✅ Yes. `export function MonthCalendar(...)` — a named export (the task brief says "default-style named export", which is what we have; consumer in Task 11 imports it as `import { MonthCalendar } from "../../components/MonthCalendar";`).
|
||||
|
||||
## Concerns
|
||||
1. **Nav buttons 36px (`h-9 w-9`):** slightly below the 44px touch-target guideline. Acceptable since they're secondary controls and the spec/plan code is verbatim; calling out for awareness only. No change made (would deviate from plan).
|
||||
2. **`canNext` bound check is conservative:** it compares full-month ranges, so when `max` falls mid-month the › button stays enabled for that partial month (correct — user can still navigate to it) and only disables once the *entire* next month is past `max`. Behavior matches the plan's intent.
|
||||
3. **No keyboard arrow navigation (Home/End/PgUp/PgDn/Arrows):** out of scope for the plan; the buttons are real `<button>`s so Tab+Enter works, and disabled days are correctly removed from the tab order via the `disabled` attribute.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Task 11 — `DateTimePicker` 2-col + month calendar + Mañana/Tarde grouping
|
||||
|
||||
## File changed
|
||||
- `src/pages/public/BookingPage.tsx`
|
||||
- Added `import { MonthCalendar } from "../../components/MonthCalendar";` (line 28).
|
||||
- Rewrote `DateTimePicker` (now lines 556–639): 2-column grid on `md+`, vertical stack below `md`, new optional `max?: string` prop, slots grouped via new `SlotGroup` subcomponent.
|
||||
- Added new `SlotGroup` helper (lines 641–681).
|
||||
|
||||
## Verification results
|
||||
|
||||
### `npm run typecheck`
|
||||
```
|
||||
> tsc -b --noEmit
|
||||
```
|
||||
**0 errors.** The existing caller of `DateTimePicker` (in `BookingPage`) does not pass `max` — confirms `max` is optional and not breaking the caller.
|
||||
|
||||
### `npm run build`
|
||||
```
|
||||
✓ 2461 modules transformed.
|
||||
✓ built in 4.98s
|
||||
```
|
||||
**Succeeds.** Production bundle emitted.
|
||||
|
||||
### Visual smoke
|
||||
Not executed interactively (subagent has no browser). Validated indirectly via successful `vite build` (2461 modules transformed, including the rewritten `BookingPage` and the imported `MonthCalendar`). Task 12 is responsible for wiring `max`/`employeeId` end-to-end and for any final interactive smoke; the unit of work in this task compiles and type-checks cleanly against the rest of the project.
|
||||
|
||||
## Self-review
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 2-col layout only on `md+` | ✅ Outer wrapper uses `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8`. Below `md` no grid is applied. |
|
||||
| Mobile stacked vertically | ✅ Right column wrapper has `mt-4 md:mt-0` so on mobile the calendar card is followed by the slots card with vertical spacing; the outer div is a plain block (no `flex`/`grid`) below `md`. |
|
||||
| Slot button markup unchanged | ✅ Identical classes (`grid grid-cols-3 gap-2 sm:grid-cols-4`; button `rounded-xl border px-2 py-2.5 text-sm font-bold transition-all`; active `border-brand-500 bg-brand-500 text-white shadow-soft`; inactive `border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50`), same `key={`${slot.iso}-${slot.employee_id}`}`, same `onClick={() => onPick(slot)}`. Only the grouping wrapper (`SlotGroup`) is new. |
|
||||
| Morning/afternoon split correct, incl. 12 PM boundary | ✅ Algorithm: parse `(\d{1,2}):(\d{2})` → `h`; `pm = /PM/i`; `hour24 = pm && h!==12 ? h+12 : !pm && h===12 ? 0 : h`; bucket by `hour24 < 12`. Verified cases: `12:00 AM` → h=12, !pm, h===12 → 0 → **Mañana**. `11:30 AM` → 11 → **Mañana**. `12:00 PM` → h=12, pm, h===12 (no +12) → 12 → **Tarde**. `12:30 PM` → 12 → **Tarde**. `1:00 PM` → 13 → **Tarde**. `11:59 PM` → 23 → **Tarde**. Boundary at noon is correct (12:00 PM is the first Tarde slot). |
|
||||
| `max` is optional | ✅ Declared `max?: string`. Typecheck with caller that omits `max` passes. |
|
||||
| No commit performed | ✅ No `git commit` run. |
|
||||
| Scope respected (only `BookingPage.tsx`, only `DateTimePicker` + import + `SlotGroup`) | ✅ No other functions touched (`TopBar`, `ActionBar`, `Confirmation`, `EmployeeList`, `ServiceGrid`, `DetailsForm` unchanged; Task 8's `pl-10` untouched). |
|
||||
|
||||
## Concerns
|
||||
- None. Implementation matches the plan verbatim. Interactive browser smoke deferred to the orchestrator / Task 12 (which will also pass `max` and `employeeId` to the picker and exercise the full wizard).
|
||||
@@ -0,0 +1,63 @@
|
||||
# Task 12 — Wizard 3/4 pasos + Confirmation con reasons + anclar main ancho
|
||||
|
||||
## File changed
|
||||
- `src/pages/public/BookingPage.tsx` (only file touched)
|
||||
|
||||
## Steps applied (1–9)
|
||||
1. Replaced static `STEPS` with `FULL_STEPS` const + `stepsFor(autoAssign)` helper that drops the "Especialista" step when auto-assign is on (returns `{n, label, original}` items).
|
||||
2. Added `maxBookableDate()` helper next to `todayStr` (today + 3 months, ISO `YYYY-MM-DD`).
|
||||
3. In `BookingPage`, computed `autoAssign`, `steps` (useMemo on autoAssign), `stepCount`, and `currentOriginal` (find by `n === step`, read `.original`) **before** `slotsQuery` so the closure can see it.
|
||||
4. `slotsQuery.enabled` now uses `currentOriginal === 3` (was `step >= 3`).
|
||||
5. `goNext` / `goBack` / `pickSlot` use `stepCount` (no hard-coded `4`).
|
||||
6. `canContinue`, `primaryLabel`, `onPrimary` switch on `currentOriginal`. `handleBook` sends `employee_id: autoAssign ? undefined : selectedSlot.employee_id`.
|
||||
7. Rewrote the main `return (...)` to render sections by `currentOriginal`; `main` widens to `max-w-5xl` only when `currentOriginal === 3`, otherwise `max-w-2xl`; `DateTimePicker` gets `max={maxBookableDate()}`; the Especialista row in Datos is hidden when `autoAssign`.
|
||||
8. Updated `TopBar` + `ActionBar` signatures to accept `stepCount` + `steps`; both widened from `max-w-3xl`/`max-w-2xl` → `max-w-5xl`. TopBar now reads "Paso X de {stepCount}" and iterates the dynamic `steps` array (including the mobile `X / {stepCount}` chip).
|
||||
9. `Confirmation` renders `appointment.reasons` as `brand` badges (with Sparkles icon) guarded by `reasons && reasons.length > 0`, placed under the Especialista row.
|
||||
|
||||
Also updated the two secondary `TopBar` call sites (disabled-booking screen and `Confirmation`) to pass `stepCount={4} steps={[]}` — `step={0}` hides the indicator block, so an empty array is safe there.
|
||||
|
||||
## Verification
|
||||
|
||||
### typecheck
|
||||
```
|
||||
> tsc -b --noEmit
|
||||
(no output — 0 errors)
|
||||
```
|
||||
First pass surfaced one error: `ActionBar`'s `stepCount` param was unused (TS6133). Fixed by surfacing it in the empty-state label: `Paso {step} de {stepCount} · Continúa para reservar`.
|
||||
|
||||
### build
|
||||
```
|
||||
> tsc -b && vite build
|
||||
✓ 2461 modules transformed.
|
||||
✓ built in 5.16s
|
||||
```
|
||||
|
||||
### lint
|
||||
```
|
||||
> eslint . --ext ts,tsx
|
||||
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
|
||||
```
|
||||
Pre-existing env issue (ESLint 9 with no flat config in the repo). Not introduced by this task; no `react-hooks/exhaustive-deps` warnings would be reachable until the config is repaired. The `useMemo(() => stepsFor(autoAssign), [autoAssign])` dep array is correct.
|
||||
|
||||
### Runtime smoke (server already running on :5173)
|
||||
- Slug: `lumiere-estetica-spa`. Default `auto_assign_specialist=0`.
|
||||
- **OFF flow**: GET `/api/public/<slug>` → `auto_assign_specialist=0`. Slots for service 6 tomorrow returned 10 slots. POST `/book` without `employee_id` → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`, `client_created=true`. (Server still auto-ranked because the client didn't pick one — that's the unchanged 4-step path.)
|
||||
- **ON flow**: PATCH `/api/settings {auto_assign_specialist:1}`. GET public → `auto_assign_specialist=1` (correctly exposed). POST `/book` without `employee_id` at a free slot → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`.
|
||||
- Double-booking guard: re-POSTing the first booked slot → 409 `Esa hora acaba de ocuparse, elige otra.` ✓
|
||||
- Restored `auto_assign_specialist=0` afterwards.
|
||||
|
||||
Visual confirmation of step count / layout width was done by code inspection (no headless browser available in this subagent): `stepsFor(false)` yields 4 items; `stepsFor(true)` yields 3 items (Especialista filtered). The TopBar "Paso X de {stepCount}" and the mobile "X / {stepCount}" both read from the dynamic count, so 4 vs 3 follows automatically. `main`'s width is selected by `currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"`, which is true only on the Horario section regardless of the 3- or 4-step mapping.
|
||||
|
||||
## Deviations
|
||||
- `ActionBar`'s previously-unused `stepCount` prop (the spec required ActionBar to accept it, but the plan's literal snippet didn't read it) is now surfaced in the empty-state copy. This both satisfies TS6133 and improves UX consistency.
|
||||
- No commit performed (per task constraints).
|
||||
|
||||
## Self-review
|
||||
- **OFF = 4 steps?** Yes — `stepsFor(false)` maps all 4 `FULL_STEPS` to `{1:Servicio, 2:Especialista, 3:Horario, 4:Datos}` with `original` matching `n`. `currentOriginal` drives section rendering, so the 4-step Servicio→Especialista→Horario→Datos flow is preserved exactly. The "Cualquiera" path still hits the server ranking (server-side, unchanged here).
|
||||
- **ON = 3 steps?** Yes — `stepsFor(true)` filters out Especialista, renumbering to `{1:Servicio (orig 1), 2:Horario (orig 3), 3:Datos (orig 4)}`. The `currentOriginal === 2` branch (Especialista) never matches because no step maps to original 2.
|
||||
- **currentOriginal before slotsQuery?** Yes — declared at line 83, slotsQuery at line 90 references it in `enabled` at line 93.
|
||||
- **handleBook sends undefined employee_id when autoAssign?** Yes — line 198 `employee_id: autoAssign ? undefined : selectedSlot.employee_id`. `BookPayload.employee_id` is optional, so this typechecks.
|
||||
- **Confirmation shows reasons badges?** Yes — guarded by `appointment.reasons && appointment.reasons.length > 0`, renders `<span>` chips with Sparkles icon under the Especialista row.
|
||||
- **main widened only on Horario?** Yes — `cn("…", currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl")`. Horario is always original 3 in both flows.
|
||||
- **Disabled-booking screen, loading/error states, reset() preserved?** Yes — only the TopBar call in the disabled screen was updated to pass the new required props; the rest of that branch is untouched. Loading/error branches and `reset()` are unchanged.
|
||||
- **No git commit.** Confirmed — only file edits + npm runs.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 13 Report — SettingsPage (auto-assign + working hours editor)
|
||||
|
||||
## Files changed
|
||||
1. **CREATED** `src/lib/workingHours.ts` — exports `DEFAULT_WH`, `DAYS`, `parseWh`. Pure data/helpers, no React/UI.
|
||||
2. **MODIFIED** `src/pages/SettingsPage.tsx`:
|
||||
- Added `Wand2`, `Clock` to the lucide-react imports.
|
||||
- Imported `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`.
|
||||
- Added local `wh` state (`useState<Record<number, {start,end}|null>>(DEFAULT_WH)`).
|
||||
- Replaced the `useEffect` so it now also calls `setWh(parseWh(data.settings.working_hours))`.
|
||||
- Extended the PATCH `api.settings.update` payload with `auto_assign_specialist` (0/1) and `working_hours: wh` (object).
|
||||
- Added, inside the "Reservas online" card (after the URL/slug block): a "Asignar especialista automáticamente" checkbox (Wand2 icon) and a "Horario del negocio" block rendering `<WorkingHoursEditor value={wh} onChange={setWh} />`.
|
||||
- Added a local `WorkingHoursEditor` subcomponent (per-day on/off + start/end `<input type="time">`, "Cerrado" label when off).
|
||||
|
||||
## Typecheck / build
|
||||
- `npm run typecheck` → 0 errors.
|
||||
- `npm run build` → succeeded (vite build OK, all chunks emitted).
|
||||
|
||||
## Runtime smoke
|
||||
- **Not executed in this subagent session** (subagent has no browser; dev server would need to be started manually by the orchestrator). The plan's Task 13 Step 4 lists this as the runtime check:
|
||||
- Boot `npm run dev`, login owner → `/settings`.
|
||||
- Toggle the auto-assign checkbox; edit working hours (e.g. Saturday on 10:00–14:00); Save; reload.
|
||||
- Expect: GET `/settings` reflects `auto_assign_specialist=1` and `working_hours='{"1":{"start":"09:00","end":"20:00"},...,6:{...},7:null}'` (or whatever was entered).
|
||||
- Expect on `/b/<slug>`: wizard collapses to 3 steps (Servicio/Horario/Datos) when toggle is on, and the slot list is constrained to the configured working hours (e.g. Saturday shows only 10:00–14:00 slots).
|
||||
- **Backend prerequisites (must already be in place for persistence to work):** Task 7 must have run — `FIELDS` whitelist in `server/routes/settings.ts` includes `auto_assign_specialist` and `working_hours`, both SELECTs return them, and the PATCH handler serializes the object → JSON string and coerces the boolean to 0/1. If Task 7 has NOT been applied yet, the PATCH will silently drop both fields (whitelist) and persistence will not occur.
|
||||
|
||||
## Deviations
|
||||
- **Improvement applied (per task spec):** `DEFAULT_WH`/`DAYS`/`parseWh` live in `src/lib/workingHours.ts` from the start, imported by SettingsPage — instead of being defined inline here and refactored out in Task 14. Task 14 (EmployeeModal) will import the same helpers.
|
||||
- No other deviations from plan Task 13. `WorkingHoursEditor` stays a local component inside SettingsPage (not exported from `workingHours.ts`).
|
||||
|
||||
## Self-review
|
||||
- **Does the PATCH send `working_hours` as an object?** Yes — `working_hours: wh` where `wh` is a `Record<number, {start,end}|null>`. Backend (Task 7 Step 1) is responsible for `JSON.stringify`-ing it before SQL. Since `api.settings.update` is typed `any`, TS does not complain.
|
||||
- **Does `parseWh` handle the JSON-string returned by GET?** Yes — when `v` is a string it `JSON.parse`s it (catching syntax errors → null), and when null/non-object it falls back to `{...DEFAULT_WH}`. It also tolerates partial maps (missing day keys → `null`).
|
||||
- **Does the editor toggle per day?** Yes — `toggle(n)` flips `value[n]` between `null` and `{start:"09:00", end:"18:00"}`; when off, the day row shows "Cerrado".
|
||||
- **First-load safety:** `wh` initialises from `DEFAULT_WH`, so even before the GET resolves the editor renders sane values (Lun–Vie 09–20, Sáb/Dom closed) rather than crashing on `undefined`.
|
||||
- **Scope discipline:** only `src/lib/workingHours.ts` (new) and `src/pages/SettingsPage.tsx` were touched. `EmployeesPage.tsx`, `BookingPage.tsx`, CSS, and any backend files were left alone.
|
||||
- **No commit performed** (per CRITICAL constraint #1).
|
||||
|
||||
## Concerns / handoff notes for orchestrator
|
||||
1. **Backend coupling:** this task is only the UI half. Persistence depends on Task 7 having extended `FIELDS` + SELECTs + the serialization block in `server/routes/settings.ts`. Verify Task 7 landed before doing the runtime smoke.
|
||||
2. **`auto_assign_specialist` initial value from GET:** backend returns it as 0/1 (INTEGER). The checkbox does `!!f.auto_assign_specialist` which coerces correctly. PATCH sends `f.auto_assign_specialist ? 1 : 0`, also correct.
|
||||
3. **Visual check:** the new "auto-assign" checkbox + working-hours block sit inside the existing `space-y-4 p-5 pt-0` container of the "Reservas online" card, so vertical rhythm is preserved without further CSS work.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Task 14 — `EmployeeModal`: especialidades, horario, eficiencia
|
||||
|
||||
## File changed
|
||||
- `src/pages/EmployeesPage.tsx` — extended `EmployeeModal` with three new controls (specialties chips, efficiency range, working hours editor with inherit toggle); added import of `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`; extended `useEffect` sync and mutation payload.
|
||||
|
||||
No other files touched. `src/lib/workingHours.ts` was already created in Task 13 and is only imported.
|
||||
|
||||
## Typecheck / build
|
||||
- `npm run typecheck` → **0 errors**.
|
||||
- `npm run build` → **succeeds** (2462 modules transformed, 5.11s).
|
||||
- `npm run lint` → fails with a pre-existing repo-level config error (`ESLint couldn't find an eslint.config.(js|mjs|cjs)` — ESLint 9 migration not done). Not caused by this task; same result on `main`.
|
||||
|
||||
## Runtime smoke
|
||||
Not executed manually (no browser session in this subagent run). Static verification instead:
|
||||
- Backend wiring confirmed in `server/routes/employees.ts`:
|
||||
- POST accepts `specialties`, `working_hours`, `efficiency_score` (line 76).
|
||||
- PATCH handles `working_hours === null` → stored as `null` (line 119) — exactly the "inherit from business" semantics the UI sends.
|
||||
- `efficiency_score` coerced via `Number(...)` server-side; `specialties` JSON-stringified if array.
|
||||
- GET `/employees` selects these columns (Task 7), so reopening the modal will prefill via the `useEffect` sync branch.
|
||||
- The `Employee` type in `shared/types.ts` declares all three fields optional (lines 53–55), so the prefill reads (`employee.specialties`, `employee.efficiency_score`, `employee.working_hours`) typecheck cleanly.
|
||||
|
||||
Recommended manual follow-up (per task spec): boot `npm run dev`, owner → `/employees`, edit an employee — add "Corte" + "Coloración", set efficiency 85, uncheck inherit and set custom hours, save, reopen to confirm persistence; toggle inherit back on, save, confirm `working_hours` column is `null` in DB.
|
||||
|
||||
## Deviations
|
||||
- Plan Step 1 said to *create* `src/lib/workingHours.ts` and refactor SettingsPage. Per the task brief, the file already exists (Task 13) and SettingsPage already imports from it — so I only added the import to `EmployeesPage.tsx`. No other files modified.
|
||||
- Lint not run green (pre-existing repo config issue, not introduced here).
|
||||
|
||||
## Self-review
|
||||
- **Payload shape:**
|
||||
- `specialties` sent as a `string[]` (the state is `string[]`, no manual serialization). ✓
|
||||
- `efficiency_score` sent as `number` (`efficiency` is `number`, initialized via `Number(e.target.value)`). ✓
|
||||
- `working_hours` sent as `null` when `inheritHours === true`, otherwise the live `wh` map. ✓ Matches the backend's `working_hours === null ? null : …` branch.
|
||||
- **Edit-mode prefill:** `useEffect[open, employee]` reads `employee.specialties` (with `Array.isArray` guard), `employee.efficiency_score` (with `typeof === "number"` guard, default 50), and `parseWh(employee.working_hours)` plus a `hasOwn` check so a stored `null`/empty map flips `inheritHours` back to `true` and resets `wh` to `DEFAULT_WH`. ✓
|
||||
- **Duplicate prevention in chips input:** `setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])])` — `Set` dedupes by value; case-sensitive (so "Corte" ≠ "corte"), which matches how the backend stores them. ✓
|
||||
- **Reset on close/new:** the `else` branch of the `useEffect` clears `specialties`, `specialtyInput`, `efficiency`, `inheritHours`, and `wh` — no stale state leaks between create/edit sessions. ✓
|
||||
- **Type safety on `wh[n]`:** narrowed with `!(wh[n])` / `wh[n]!.start` and an explicit type cast on the spread (`as { start: string; end: string }`) to avoid `null`-widening complaints — typecheck confirms.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 2 Implementation Report
|
||||
|
||||
## Scope
|
||||
|
||||
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.cmd run typecheck`: exit 0.
|
||||
|
||||
```text
|
||||
> [email protected] typecheck
|
||||
> tsc -b --noEmit
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Task 3 Report: Add Install Detection and UI
|
||||
|
||||
## Status
|
||||
|
||||
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.
|
||||
|
||||
## Changed files
|
||||
|
||||
- `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.
|
||||
|
||||
## 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 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
|
||||
|
||||
- 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.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Task 4 Report
|
||||
|
||||
## Files
|
||||
|
||||
- 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
|
||||
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
- 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 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.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task 5 Report — backend `booking.ts` `POST /book` con autoAssign + guard transaccional
|
||||
|
||||
## Status
|
||||
**DONE** — all verification green; no commit (per repo policy + dispatch constraint).
|
||||
|
||||
## Files changed
|
||||
- `server/routes/booking.ts` — only the `POST /:slug/book` handler rewritten + 3 symbols added to the existing scheduling import. **No other file touched by this task.**
|
||||
|
||||
## What changed
|
||||
|
||||
### Import (line 3-7)
|
||||
Added `autoAssign, isAvailable, runInTransaction` to the existing scheduling import list (Task 4 had already imported `parseWorkingHours`, `getWorkingHoursForDate`, `getCandidates`, `getExistingBusy`, `pickBestSlotEmployee`, `WorkingHoursMap`).
|
||||
|
||||
### `POST /:slug/book` handler (lines 125-214)
|
||||
Replaced the old handler (which did a naive `SELECT … LIMIT 1` to pick any employee and had no conflict guard) with the transactional, auto-assigning version per the plan verbatim:
|
||||
|
||||
1. **Resolve specialist inside `runInTransaction(db, …)`** (BEGIN IMMEDIATE … COMMIT/ROLLBACK):
|
||||
- `autoAssignOn = !!biz.auto_assign_specialist`
|
||||
- `clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null`
|
||||
- If `!clientChose` → call `autoAssign(db, ctx)` with `bizWh = parseWorkingHours(biz.working_hours)`. If it returns null (no candidate free) → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`.
|
||||
- If `clientChose` → validate the employee offers the service (`employee_services`), then `isAvailable(db, empId, startMs, endMs)` guard; on conflict → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`. `reasons = ["Tu especialista elegido"]`.
|
||||
2. **Client resolution preserved verbatim**: lookup by `phone` (priority) else `email`, else `INSERT … tags='Online' RETURNING id`.
|
||||
3. **INSERT appointments** uses `new Date(endMs/startMs).toISOString().replace(/\.\d{3}Z$/, "Z")` for both ISOs (matches existing convention; confirmed in smoke response `2026-07-27T15:00:00Z`).
|
||||
4. **201 response** now includes `appointment.employee_id` (number) and `appointment.reasons: string[]` alongside the previous fields.
|
||||
5. **catch block**: if `e && typeof e === "object" && e.status` → `res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error })`; otherwise `throw e` (rethrow genuine errors → handled by Express error middleware).
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. `npm run typecheck` → **0 errors** ✓
|
||||
|
||||
### 2. Integration smoke (temp `.mjs`, deleted after)
|
||||
Booted a **fresh one-shot `tsx server/index.ts`** on `PORT=3007 HOST=127.0.0.1` (did NOT touch the user's existing dev server on port 3000, which is a non-watch `tsx server/index.ts` running stale code). Login `[email protected]`/`demo1234` → slug from `/api/settings` → first service from `/api/public/<slug>`.
|
||||
|
||||
- **(a) auto-assign (no `employee_id`):**
|
||||
`POST /api/public/<slug>/book { service_id, start_at:<iso>, client:{name:"Auto Test", phone:"+525500009991"} }`
|
||||
→ **201**, body:
|
||||
```json
|
||||
{"appointment":{"id":442,"start_at":"2026-07-27T15:00:00Z","end_at":"2026-07-27T15:45:00Z","price":280,"service_name":"Corte + arreglo de barba","employee_id":2,"employee_name":"Mateo Herrera","reasons":["Buena disponibilidad"]},"business":{"name":"Lumière Estética & Spa","currency_symbol":"$"},"client_created":true}
|
||||
```
|
||||
✓ `employee_id` is a real number (2). ✓ `reasons` is a non-empty array (["Buena disponibilidad"]).
|
||||
- **(b) double-booking guard (same slot + same `employee_id=2`):**
|
||||
`POST /api/public/<slug>/book { service_id, employee_id:2, start_at:<same iso>, client:{name:"Auto Test 2", phone:"+525500009992"} }`
|
||||
→ **409**, body: `{"error":"Esa hora acaba de ocuparse, elige otra."}` ✓ friendly message present.
|
||||
|
||||
The slots pre-fetch used `GET /api/public/<slug>/slots?service_id=X&date=2026-07-27` → returned a ranked slot (iso `2026-07-27T15:00:00.000Z`, pre-assigned emp=2), confirming the slots endpoint from Task 4 still works.
|
||||
|
||||
### 3. `npm run test:unit` → **15/15 PASS** ✓ (no regression)
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Is the whole booking + guard inside the transaction?** Yes. `runInTransaction(db, () => { … })` wraps: specialist resolution (autoAssign OR employee-services check OR isAvailable guard), client resolution, and the `INSERT INTO appointments`. Any thrown `{status,error}` triggers ROLLBACK before the catch translates it to HTTP.
|
||||
- **Does autoAssign run when `employee_id` is omitted?** Yes. `clientChose` is null when `employee_id` is absent (or when `auto_assign_specialist` is on, regardless of `employee_id`), and the `if (!empId)` branch calls `autoAssign(db, ctx)`. Verified by smoke (a): no `employee_id` sent → got back `employee_id=2` picked by the algorithm.
|
||||
- **Does the 409 path work?** Yes — verified two ways: (i) `autoAssign` returning null yields 409 (the "no specialist available" branch), and (ii) the `isAvailable` guard yields 409 on a concrete double-book (smoke b returned exactly 409 with the friendly message, and the ROLLBACK prevented the second INSERT).
|
||||
- **Does a client-chosen employee still get isAvailable-guarded?** Yes. When `auto_assign_specialist` is off and `employee_id` is provided, the `else` branch runs: employee_services validation, then `if (!isAvailable(db, empId, startMs, endMs)) throw {status:409,…}`. Smoke (b) exercised exactly this path (client chose emp=2, slot was already taken by smoke a → 409).
|
||||
- **Constraint: `auto_assign_specialist=1` overrides client choice.** Yes — `clientChose = !autoAssignOn && employee_id ? … : null` is null when `autoAssignOn`, so even an explicit `employee_id` is ignored and autoAssign runs. (Not exercised live in the smoke because the seed has `auto_assign_specialist=0`; the logic is straight from the plan.)
|
||||
- **Preserved client-resolution logic?** Yes — identical phone-then-email lookup with `tags='Online'` on create.
|
||||
- **ISO format?** Yes — `.replace(/\.\d{3}Z$/, "Z")` applied to both `startIso` and `endIso`; confirmed in the 201 response (`...T15:00:00Z`, no millis).
|
||||
- **Scope discipline?** Yes — only `POST /:slug/book` and the import list touched. Slots endpoint, `publicBusiness`, `publicBusiness`-exposing `GET /:slug`, and `overlapsRange` helper are untouched.
|
||||
- **No commit made?** Confirmed — `git log` shows latest commit is still `e8d2435` (v1.0 base); all work is in the working tree.
|
||||
|
||||
## Deviations
|
||||
None. The handler is transcribed verbatim from the plan (lines 776-866 of the plan). The import addition merges into the existing multi-line import rather than adding a second `import { autoAssign, isAvailable, runInTransaction } …` line (the plan's Step 1 phrasing is "Añadir a la lista de imports de Task 4" — i.e. extend the existing list — which is what I did; a duplicate import statement would have been a TypeScript error).
|
||||
|
||||
## Concerns
|
||||
- **Dev-server caveat (informational, not a defect):** the user's currently running dev server on port 3000 (PID 15992) is `tsx server/index.ts` (NOT `tsx watch`), so it has NOT picked up my booking.ts change. It will need a restart to serve the new code. I ran my smoke against a separate one-shot instance on port 3007 (now killed) to verify without disturbing the user's process. All my temp processes and log files have been cleaned up; port 3000 still belongs to the user's original process.
|
||||
- **DB side-effect:** the smoke test inserted 2 appointments (ids ~442) and 2 clients into the dev SQLite DB. This is expected per the plan's verification steps (the plan's own e2e script and seed note acknowledge this). A `RESET_DB=1 npm run seed` will reset if a clean DB is desired.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Task 6 — backend `appointments.ts` conflict guard + autoAssign fallback
|
||||
|
||||
## Files changed
|
||||
- `server/routes/appointments.ts`
|
||||
- Added import: `import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";`
|
||||
- Rewrote the `POST /` handler tail (from the old `// Resolve employee` comment through the handler's closing `})`) so that:
|
||||
- `start`/`end`/`startMs`/`endMs`/`finalStatus`/`price` are computed first (outside the tx).
|
||||
- The employee resolution + guard + INSERT + completed→ticket + `joinAppointment` all run inside `runInTransaction(db, …)`.
|
||||
- Employee resolution keeps the same priority chain (`employee_id` from body → `employee.role` self-assign → `autoAssign` fallback). The old `LIMIT 1` lookup over `employee_services` is removed; when no employee is resolved, `autoAssign(db, {…})` picks the best available specialist.
|
||||
- Before the INSERT, `isAvailable(db, empId, startMs, endMs)` is checked; on conflict it throws `{status:409, error:"Ese horario ya está ocupado para el especialista"}`.
|
||||
- The catch translates any `{status,error}` throw into `err(res, status, error)` and rethrows everything else.
|
||||
- `GET /`, `GET /:id`, `PATCH /:id`, `POST /:id/complete`, `DELETE /:id`, `GET /:id/cancellation-policy` are **untouched**. `joinAppointment` and `computeCommission` are untouched.
|
||||
|
||||
## Deviations from the plan's literal text
|
||||
1. **Replaced a wider window than "lines 129-167".** The plan said to replace from `const start = new Date(start_at);` (line 129) through the handler end, but the verbatim block it provided *re-resolves* `empId` inside the transaction. If I had kept the original outer `empId` resolution (lines 116-127, including the `LIMIT 1` lookup and the early `return err(res,400,…)`), the outer code would (a) shadow the inner `empId`, (b) leave the new `autoAssign` path as dead code, and (c) directly contradict the task summary ("when still no employee, call `autoAssign(...)` instead of the old `LIMIT 1` lookup"). So I replaced the outer block too. This matches the verbatim block's own comment ("Resolver empleado (igual que antes, pero usando autoAssign si ninguno)") and the task summary.
|
||||
2. **`businessId: Number(req.user!.business_id)` instead of `businessId: req.user!.business_id`.** `AuthedRequest.user.business_id` is typed `number | null` (from `shared/types.ts`), but `AutoAssignCtx.businessId` requires `number`. `Number(...)` is safe because `authRequired` guarantees an authenticated user with a business. This is a literal-text bug in the plan; without the coercion `tsc` errors with TS2322.
|
||||
|
||||
## Verification
|
||||
|
||||
### `npm run typecheck`
|
||||
**PASS — 0 errors.** (After the `Number(...)` coercion; raw plan text produced `TS2322: Type 'number | null' is not assignable to type 'number'`.)
|
||||
|
||||
### `npm run test:unit`
|
||||
**PASS — 15/15.** `scheduling.test.ts` unchanged and green.
|
||||
|
||||
### `npm run test:e2e`
|
||||
**FAIL — but NOT a code bug; it is the seed/test-data concern the task anticipated.**
|
||||
|
||||
- The failure is at `e2e-test.mjs:46` ("create appointment"): `POST /api/appointments { service_id:1, client_id:1, start_at:"2026-09-15T11:00:00Z" }` returns **400** `{"error":"No hay empleado asignado a este servicio"}` (i.e. `autoAssign` returned `null`), which makes line 47 throw a `TypeError` reading `.id` off `undefined`.
|
||||
- The other 16 checks up to that point all PASS (login, /me, business, employees+stats, services, clients+stats, appointments joined, dashboard ×9, tickets).
|
||||
|
||||
**Root cause (verified):** timezone mismatch between the e2e fixture and the new working-hours enforcement.
|
||||
- Server TZ: `America/Mexico_City` (UTC−6).
|
||||
- Fixture slot `2026-09-15T11:00:00Z` = **05:00 local** — before the business's 09:00 opening (Mon–Fri 09:00–20:00).
|
||||
- `autoAssign` → `getWorkingHoursForDate` builds the day window in local time (09:00–20:00 local) and filters every candidate whose slot doesn't fit; at 05:00 local nobody qualifies → `autoAssign` returns `null` → handler throws `{status:400, "No hay empleado asignado a este servicio"}`.
|
||||
- The legacy `LIMIT 1` lookup never checked working hours, so this exact fixture used to pass; the new code enforces the plan's hard constraint.
|
||||
|
||||
This is the "seed/test-data concern, not a code defect" case the task description calls out (it anticipated a 409, but the same principle applies to the 400 from the stricter autoAssign). The fix is data-side — either run the server in UTC, or update `e2e-test.mjs` to use in-hours ISO slots (the plan's own Task 7 `booking-e2e.mjs` already does this by fetching slots from `/slots`). **The guard was not weakened.**
|
||||
|
||||
### Conflict smoke (temp `.mjs`, created/verified/deleted)
|
||||
Using an in-hours slot `2027-02-01T17:00:00Z` (= 11:00 local):
|
||||
- Create #1 (`service_id:1`, owner, no `employee_id`): **201**, `autoAssign` placed it on `employee_id:1`.
|
||||
- Create #2 (same `employee_id:1` + same slot): **409** `{"error":"Ese horario ya está ocupado para el especialista"}`.
|
||||
- Create #3 (same `employee_id:1` + 2 h later, non-overlapping): **201**.
|
||||
|
||||
All three behave exactly as required.
|
||||
|
||||
## Self-review
|
||||
- **Guard inside tx?** Yes — `isAvailable(db, empId, startMs, endMs)` runs inside `runInTransaction(db, …)`, before the INSERT. Throwing inside rolls back via the helper's ROLLBACK path.
|
||||
- **autoAssign fallback when no employee?** Yes — after the body's `employee_id` and the employee-role self-assign, if `!empId`, the handler loads `businesses.working_hours`, parses it, and calls `autoAssign(db, {businessId, serviceId, serviceName, serviceCategory, startMs, endMs, bizWh})`. `empId = aa?.employeeId ?? null`; if still null, throws `{status:400,…}`.
|
||||
- **Employee-role self-assign preserved?** Yes — `if (!empId && req.user!.role === "employee" && req.user!.employee_id) empId = req.user!.employee_id;` is intact.
|
||||
- **Owner with no employee_id → autoAssign (not LIMIT 1)?** Yes — the old `SELECT … FROM employee_services … LIMIT 1` block is removed.
|
||||
- **completed→ticket still works?** Yes — the `if (finalStatus === "completed") { INSERT INTO tickets … }` block is preserved verbatim inside the tx, keyed on the freshly `RETURNING *`-ed `inserted.id`.
|
||||
- **Price override preserved?** Yes — `price = price_override !== undefined ? Number(price_override) : service.price` computed before the tx and passed into the INSERT.
|
||||
- **Client resolution preserved?** Yes — untouched above the replaced block (existing `client_id` or `new_client` create).
|
||||
- **`joinAppointment` on result?** Yes — called on `inserted` inside the tx, then returned; `res.status(201).json({ appointment: r })` outside.
|
||||
- **Other endpoints untouched?** Yes — GET/PATCH/DELETE/complete/cancellation-policy are byte-identical to before.
|
||||
- **Response on success?** Yes — `res.status(201).json({ appointment: r })` unchanged.
|
||||
- **No `git commit` run?** Correct — no commit, no stage.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 7 — backend settings + employees (campos nuevos) + script de integración
|
||||
|
||||
## Status
|
||||
**COMPLETE** — all verification green, no commits made.
|
||||
|
||||
## Files changed
|
||||
- `server/routes/settings.ts` — extended `FIELDS` with `auto_assign_specialist`, `working_hours`; added both columns to GET and final-PATCH SELECTs; serialized `working_hours` to JSON when received as object; coerced `auto_assign_specialist` to `0`/`1`.
|
||||
- `server/routes/employees.ts` — `POST /` and `PATCH /:id` now accept and persist `specialties`, `working_hours`, `efficiency_score` (JSON-serialized arrays/maps, `Number(...)` coercion for efficiency).
|
||||
- `server/scripts/booking-e2e.mjs` — NEW. Slot-aware integration test covering auto-assign, double-booking 409, and `auto_assign_specialist` exposure on the public business endpoint.
|
||||
- `package.json` — added `"test:booking": "node server/scripts/booking-e2e.mjs"`.
|
||||
- `e2e-test.mjs` — fixture adaptation only: added `findSlot(slug, serviceId, maxDays=30)` helper that queries `/public/<slug>/slots` for the next 30 days and returns the first available slot iso; replaced the two hard-coded `start_at` ISO strings (`2026-09-15T11:00:00Z`, `2026-09-20T10:00:00Z`) with slot-aware values. Added a `GET /settings` call after login to obtain the business `slug`. Employee-service fallback: tries service_id 5 first, falls back to 6 (both offered only by Mateo Herrera, so the self-assign assertion still holds).
|
||||
|
||||
## Verification results
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm run typecheck` | **0 errors** |
|
||||
| `npm run test:unit` | **15/15 passed** |
|
||||
| `npm run test:e2e` | **25/25 passed** (was failing before the fixture fix) |
|
||||
| `npm run test:booking` | **9/9 passed** |
|
||||
|
||||
## Manual API check (temp .mjs, run + deleted)
|
||||
- `PATCH /settings { auto_assign_specialist: 1, working_hours: {1:{start:"09:00",end:"13:00"}, ...} }` → PATCH response reflected `auto_assign_specialist = 1` and `working_hours` as a JSON string. `GET /settings` returned the same. `working_hours` parsed back to `{1:{start:"09:00",end:"13:00"}, 3:null, ...}`.
|
||||
- `PATCH /employees/:id { specialties: ["Cabello","Coloración"], efficiency_score: 87, working_hours: {...} }` → reflected `specialties = ["Cabello","Coloración"]`, `efficiency_score = 87` with `typeof === "number"`, and `working_hours` JSON. `GET /employees/:id` confirmed persistence. Restored afterwards.
|
||||
|
||||
## Self-review
|
||||
- **settings serializes `working_hours`?** YES — `merged.working_hours = JSON.stringify(merged.working_hours)` when not already a string; stored as TEXT, returned verbatim by both SELECTs.
|
||||
- **settings coerces `auto_assign_specialist` to 0/1?** YES — `merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0`.
|
||||
- **employees coerces `efficiency_score` to `Number`?** YES — both POST (`typeof === "number" ? efficiency_score : 50`) and PATCH (`Number(efficiency_score)` with fallback to `existing.efficiency_score ?? 50`). Confirmed via manual check: returned value has `typeof === "number"`.
|
||||
- **employees handles `specialties`/`working_hours` JSON?** YES — POST stringifies the array/object; PATCH merges with existing and accepts string, object/array, or `null` (for `working_hours`).
|
||||
- **booking-e2e covers auto-assign + 409 + `auto_assign_specialist` exposure?** YES — step 4 books without `employee_id` and asserts the response contains `employee_id` + non-empty `reasons` (auto-assign); step 5 re-books the same slot/employee and asserts `409`; step 6 toggles `auto_assign_specialist` to 1 via PATCH and asserts the public `/public/<slug>` response exposes `business.auto_assign_specialist === 1`.
|
||||
- **e2e-test now passes without weakening guards?** YES — no guards were changed (only `settings.ts`, `employees.ts`, `e2e-test.mjs`, new `booking-e2e.mjs`, and `package.json` were touched; `booking.ts`, `appointments.ts`, `scheduling.ts`, `db.ts`, `types.ts` untouched). The fix is purely test-fixture: instead of hard-coding out-of-working-hours ISO strings (`11:00Z` ≈ 05:00 local, and `2026-09-20` is Sunday — both correctly rejected by the new working-hours enforcement), the test now fetches a real available slot from the same public endpoint that respects the guards. All 25 original assertions preserved; 4 new helper assertions added (slot lookups + slug).
|
||||
|
||||
## Deviations
|
||||
- **e2e-test.mjs employee-service fallback (svc 5 → svc 6).** The plan does not mandate this; the additional sub-task says "If service_id 5 has no slots, pick another service the employee offers". Implemented as: try service 5 across 30 days; if null, try service 6. In practice service 5 always yields slots in the seed data, so the fallback never fires — but it makes the test robust to seed variation. The self-assign assertion still holds because both services 5 and 6 are offered exclusively by Mateo Herrera.
|
||||
- **`findSlot` searches 30 days** (vs. a single weekday in the suggested approach) — gives the fixture more headroom to dodge dense seed bookings without weakening any guard.
|
||||
|
||||
## Concerns
|
||||
None.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Task 8 — Fix de iconos solapados (`@layer components` + `pl-10`)
|
||||
|
||||
## Files changed
|
||||
1. `src/index.css` — wrapped the `.input, .select, .textarea { ... }` block AND its `:focus` rule in `@layer components { ... }` (now lines 272–292).
|
||||
2. `src/pages/public/BookingPage.tsx` — in `DetailsForm`, changed the three `className="input pl-9"` → `className="input pl-10"` (Nombre, Teléfono, Correo; lines 664, 678, 692).
|
||||
|
||||
## Verification results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm run typecheck` | ✅ 0 errors (`tsc -b --noEmit` exited clean) |
|
||||
| `npm run build` | ✅ Succeeded in 5.06s — `dist/assets/index-*.css` emitted (36.19 kB). Confirms `@layer components` is valid CSS and Tailwind processes it. |
|
||||
| Grep: `padding: 0.55rem 0.75rem` outside `@layer components` | ✅ Only occurrence is on **line 280**, which is INSIDE the `@layer components { ... }` block (lines 272–292). No bare occurrence remains. |
|
||||
| Grep: BookingPage `input pl-` | ✅ All three now read `input pl-10` (lines 664, 678, 692). No `pl-9` remains. |
|
||||
|
||||
## Untouched (pre-existing WIP, not modified by this task)
|
||||
|
||||
### FullCalendar section in `src/index.css` (lines 131–163) — unchanged
|
||||
Shown verbatim from the file after my edit:
|
||||
|
||||
```css
|
||||
.fc-event {
|
||||
border: none !important;
|
||||
border-radius: 0.45rem !important;
|
||||
padding: 2px 5px !important;
|
||||
font-size: 0.74rem !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-event:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.fc-daygrid-event {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fc .fc-timegrid-slot {
|
||||
height: 2.4rem;
|
||||
}
|
||||
.fc-timegrid-event {
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.fc-timegrid-event-harness {
|
||||
margin-right: 2px !important;
|
||||
}
|
||||
/* When slotEventOverlap is false, FullCalendar puts a small gap between
|
||||
side-by-side events; tighten it a touch so each event gets more readable
|
||||
width without bleeding into its neighbour. */
|
||||
.fc .fc-timegrid-col-events {
|
||||
column-gap: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `git diff src/index.css` does show these FullCalendar lines as additions vs. HEAD, but they were **already present in the working tree before Task 8 began** (pre-existing WIP on the branch). My `edit` call only targeted the `.input/.select/.textarea` block — its `oldString`/`newString` did not include any FullCalendar text — so I did not modify this section.
|
||||
|
||||
### `src/components/AppointmentModal.tsx` and `src/pages/CalendarPage.tsx`
|
||||
Listed as modified in `git status` (pre-existing WIP from earlier tasks on this branch). I did not open, read, or edit either file during Task 8 — my only tool calls against the repo were the two `edit` operations on `src/index.css` and `src/pages/public/BookingPage.tsx`, plus read-only verification commands.
|
||||
|
||||
## Diff of MY two files (only the parts I changed)
|
||||
|
||||
```diff
|
||||
--- a/src/index.css
|
||||
+++ b/src/index.css
|
||||
@@ -258,24 +269,26 @@ body {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
-.input,
|
||||
-.select,
|
||||
-.textarea {
|
||||
- width: 100%;
|
||||
- ...
|
||||
- outline: none;
|
||||
-}
|
||||
-.input:focus,
|
||||
-.select:focus,
|
||||
-.textarea:focus {
|
||||
- border-color: #3b66ff;
|
||||
- box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||
+@layer components {
|
||||
+ .input,
|
||||
+ .select,
|
||||
+ .textarea {
|
||||
+ width: 100%;
|
||||
+ ...
|
||||
+ outline: none;
|
||||
+ }
|
||||
+ .input:focus,
|
||||
+ .select:focus,
|
||||
+ .textarea:focus {
|
||||
+ border-color: #3b66ff;
|
||||
+ box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||
+ }
|
||||
}
|
||||
|
||||
--- a/src/pages/public/BookingPage.tsx
|
||||
+++ b/src/pages/public/BookingPage.tsx
|
||||
@@ -661,7 +661,7 @@
|
||||
- className="input pl-9"
|
||||
+ className="input pl-10"
|
||||
@@ -675,7 +675,7 @@
|
||||
- className="input pl-9"
|
||||
+ className="input pl-10"
|
||||
@@ -689,7 +689,7 @@
|
||||
- className="input pl-9"
|
||||
+ className="input pl-10"
|
||||
```
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Does `@layer components` now wrap the input rule?**
|
||||
YES. `src/index.css` lines 272–292 open with `@layer components {` and close with `}`; the `.input/.select/.textarea` block and the `:focus` rule are both inside it, indented one level.
|
||||
|
||||
- **Will `pl-9`/`pl-10` utilities now override the component `padding` shorthand?**
|
||||
YES. Tailwind emits its utilities through `@tailwind utilities;` (line 3 of `index.css`), which places them in the `utilities` CSS cascade layer. Per the CSS cascade-layer ordering rule, declarations in a layer declared **later** beat declarations in an **earlier** layer at equal specificity, regardless of source order. The layer order established by the three `@tailwind` directives is `base → components → utilities`, so the `utilities` layer (containing `.pl-10 { padding-left: 2.5rem }`) wins over the `components` layer (containing `.input { padding: 0.55rem 0.75rem }`). Before this change, `.input` was declared outside any layer, which made it an *unlayered* style — and unlayered styles beat layered styles in the cascade, so the utility was losing. Wrapping in `@layer components` fixes this app-wide for every `.input`/`.select`/`.textarea` that uses `pl-*`/`pr-*` (LoginPage, BookingPage, etc.).
|
||||
|
||||
- **Why `pl-10` (2.5rem = 40px) instead of `pl-9` (2.25rem = 36px)?**
|
||||
Icons sit at `left-3` (12px) and are `h-4 w-4` (16px), so they occupy roughly 12–28px of horizontal space. `pl-9` left only an 8px gap between icon edge and text start, which clipped ascenders/descenders and made placeholders appear to overlap the icon. `pl-10` gives a comfortable 12px gap (text starts at 40px, icon ends ~28px). Combined with the layer fix above, the utility now actually applies.
|
||||
|
||||
## Concerns
|
||||
None. No commit performed (per instructions). Build and typecheck both green. Pre-existing WIP files left exactly as found.
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# Task 9 Report — `publicApi.ts` tipos actualizados
|
||||
|
||||
## Status
|
||||
✅ **DONE** — typecheck passes with 0 errors.
|
||||
|
||||
## File changed
|
||||
- `src/lib/publicApi.ts` (only this file; no consumers touched)
|
||||
|
||||
## Typecheck result
|
||||
```
|
||||
$ npm run typecheck
|
||||
> [email protected] typecheck
|
||||
> tsc -b --noEmit
|
||||
```
|
||||
**0 errors.** The changes are additive/loosening, so no consumer breakage:
|
||||
- `PublicBusiness.auto_assign_specialist` (added required field) is safe — the only consumer reading `PublicBusiness` is `BookingPage.tsx`, which does not yet destructure this field (it'll be added in Task 12). Adding a required field to an interface does not error on existing readers; it only errors on object *literals* that lack it, and there are no literals of type `PublicBusiness` in the codebase (they come from `request<PublicBusinessResponse>`).
|
||||
- `BookPayload.employee_id` made optional → strictly loosening; existing callers passing `employee_id` still typecheck.
|
||||
- `BookResponse.appointment` widened with `employee_id`/`reasons` → existing readers of `employee_name` still work; new readers will be added in Task 12.
|
||||
|
||||
No consumer errors observed.
|
||||
|
||||
## Interface diffs (verbatim from plan)
|
||||
|
||||
### 1. `PublicBusiness` — added `auto_assign_specialist`
|
||||
```diff
|
||||
require_deposit: boolean;
|
||||
deposit_pct: number;
|
||||
+ auto_assign_specialist: number | boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `BookPayload` — `employee_id` now optional
|
||||
```diff
|
||||
export interface BookPayload {
|
||||
service_id: number;
|
||||
- employee_id: number;
|
||||
+ employee_id?: number;
|
||||
start_at: string;
|
||||
client: BookClient;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `BookResponse.appointment` — added `employee_id` + `reasons`
|
||||
```diff
|
||||
export interface BookResponse {
|
||||
appointment: {
|
||||
id: number;
|
||||
start_at: string;
|
||||
price: number;
|
||||
service_name: string;
|
||||
+ employee_id: number;
|
||||
employee_name: string;
|
||||
+ reasons: string[];
|
||||
};
|
||||
business: { name: string; currency_symbol: string };
|
||||
client_created: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Consumer errors
|
||||
None. (Plan's Step 2 note had anticipated possible errors in `BookingPage.tsx`, but none materialized — the optional `employee_id` and the widened `BookResponse` are backward-compatible with the current consumer code, which will be properly updated in Tasks 11–12.)
|
||||
|
||||
## Concerns
|
||||
None. Ready for Tasks 10–12 to consume these new types.
|
||||
@@ -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.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# ---- Build frontend ----
|
||||
FROM node:22-slim AS web-build
|
||||
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 ----
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Built frontend (vite outputs to dist/)
|
||||
COPY --from=web-build /app/dist ./dist
|
||||
|
||||
# Server source (tsx runs TS directly in production)
|
||||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Persistent SQLite data
|
||||
RUN mkdir -p /app/data
|
||||
VOLUME /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npx", "tsx", "server/index.ts"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# AgendaPro
|
||||
# AgendaMax
|
||||
|
||||
Aplicación web **multi-tenant (SaaS)** para la gestión de negocios de servicios (estética, spa, barbería, clínicas, etc.):
|
||||
calendario de citas con **arrastrar y soltar**, gestión de **empleados**, **servicios**, **clientes** y **tickets**,
|
||||
@@ -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 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,25 +95,26 @@ 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…”**.
|
||||
|
||||
## Multi-tenant y consola de administrador
|
||||
|
||||
AgendaPro es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
|
||||
AgendaMax es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
|
||||
y las consultas se filtran por el negocio del usuario). El **administrador de plataforma** gestiona
|
||||
todos los negocios desde `/admin`:
|
||||
|
||||
|
||||
+2
-2
@@ -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}`);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: "owner@agendamax.demo"
|
||||
|
||||
// server/scripts/seed.ts
|
||||
VALUES (NULL, 'admin@agendamax.demo', 'demo1234', 'Administrador', 'admin', '#0f172a')
|
||||
|
||||
// src/pages/LoginPage.tsx
|
||||
useState(DEMO ? "owner@agendamax.demo" : "")
|
||||
```
|
||||
|
||||
- [ ] **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 'admin@agendapro.demo' THEN 'admin@agendamax.demo' WHEN 'owner@agendapro.demo' THEN 'owner@agendamax.demo' END WHERE (email = 'admin@agendapro.demo' AND role = 'admin') OR (email = 'owner@agendapro.demo' 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 ('admin@agendamax.demo','owner@agendamax.demo') 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 ('admin@agendapro.demo','owner@agendapro.demo','admin@agendamax.demo','owner@agendamax.demo') 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 'admin@agendapro.demo' THEN 'admin@agendamax.demo' WHEN 'owner@agendapro.demo' THEN 'owner@agendamax.demo' END WHERE (email='admin@agendapro.demo' AND role='admin') OR (email='owner@agendapro.demo' 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":"admin@agendamax.demo","password":"demo1234"}'
|
||||
$owner = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"owner@agendamax.demo","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,190 @@
|
||||
# Auto-asignación de especialista + rediseño de la página de reservas
|
||||
|
||||
**Fecha:** 2026-07-26
|
||||
**Estado:** Borrador para revisión del usuario
|
||||
**Alcance:** Página pública de reservas (`/b/:slug`) + sección de Configuración + modelo de datos
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
1. Añadir en Configuración un check **"Asignar especialista automáticamente"** que, al activarse, **omite el paso de elección de especialista** para el cliente en la URL pública de reservas.
|
||||
2. La asignación se resuelve con un **algoritmo matemático** que considera especialidad, eficiencia y —sobre todo— **disponibilidad**, garantizando que **un especialista nunca atienda a dos clientes a la vez** (sin traslape con citas ya agendadas).
|
||||
3. En "Elige fecha y hora", reemplazar el `<input type="date">` nativo por un **calendario de mes siempre visible**.
|
||||
4. En **escritorio**, dividir "Elige fecha y hora" en **dos columnas** (calendario + horarios) para aprovechar el ancho de pantalla; en móvil se mantiene el stack actual.
|
||||
5. **Colores indicativos llamativos** (alto contraste, mayor tamaño) para ayudar a usuarios mayores de 50, **manteniendo** el estilo moderno/fresco de AgendaMax y **sin alterar** los bloques de horario actuales.
|
||||
6. **Fix:** iconos de "Tus datos" (nombre/teléfono/correo) que se solapan con el placeholder gris.
|
||||
|
||||
## 2. No-objetivos (YAGNI)
|
||||
|
||||
- Pasarela de pago / depósitos (`require_deposit` queda sin aplicar).
|
||||
- Política de cancelación automatizada (fuera de alcance aquí).
|
||||
- Recurrencia de citas, descansos/almuerzos puntuales.
|
||||
- Vista de disponibilidad tipo FullCalendar en el flujo público (se usa una grilla de mes propia, más simple y enfocada).
|
||||
|
||||
## 3. Arquitectura
|
||||
|
||||
Nuevo módulo **`server/lib/scheduling.ts`** con funciones puras y testeables que centralizan toda la "data science":
|
||||
|
||||
- `getWorkingHours(business, employee|null, date) → { start, end } | null` (resuelve horario: empleado → si no, negocio; si el día es `null`, fuera de servicio).
|
||||
- `isAvailable(db, employeeId, startIso, endIso) → boolean` (verifica traslape contra citas `(scheduled, completed)`).
|
||||
- `computeOccupiedMinutes(db, employeeId, date) → number` (para load balance).
|
||||
- `specialtyMatch(employee, service) → 0..1`.
|
||||
- `scoreCandidate(ctx) → { score, reasons }`.
|
||||
- `autoAssign(db, { business, service, startIso, endIso }) → { employeeId, score, reasons } | null` (filtra por hard constraints, luego scorea).
|
||||
|
||||
Se descarta una versión SQL-pura (window functions): el scoring ponderado es más legible y mantenible en TypeScript y el número de especialistas por negocio es pequeño (cabe en memoria). El módulo es importado por `server/routes/booking.ts` y `server/routes/appointments.ts`.
|
||||
|
||||
## 4. Modelo de datos (migración V3 → V4)
|
||||
|
||||
Función `migrateV3ToV4` en `server/db.ts` (sigue el patrón de `migrateV2ToV3`).
|
||||
|
||||
### Tabla `businesses` — 2 columnas
|
||||
| Columna | Tipo | Default | Notas |
|
||||
|---|---|---|---|
|
||||
| `auto_assign_specialist` | `INTEGER NOT NULL` | `0` | El check que omite el paso de especialista. |
|
||||
| `working_hours` | `TEXT` (JSON) | ver backfill | Horario default del negocio por día. |
|
||||
|
||||
Formato JSON: `{"1":{"start":"09:00","end":"20:00"}, "2":{...}, ..., "6":null, "7":null}` (1=Lun … 7=Dom; `null` = cerrado).
|
||||
|
||||
### Tabla `employees` — 3 columnas
|
||||
| Columna | Tipo | Default | Notas |
|
||||
|---|---|---|---|
|
||||
| `specialties` | `TEXT` (JSON array) | `'[]'` | Etiquetas: `["Corte","Barba"]`. Se comparan con `service.category` y tokens de `service.name`. |
|
||||
| `working_hours` | `TEXT` (JSON) | `NULL` | Horario individual; `NULL` = hereda el del negocio. |
|
||||
| `efficiency_score` | `REAL NOT NULL` | `50` | 0–100. Lo captura el dueño. |
|
||||
|
||||
### Backfill (en la migración)
|
||||
- `auto_assign_specialist = 0` para todos.
|
||||
- `businesses.working_hours` = `{"1":{"start":"09:00","end":"20:00"},...,"5":{...},"6":null,"7":null}` (replica el `09:00–20:00` Lun–Vie actualmente hardcodeado en `booking.ts:57-58`).
|
||||
- `employees.specialties = '[]'`, `employees.working_hours = NULL`, `employees.efficiency_score = 50`.
|
||||
|
||||
### Tipos (`shared/types.ts`)
|
||||
- `Business` añade `auto_assign_specialist: boolean`, `working_hours: WorkingHours | null`.
|
||||
- `Employee` añade `specialties: string[]`, `working_hours: WorkingHours | null`, `efficiency_score: number`.
|
||||
- `WorkingHours` = `Record<1..7, { start: string; end: string } | null>`.
|
||||
|
||||
### Settings API (`server/routes/settings.ts`)
|
||||
- La lista blanca (`settings.ts:8-20`) añade: `auto_assign_specialist`, `working_hours`.
|
||||
|
||||
## 5. Algoritmo de auto-asignación (la "data science")
|
||||
|
||||
Restricción dura + score ponderado, para una cita candidata (servicio `S`, intervalo `[t0, t1)`):
|
||||
|
||||
### 5.1 Hard constraints — candidato válido si:
|
||||
1. El empleado está `active = 1`.
|
||||
2. Ofrece el servicio: existe fila en `employee_services(employee_id, service_id)`.
|
||||
3. `[t0, t1)` cae **dentro** de su horario laboral ese día (`getWorkingHours`). Si el día es cerrado → descartado.
|
||||
4. **No traslapa** ninguna cita existente con `status IN ('scheduled','completed')`: para toda cita `c`, es falso que `t0 < c.end_at && t1 > c.start_at` (`isAvailable`).
|
||||
|
||||
→ Garantiza que **un especialista jamás atiende a dos personas a la vez**.
|
||||
|
||||
### 5.2 Score (0–100) de cada candidato válido
|
||||
```
|
||||
score = 50 * specialtyMatch + 30 * efficiency + 20 * (1 - loadBalance)
|
||||
```
|
||||
- **specialtyMatch (0..1):** `1.0` si una etiqueta de `employee.specialties` coincide (normalizado: lowercase, sin acentos, match por token/subcadena) con `service.category` o un token de `service.name`; `0.5` si ofrece el servicio (paso 2) pero sin coincidencia de etiqueta.
|
||||
- **efficiency (0..1):** `employee.efficiency_score / 100`.
|
||||
- **loadBalance (0..1):** `min(1, ocupadoHoy / laborableHoy)`, donde `ocupadoHoy = computeOccupiedMinutes` (suma de duraciones de citas del día) y `laborableHoy = minutos del turno ese día`. A menor carga → mayor `(1 - loadBalance)` → premia reparto justo.
|
||||
|
||||
### 5.3 Desempate y resultado
|
||||
- Mayor `score` gana. Empate → menor `id` (determinista).
|
||||
- `autoAssign` devuelve `{ employeeId, score, reasons: string[] }` para auditoría/UI.
|
||||
|
||||
### 5.4 Pesos
|
||||
`specialty 50 / efficiency 30 / load 20`. Disponibilidad es **restricción dura** (peso infinito): nunca se viola.
|
||||
|
||||
## 6. Guard anti double-booking (transaccional)
|
||||
|
||||
Hoy `POST /api/public/:slug/book` (`booking.ts:100-153`) y `POST /api/appointments` (`appointments.ts:75-167`) insertan **sin re-verificar** conflictos (race condition). Fix:
|
||||
|
||||
- Envolver la creación en una **transacción** de SQLite (`db.exec('BEGIN IMMEDIATE')` o el equivalente de `node:sqlite`).
|
||||
- Antes del `INSERT`, llamar a `isAvailable(employeeId, t0, t1)`.
|
||||
- Si ya no está libre → **409 Conflict** con `{ error: "ESE_HORARIO_OCUPADO" }`. El cliente público ve "Esa hora acaba de ocuparse, elige otra" y vuelve al paso de horario.
|
||||
- En auto-asignación, `autoAssign` corre **dentro** de la misma transacción, sobre el estado ya bloqueado.
|
||||
|
||||
## 7. Flujo del wizard público (`src/pages/public/BookingPage.tsx`)
|
||||
|
||||
### 7.1 Pasos
|
||||
- `auto_assign_specialist = ON` → **3 pasos**: Servicio → **Horario** → Datos (sin paso Especialista).
|
||||
- `OFF` → 4 pasos actuales, pero la card "Cualquiera" usa `autoAssign` (no más `LIMIT 1`).
|
||||
|
||||
### 7.2 Slots (`GET /api/public/:slug/slots`)
|
||||
- Se mantiene la firma y la grilla de **30 min**, pero el rango ahora se lee de `getWorkingHours` (negocio/empleado) en vez del hardcodeado `09:00–20:00`.
|
||||
- Cuando se omite el especialista, los candidatos son **todos** los empleados que ofrecen el servicio; un slot se muestra si **al menos uno** está libre (unión de disponibilidades). La asignación concreta se resuelve al confirmar.
|
||||
- El payload del slot puede incluir un flag opcional `hasMultiple` para mostrar "varios especialistas disponibles" (informativo).
|
||||
|
||||
### 7.3 Confirmación
|
||||
- Muestra el especialista asignado: avatar/color, nombre, rol, y 1–2 `reasons` legibles (p. ej. "Especialista en Corte · alta eficiencia · disponible"). El cliente nunca queda sin saber a quién le tocó.
|
||||
|
||||
## 8. UI — `DateTimePicker` (Elige fecha y hora)
|
||||
|
||||
### 8.1 Calendario de mes siempre visible (`MonthCalendar`)
|
||||
- Componente nuevo (sin librería externa). Grilla **Lun–Dom**, header con mes/año + flechas ‹ › (limite ±3 meses).
|
||||
- Días pasados o fuera de horario: atenuados (`slate-100`, texto `slate-300`), no seleccionables.
|
||||
- Fin de semana: color tenue (no bloqueado salvo que `working_hours` sea `null`).
|
||||
- Día con disponibilidad (≥1 slot): **punto `accent`** (naranja AgendaMax) bajo el número.
|
||||
- Día seleccionado: relleno `brand-500`, texto blanco, `ring-2 ring-brand-500/30`, `shadow-soft`.
|
||||
- Tipografía base >=16px; celda táctil >=44px (accesibilidad 50+).
|
||||
|
||||
### 8.2 Dos columnas en escritorio
|
||||
- En el paso Horario, `main` pasa de `max-w-2xl` a **`max-w-5xl`** (solo en este paso).
|
||||
- `DateTimePicker`: `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:gap-8` → **calendario izquierda**, **tarjeta de horarios derecha**.
|
||||
- Tarjeta de horarios: encabezado con la fecha elegida en grande + agrupación visual de slots por **Mañana / Tarde** (etiquetas con color `accent`/`brand` suave). **La grilla de bloques actual no se modifica** (`grid-cols-3 sm:grid-cols-4`, estilos seleccionado/disponible idénticos).
|
||||
- En `<md` se conserva el stack vertical actual (aprobado por el usuario).
|
||||
- `TopBar` (`max-w-3xl`) y `ActionBar` (`max-w-2xl`) se ensanchan a `max-w-5xl` para acompañar.
|
||||
|
||||
### 8.3 Colores indicativos (alta legibilidad, sin romper la marca)
|
||||
- No se introducen colores nuevos estridentes; se **refuerza contraste y tamaño** y se usa `accent` como señal secundaria.
|
||||
- Seleccionado: `bg-brand-500 text-white shadow-soft` + badge `Check` (más grande).
|
||||
- Disponible: `border-slate-200 bg-white`, hover → `border-brand-400 bg-brand-50`.
|
||||
- Indisponible: `bg-slate-100 text-slate-300` + ícono de candado sutil.
|
||||
- Texto de etiquetas y campos >=16px; estado enfocado con `ring` más visible.
|
||||
|
||||
## 9. UI — Fix de iconos en "Tus datos" (y app-wide)
|
||||
|
||||
### 9.1 Causa raíz
|
||||
`src/index.css:272-284`: `.input { padding: 0.55rem 0.75rem }` (shorthand) tiene la misma especificidad (0,1,0) que `.pl-9` de Tailwind. Como `@tailwind utilities;` se emite **antes** en el archivo, la regla `.input` (posterior) **gana** y fija `padding-left: 0.75rem` (12px), justo donde inicia el icono (`left-3` = 12px, ancho 16px → ocupa 12–28px). El texto arranca en 12px → **solape**. Afecta a **9 inputs** de la app.
|
||||
|
||||
### 9.2 Fix
|
||||
1. Envolver `.input`, `.select`, `.textarea` (y demás reglas de componente custom) en **`@layer components { … }`** en `src/index.css`. Así las utilidades del layer `utilities` (incluido `pl-*`) **siempre** ganan, independientemente del orden de fuente. Resuelve los 9 sitios a la vez.
|
||||
2. En los 3 fields de "Tus datos" subir `pl-9` → **`pl-10`** (icono `left-3` + 16px → texto a 40px, ~12px de holgura, mejor para 50+). Opcionalmente replicar `pl-10` en los otros 6 inputs con icono para consistencia.
|
||||
|
||||
## 10. UI — Configuración (`src/pages/SettingsPage.tsx`)
|
||||
|
||||
- Card **"Reservas online"** (`:64-101`): añade
|
||||
- Check **"Asignar especialista automáticamente"** con helper "El cliente no elige especialista; lo asigna el sistema según especialidad, eficiencia y disponibilidad".
|
||||
- Editor de **horario del negocio**: grilla 7 días, cada uno con switch activo + `start`/`end` (type=time). Persiste en `businesses.working_hours`.
|
||||
- El listado de campos del mutation PATCH (`SettingsPage.tsx:28-49`) incluye los nuevos.
|
||||
|
||||
## 11. UI — Ficha de Especialista (edición de empleado)
|
||||
|
||||
- Vista de edición de empleado añade:
|
||||
- **Especialidades** (input de chips/tags → array).
|
||||
- **Horario laboral** (default: "Heredar del negocio"; si se edita, grilla 7 días como en el negocio).
|
||||
- **Eficiencia** (slider 0–100 con etiqueta descriptiva).
|
||||
- Endpoints existentes `PATCH /api/employees/:id` se extienden con los 3 campos en la lista blanca.
|
||||
|
||||
## 12. Plan de pruebas
|
||||
|
||||
- **Unit (scheduling):** `isAvailable` (casos de traslape en bordes), `specialtyMatch` (acentos/case), `scoreCandidate` (monotocidad de cada factor), `autoAssign` (elige óptimo, desempate determinista, respeta horario, ninguno disponible → null).
|
||||
- **Integración (rutas):** reservar la misma hora dos veces → la 2ª da 409; reservar con auto-assign activo asigna y no traspasa.
|
||||
- **Build/estático:** `npm run typecheck` y `npm run lint` limpios.
|
||||
- **E2E si aplica:** flujo público completo con check activo (3 pasos) y sin check (4 pasos).
|
||||
|
||||
## 13. Riesgos y mitigaciones
|
||||
|
||||
| Riesgo | Mitigación |
|
||||
|---|---|
|
||||
| Datos vacíos al migrar (sin especialidades/eficiencia) | Defaults neutros: `specialties=[]`, `efficiency=50`. El algoritmo degrada a disponibilidad + load balance. |
|
||||
| Race condition de doble reserva | Transacción `BEGIN IMMEDIATE` + `isAvailable` dentro de la misma. |
|
||||
| Performance con muchos especialistas | N por negocio es chico; peor caso O(N·slots·citas). Cache de `computeOccupiedMinutes` por (employee, date). |
|
||||
| Cambio de `working_hours` rompe slots pasados | Solo afecta a fechas futuras; citas pasadas no se revalidan. |
|
||||
| `@layer components` cambia cascada | Re-auditar visualmente inputs/selects existentes; los utilities ya pensados para ganar. |
|
||||
|
||||
## 14. Entregables (resumen de archivos)
|
||||
|
||||
- **Nuevos:** `server/lib/scheduling.ts`.
|
||||
- **Migración:** `server/db.ts` (`migrateV3ToV4`).
|
||||
- **Backend rutas:** `server/routes/booking.ts`, `server/routes/appointments.ts`, `server/routes/settings.ts`, `server/routes/employees.ts`.
|
||||
- **Tipos:** `shared/types.ts`.
|
||||
- **Frontend:** `src/pages/public/BookingPage.tsx` (MonthCalendar, layout 2 col, flujo 3/4 pasos, confirmación), `src/pages/SettingsPage.tsx`, ficha de empleado, `src/lib/publicApi.ts`, `src/lib/api.ts`.
|
||||
- **Estilos:** `src/index.css` (`@layer components` + fix de padding).
|
||||
@@ -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
@@ -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");
|
||||
@@ -38,7 +50,7 @@ const emps = await req("/api/employees", { headers: H(t) }); check("employees li
|
||||
const svcs = await req("/api/services", { headers: H(t) }); check("services list", svcs.json.services.length === 12 && svcs.json.services[0].employee_ids);
|
||||
const cls = await req("/api/clients", { headers: H(t) }); check("clients list", cls.json.clients.length > 0 && cls.json.clients[0].stats?.visits >= 0);
|
||||
const appts = await req("/api/appointments?limit=5", { headers: H(t) }); check("appointments joined", appts.json.appointments[0]?.service && appts.json.appointments[0]?.client);
|
||||
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_month > 0 && ov.json.appts_upcoming > 0);
|
||||
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_range > 0 && ov.json.appts_upcoming > 0);
|
||||
const be = await req("/api/dashboard/best-employees", { headers: H(t) }); check("best-employees ranked", be.json.employees.length === 6);
|
||||
const bs = await req("/api/dashboard/best-services", { headers: H(t) }); check("best-services ranked", bs.json.services.length > 0);
|
||||
const tt = await req("/api/dashboard/top-tickets", { headers: H(t) }); check("top-tickets", tt.json.tickets.length > 0);
|
||||
|
||||
+63
-6
@@ -13,12 +13,30 @@ function check(name, cond, extra = "") {
|
||||
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
||||
}
|
||||
|
||||
// Fetch the first available slot (iso) for a service over the next `maxDays` days.
|
||||
// Slots already filter out non-working days and double-booked specialists, so any
|
||||
// returned iso is safe to use as start_at for either autoAssign or self-assign paths.
|
||||
async function findSlot(slug, serviceId, maxDays = 30) {
|
||||
for (let i = 1; i <= maxDays; i++) {
|
||||
const d = new Date(Date.now() + i * 86400000);
|
||||
const date = d.toISOString().slice(0, 10);
|
||||
const { json } = await req("GET", `/public/${slug}/slots?service_id=${serviceId}&date=${date}`);
|
||||
if (Array.isArray(json.slots) && json.slots.length > 0) return json.slots[0].iso;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /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);
|
||||
const slug = settings.settings?.slug;
|
||||
check("tiene slug", !!slug, String(slug));
|
||||
|
||||
const checks = [
|
||||
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
||||
@@ -26,7 +44,7 @@ const checks = [
|
||||
["services", "/services", (j) => j.services?.length === 12 && Array.isArray(j.services[0].employee_ids)],
|
||||
["clients+stats", "/clients", (j) => j.clients?.length > 0 && j.clients[0].stats?.visits !== undefined],
|
||||
["appointments joined", "/appointments?limit=5", (j) => j.appointments?.[0]?.service && j.appointments[0].client],
|
||||
["dashboard overview", "/dashboard/overview", (j) => j.revenue_month > 0],
|
||||
["dashboard overview", "/dashboard/overview", (j) => j.revenue_range > 0],
|
||||
["best-employees", "/dashboard/best-employees", (j) => j.employees?.length === 6],
|
||||
["best-services", "/dashboard/best-services", (j) => j.services?.length > 0],
|
||||
["top-tickets", "/dashboard/top-tickets", (j) => j.tickets?.length > 0],
|
||||
@@ -42,13 +60,21 @@ for (const [name, path, cond] of checks) {
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, t);
|
||||
const slotOwner = await findSlot(slug, 1);
|
||||
check("found slot for service 1", !!slotOwner);
|
||||
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: slotOwner }, t);
|
||||
check("create appointment", !!created.appointment?.id);
|
||||
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
||||
check("complete -> ticket", completed.appointment?.status === "completed");
|
||||
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||
const { json: empAppt } = await req("POST", "/appointments", { service_id: 5, client_id: 2, start_at: "2026-09-20T10:00:00Z" }, empLogin.token);
|
||||
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id);
|
||||
// Mateo offers services "Corte caballero" (5) and "Corte + arreglo de barba" (6).
|
||||
// Try 5 first (keeps the original assertion intent); fall back to 6 if 5 has no slots.
|
||||
let empSvcId = 5;
|
||||
let slotEmp = await findSlot(slug, empSvcId);
|
||||
if (!slotEmp) { empSvcId = 6; slotEmp = await findSlot(slug, empSvcId); }
|
||||
check("found slot for employee service", !!slotEmp);
|
||||
const { json: empAppt } = await req("POST", "/appointments", { service_id: empSvcId, client_id: 2, start_at: slotEmp }, empLogin.token);
|
||||
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id, JSON.stringify(empAppt).slice(0, 150));
|
||||
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
||||
check("bad login 401", badLogin === 401);
|
||||
const { status: noAuth } = await req("GET", "/employees");
|
||||
@@ -56,5 +82,36 @@ check("no auth 401", noAuth === 401);
|
||||
const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token);
|
||||
check("employee blocked dashboard 403", empForb === 403);
|
||||
|
||||
// ---- Permission boundary (server-side enforcement) ----
|
||||
// F1: employees must NOT see commission_pct on any colleague row
|
||||
const { json: empLeak } = await req("GET", "/employees", null, empLogin.token);
|
||||
const leaked = (empLeak.employees || []).filter((e) => Object.prototype.hasOwnProperty.call(e, "commission_pct"));
|
||||
check("F1: employee cannot read commission_pct", leaked.length === 0, `${leaked.length} rows leaked commission_pct`);
|
||||
|
||||
// F2: employees cannot mutate a colleague's appointment (PATCH / complete / DELETE)
|
||||
const mateoId = empLogin.user?.employee_id;
|
||||
const { json: allAppts } = await req("GET", "/appointments?limit=80", null, t);
|
||||
const foreign = (allAppts.appointments || []).find((a) => a.employee_id !== mateoId);
|
||||
check("F2: found a colleague's appointment", !!foreign, "no foreign appt in seed");
|
||||
if (foreign) {
|
||||
const { status: sPatch } = await req("PATCH", `/appointments/${foreign.id}`, { notes: "x" }, empLogin.token);
|
||||
check("F2: employee PATCH foreign appt -> 403", sPatch === 403, `status=${sPatch}`);
|
||||
const { status: sComplete } = await req("POST", `/appointments/${foreign.id}/complete`, { tip: 0 }, empLogin.token);
|
||||
check("F2: employee complete foreign appt -> 403", sComplete === 403, `status=${sComplete}`);
|
||||
const { status: sDel } = await req("DELETE", `/appointments/${foreign.id}`, null, empLogin.token);
|
||||
check("F2: employee DELETE foreign appt -> 403", sDel === 403, `status=${sDel}`);
|
||||
}
|
||||
// positive control: employee can still mutate their OWN appointment
|
||||
if (empAppt.appointment?.id) {
|
||||
const { status: sOwn } = await req("PATCH", `/appointments/${empAppt.appointment.id}`, { notes: "nota propia" }, empLogin.token);
|
||||
check("F2: employee PATCH own appt -> 200", sOwn === 200, `status=${sOwn}`);
|
||||
}
|
||||
|
||||
// F3: employees cannot edit/delete clients (ownerOnly)
|
||||
const { status: sClientPatch } = await req("PATCH", "/clients/1", { notes: "x" }, empLogin.token);
|
||||
check("F3: employee PATCH client -> 403", sClientPatch === 403, `status=${sClientPatch}`);
|
||||
const { status: sClientDel } = await req("DELETE", "/clients/1", null, empLogin.token);
|
||||
check("F3: employee DELETE client -> 403", sClientDel === 403, `status=${sClientDel}`);
|
||||
|
||||
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\Uriel Jareth\AppData\Local\Programs\Python\Python312\python.exe
|
||||
@@ -0,0 +1 @@
|
||||
H:\MegaSync\Proyectos\AgendaPro
|
||||
+16
-4
@@ -3,13 +3,25 @@
|
||||
<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="AgendaPro — Gestión visual de citas, empleados e ingresos para tu negocio." />
|
||||
<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" />
|
||||
<title>AgendaPro — Gestión visual de tu negocio</title>
|
||||
<!-- 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>
|
||||
<div id="root"></div>
|
||||
|
||||
+202
@@ -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;
|
||||
});
|
||||
Generated
+43
-14
@@ -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",
|
||||
|
||||
+8
-1
@@ -20,7 +20,13 @@
|
||||
"seed": "node scripts/run-tsx.mjs server/scripts/seed.ts",
|
||||
"test:e2e": "node e2e-test.mjs",
|
||||
"test:admin": "node admin-test.mjs",
|
||||
"audit:visual": "node visual-audit.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",
|
||||
"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",
|
||||
@@ -35,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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -222,11 +222,46 @@ function migrateV1ToV2() {
|
||||
setMeta("schema_version", "2");
|
||||
}
|
||||
|
||||
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
|
||||
function migrateV3ToV4() {
|
||||
if (getMeta("schema_version") >= "4") return;
|
||||
|
||||
const bizCols: [string, string][] = [
|
||||
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["working_hours", "TEXT"],
|
||||
];
|
||||
for (const [col, def] of bizCols) {
|
||||
if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
|
||||
}
|
||||
|
||||
const empCols: [string, string][] = [
|
||||
["specialties", "TEXT"],
|
||||
["working_hours", "TEXT"],
|
||||
["efficiency_score", "REAL NOT NULL DEFAULT 50"],
|
||||
];
|
||||
for (const [col, def] of empCols) {
|
||||
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
|
||||
}
|
||||
|
||||
// Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
|
||||
const DEFAULT_WH = JSON.stringify({
|
||||
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
|
||||
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
|
||||
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
|
||||
});
|
||||
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
|
||||
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
||||
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
|
||||
|
||||
setMeta("schema_version", "4");
|
||||
}
|
||||
|
||||
export function runMigrations() {
|
||||
// Ensure base schema exists (no-op on existing tables)
|
||||
db.exec(SCHEMA);
|
||||
migrateV1ToV2();
|
||||
migrateV2ToV3();
|
||||
migrateV3ToV4();
|
||||
}
|
||||
|
||||
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
|
||||
|
||||
+4
-2
@@ -17,6 +17,7 @@ import { settingsRouter } from "./routes/settings.ts";
|
||||
import { cashRouter } from "./routes/cash.ts";
|
||||
import { notificationsRouter, scheduleReminders } from "./routes/notifications.ts";
|
||||
import { bookingRouter } from "./routes/booking.ts";
|
||||
import { meRouter } from "./routes/me.ts";
|
||||
import { authRequired } from "./lib/auth.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -37,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",
|
||||
});
|
||||
}
|
||||
@@ -64,6 +65,7 @@ app.use("/api/employees", authRequired, employeesRouter);
|
||||
app.use("/api/services", authRequired, servicesRouter);
|
||||
app.use("/api/clients", authRequired, clientsRouter);
|
||||
app.use("/api/appointments", authRequired, appointmentsRouter);
|
||||
app.use("/api/me", authRequired, meRouter);
|
||||
app.use("/api/dashboard", authRequired, dashboardRouter);
|
||||
app.use("/api/admin", authRequired, adminRouter);
|
||||
app.use("/api/settings", authRequired, settingsRouter);
|
||||
@@ -90,5 +92,5 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
app.listen(port, host, () => {
|
||||
console.log(`AgendaPro API en http://${host}:${port}`);
|
||||
console.log(`AgendaMax API en http://${host}:${port}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// server/lib/metrics.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { cancelRate, noShowRate, completionRate } from "./metrics.ts";
|
||||
|
||||
test("cancelRate: basic ratio rounded to 1 decimal", () => {
|
||||
assert.equal(cancelRate(1, 4), 25);
|
||||
assert.equal(cancelRate(3, 7), 42.9);
|
||||
assert.equal(cancelRate(0, 10), 0);
|
||||
});
|
||||
|
||||
test("cancelRate: zero total → 0 (no NaN)", () => {
|
||||
assert.equal(cancelRate(0, 0), 0);
|
||||
assert.equal(cancelRate(5, 0), 0);
|
||||
assert.equal(cancelRate(5, -3), 0);
|
||||
});
|
||||
|
||||
test("cancelRate: all cancelled → 100", () => {
|
||||
assert.equal(cancelRate(8, 8), 100);
|
||||
});
|
||||
|
||||
test("noShowRate: basic ratio rounded to 1 decimal", () => {
|
||||
assert.equal(noShowRate(1, 10), 10);
|
||||
assert.equal(noShowRate(1, 3), 33.3);
|
||||
assert.equal(noShowRate(2, 9), 22.2);
|
||||
});
|
||||
|
||||
test("noShowRate: zero total → 0", () => {
|
||||
assert.equal(noShowRate(0, 0), 0);
|
||||
assert.equal(noShowRate(3, 0), 0);
|
||||
});
|
||||
|
||||
test("completionRate: basic ratio rounded to whole percent", () => {
|
||||
assert.equal(completionRate(7, 10), 70);
|
||||
assert.equal(completionRate(1, 3), 33);
|
||||
assert.equal(completionRate(2, 3), 67);
|
||||
});
|
||||
|
||||
test("completionRate: zero total → 0", () => {
|
||||
assert.equal(completionRate(0, 0), 0);
|
||||
assert.equal(completionRate(5, 0), 0);
|
||||
});
|
||||
|
||||
test("completionRate: all completed → 100", () => {
|
||||
assert.equal(completionRate(10, 10), 100);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// server/lib/metrics.ts
|
||||
// Pure KPI metric definitions. All return 0 when there is no data (no divide-by-zero).
|
||||
|
||||
/** Cancellation rate: cancelled / total, rounded to 1 decimal (percent). */
|
||||
export function cancelRate(cancelled: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((cancelled / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** No-show rate: noShow / total, rounded to 1 decimal (percent). */
|
||||
export function noShowRate(noShow: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((noShow / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** Completion rate: completed / total, rounded to the nearest whole percent. */
|
||||
export function completionRate(completed: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((completed / total) * 100);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// server/lib/scheduling.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
|
||||
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
|
||||
pickBestSlotEmployee, BUSY_STATUSES,
|
||||
} from "./scheduling.ts";
|
||||
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
||||
|
||||
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
||||
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
|
||||
assert.equal(m?.[1]?.start, "09:00");
|
||||
assert.equal(m?.[6], null);
|
||||
assert.equal(m?.[7], null);
|
||||
assert.equal(m?.[2], null); // ausente → null
|
||||
});
|
||||
|
||||
test("parseWorkingHours: null/invalid → null", () => {
|
||||
assert.equal(parseWorkingHours(null), null);
|
||||
assert.equal(parseWorkingHours("no-json"), null);
|
||||
});
|
||||
|
||||
test("isoDayOfWeek: lunes=1, domingo=7", () => {
|
||||
assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes
|
||||
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => {
|
||||
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
|
||||
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||
const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes
|
||||
assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime());
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: día cerrado → null", () => {
|
||||
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
|
||||
assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado
|
||||
});
|
||||
|
||||
test("normalizeText/tokens: quita acentos y lowercase", () => {
|
||||
assert.equal(normalizeText("Coloración"), "coloracion");
|
||||
assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]);
|
||||
});
|
||||
|
||||
test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => {
|
||||
assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1);
|
||||
});
|
||||
|
||||
test("specialtyMatch: etiqueta con acento/case → 1", () => {
|
||||
assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1);
|
||||
});
|
||||
|
||||
test("specialtyMatch: sin etiquetas → 0.5", () => {
|
||||
assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5);
|
||||
});
|
||||
|
||||
test("specialtyMatch: etiqueta no relacionada → 0.5", () => {
|
||||
assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5);
|
||||
});
|
||||
|
||||
test("overlaps: bordes inclusivos de no-traslape", () => {
|
||||
// una cita termina exactamente cuando empieza otra → NO traslape
|
||||
assert.equal(overlaps(100, 200, 200, 300), false);
|
||||
// traslape real
|
||||
assert.equal(overlaps(100, 250, 200, 300), true);
|
||||
assert.equal(overlaps(200, 300, 100, 250), true);
|
||||
});
|
||||
|
||||
test("hasConflict: lista vacía → false", () => {
|
||||
assert.equal(hasConflict([], 100, 200), false);
|
||||
});
|
||||
|
||||
test("scoreCandidate: pesos 40/20/40", () => {
|
||||
// todo al máximo → 100
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
|
||||
// todo al mínimo → 0
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
|
||||
// sólo specialty (0.5) → 20 (40·0.5)
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 20);
|
||||
});
|
||||
|
||||
test("scoreCandidate: mayor efficiency → mayor score", () => {
|
||||
const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 });
|
||||
const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 });
|
||||
assert.ok(high > low);
|
||||
});
|
||||
|
||||
test("scoreCandidate: menor load (más disponible) → mayor score", () => {
|
||||
const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 });
|
||||
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
|
||||
assert.ok(free > busy);
|
||||
});
|
||||
|
||||
test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (misma especialidad)", () => {
|
||||
// Con los pesos viejos (50/30/20) el efficiency estático (30) aplastaba al load (20):
|
||||
// un senior ocupado siempre ganaba. Con 40/20/40, el load (40) supera al efficiency (20).
|
||||
// A: especialista ocupado (load 0.8) y muy eficiente (0.9)
|
||||
const a = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.8 });
|
||||
// B: especialista libre (load 0.1) e ineficiente (0.2)
|
||||
const b = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.1 });
|
||||
assert.equal(a, 40 * 0.5 + 20 * 0.9 + 40 * (1 - 0.8)); // 46
|
||||
assert.equal(b, 40 * 0.5 + 20 * 0.2 + 40 * (1 - 0.1)); // 60
|
||||
assert.ok(b > a, "el especialista libre debe vencer al ocupado+eficiente (anti-burnout)");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario
|
||||
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
|
||||
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
||||
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
||||
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
||||
// → 40·0.5 + 20·0.5 + 40·1 = 70 → empate exacto; menor load gana
|
||||
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([
|
||||
[1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]],
|
||||
[2, []],
|
||||
]);
|
||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
|
||||
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
|
||||
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
||||
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
||||
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
||||
});
|
||||
|
||||
test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)", () => {
|
||||
const s: string[] = [...BUSY_STATUSES];
|
||||
assert.ok(s.includes("scheduled"));
|
||||
assert.ok(s.includes("completed"));
|
||||
assert.equal(s.includes("no_show"), false);
|
||||
assert.equal(s.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
// server/lib/scheduling.ts
|
||||
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
|
||||
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.
|
||||
|
||||
export interface WorkingDay { start: string; end: string; } // "HH:mm"
|
||||
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
|
||||
|
||||
export interface BusyWindow { startMs: number; endMs: number; }
|
||||
export interface ScoreInput {
|
||||
specialtyMatch: number; // 0..1
|
||||
efficiency: number; // 0..1
|
||||
loadBalance: number; // 0..1 (fracción de jornada ocupada)
|
||||
}
|
||||
|
||||
export function clamp01(n: number): number {
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
export function parseWorkingHours(json: string | null | undefined): WorkingHoursMap | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const obj = JSON.parse(json);
|
||||
if (typeof obj !== "object" || obj === null) return null;
|
||||
const out: WorkingHoursMap = {};
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const v = obj[String(d)];
|
||||
if (v && typeof v.start === "string" && typeof v.end === "string") out[d] = { start: v.start, end: v.end };
|
||||
else out[d] = null;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JS getDay(): 0=Dom..6=Sáb → ISO 1=Lun..7=Dom. */
|
||||
export function isoDayOfWeek(date: Date): number {
|
||||
const j = date.getDay();
|
||||
return j === 0 ? 7 : j;
|
||||
}
|
||||
|
||||
export function isoDateStr(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */
|
||||
export function getWorkingHoursForDate(
|
||||
empWh: WorkingHoursMap | null,
|
||||
bizWh: WorkingHoursMap | null,
|
||||
date: Date
|
||||
): { startMs: number; endMs: number } | null {
|
||||
const dow = isoDayOfWeek(date);
|
||||
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
|
||||
if (!day) return null;
|
||||
const [sh, sm] = day.start.split(":").map(Number);
|
||||
const [eh, em] = day.end.split(":").map(Number);
|
||||
const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
|
||||
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
|
||||
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
|
||||
return { startMs, endMs };
|
||||
}
|
||||
|
||||
export function normalizeText(s: string): string {
|
||||
return (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
}
|
||||
|
||||
export function tokens(s: string): string[] {
|
||||
return normalizeText(s).split(/[^a-z0-9]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.0 si una especialidad coincide con la categoría o un token del nombre del servicio.
|
||||
* 0.5 si no hay etiquetas o no coincide (el caller ya garantiza que ofrece el servicio).
|
||||
*/
|
||||
export function specialtyMatch(specialties: string[], service: { name: string; category: string }): number {
|
||||
if (!specialties || specialties.length === 0) return 0.5;
|
||||
const catN = normalizeText(service.category);
|
||||
const nameTokens = tokens(service.name);
|
||||
const hay = new Set<string>([catN, ...nameTokens]);
|
||||
for (const sp of specialties) {
|
||||
const spn = normalizeText(sp);
|
||||
if (!spn) continue;
|
||||
if (hay.has(spn)) return 1;
|
||||
if (catN && (catN.includes(spn) || spn.includes(catN))) return 1;
|
||||
for (const t of nameTokens) {
|
||||
if (t.length >= 4 && (t.includes(spn) || spn.includes(t))) return 1;
|
||||
}
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||
return aStart < bEnd && aEnd > bStart;
|
||||
}
|
||||
|
||||
export function hasConflict(existing: BusyWindow[], startMs: number, endMs: number): boolean {
|
||||
return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
|
||||
}
|
||||
|
||||
/** score = 40·specialtyMatch + 20·efficiency + 40·(1−loadBalance), todo en [0,1]. Rango 0..100.
|
||||
* Pesos 40/20/40: specialty sigue mandando en matches reales, pero loadBalance (40) ahora
|
||||
* puede superar la ventaja estática de efficiency (20) → el especialista libre/menos cargado
|
||||
* gana con más frecuencia (equidad, anti-burnout). */
|
||||
export function scoreCandidate(s: ScoreInput): number {
|
||||
const sm = clamp01(s.specialtyMatch);
|
||||
const eff = clamp01(s.efficiency);
|
||||
const inv = clamp01(1 - clamp01(s.loadBalance));
|
||||
return 40 * sm + 20 * eff + 40 * inv;
|
||||
}
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export interface CandidateInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
role: string;
|
||||
specialties: string[];
|
||||
efficiency_score: number;
|
||||
empWh: WorkingHoursMap | null;
|
||||
}
|
||||
|
||||
function safeArr(json: unknown): string[] {
|
||||
if (typeof json !== "string" || !json) return [];
|
||||
try {
|
||||
const a = JSON.parse(json);
|
||||
return Array.isArray(a) ? a.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getCandidates(db: DatabaseSync, businessId: number, serviceId: number): CandidateInfo[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role, e.specialties, e.working_hours wh, e.efficiency_score
|
||||
FROM employees e
|
||||
JOIN employee_services es ON es.employee_id = e.id
|
||||
WHERE e.business_id = ? AND es.service_id = ? AND e.active = 1`
|
||||
)
|
||||
.all(businessId, serviceId) as any[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
role: r.role,
|
||||
specialties: safeArr(r.specialties),
|
||||
efficiency_score: typeof r.efficiency_score === "number" ? r.efficiency_score : 50,
|
||||
empWh: parseWorkingHours(r.wh),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Estados que ocupan la ventana de trabajo del empleado para load/availability.
|
||||
* NOTA: `no_show` NO está — la silla quedó vacía, así que no cuenta contra la carga
|
||||
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
||||
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
||||
|
||||
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
|
||||
const map = new Map<number, BusyWindow[]>();
|
||||
if (employeeIds.length === 0) return map;
|
||||
const startOfDay = `${dateIso}T00:00:00`;
|
||||
const endOfDay = `${dateIso}T23:59:59`;
|
||||
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT employee_id, start_at, end_at FROM appointments
|
||||
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
|
||||
AND status IN (${statusPh}) AND start_at >= ? AND start_at <= ?`
|
||||
)
|
||||
.all(...employeeIds, ...BUSY_STATUSES, startOfDay, endOfDay) as any[];
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.employee_id) ?? [];
|
||||
arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
|
||||
map.set(r.employee_id, arr);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
|
||||
const dateIso = isoDateStr(new Date(startMs));
|
||||
const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
|
||||
return !hasConflict(busy, startMs, endMs);
|
||||
}
|
||||
|
||||
interface RankedCandidate { info: CandidateInfo; score: number; loadBalance: number; reasons: string[] }
|
||||
|
||||
function rankOne(
|
||||
c: CandidateInfo,
|
||||
busy: BusyWindow[],
|
||||
wh: { startMs: number; endMs: number },
|
||||
service: { name: string; category: string }
|
||||
): RankedCandidate | null {
|
||||
const workingMs = Math.max(1, wh.endMs - wh.startMs);
|
||||
let occupiedMs = 0;
|
||||
for (const b of busy) {
|
||||
occupiedMs += Math.min(b.endMs, wh.endMs) - Math.max(b.startMs, wh.startMs);
|
||||
if (occupiedMs < 0) occupiedMs = 0;
|
||||
}
|
||||
const loadBalance = clamp01(occupiedMs / workingMs);
|
||||
const sm = specialtyMatch(c.specialties, service);
|
||||
const score = scoreCandidate({ specialtyMatch: sm, efficiency: c.efficiency_score / 100, loadBalance });
|
||||
const reasons: string[] = [];
|
||||
if (sm >= 1) reasons.push("Especialidad coincidente");
|
||||
if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
|
||||
if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
|
||||
return { info: c, score, loadBalance, reasons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash determinista (xmur3) → uint32. Sin deps. Misma entrada ⇒ misma salida en cualquier
|
||||
* plataforma, para que el desempate por hash sea reproducible por input.
|
||||
* Úselo para repartir empates sin sesgo por id/seniority. */
|
||||
export function hashStr(str: string): number {
|
||||
let h = 1779033703 ^ str.length;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||
return (h ^ (h >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
/** Epsilon para considerar dos loadBalance iguales (ratio de ms → posible ruido float). */
|
||||
const LOAD_EPSILON = 1e-9;
|
||||
|
||||
/**
|
||||
* ¿`a` desplaza al actual `best`? Orden de desempate (anti-burnout, sin sesgo por id):
|
||||
* 1) mayor score
|
||||
* 2) (cerca de empate) menor loadBalance — gana el menos cargado
|
||||
* 3) hash determinista de `${dateIso}|${startMs}|${empId}` — mismo slot ⇒ misma decisión,
|
||||
* pero slots distintos reparten entre empleados (no siempre cae en la misma persona).
|
||||
*/
|
||||
function beats(a: RankedCandidate, best: RankedCandidate, dateIso: string, startMs: number): boolean {
|
||||
if (a.score !== best.score) return a.score > best.score;
|
||||
if (Math.abs(a.loadBalance - best.loadBalance) > LOAD_EPSILON) return a.loadBalance < best.loadBalance;
|
||||
return hashStr(`${dateIso}|${startMs}|${a.info.id}`) < hashStr(`${dateIso}|${startMs}|${best.info.id}`);
|
||||
}
|
||||
|
||||
/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
|
||||
export function pickBestSlotEmployee(
|
||||
candidates: CandidateInfo[],
|
||||
busyMap: Map<number, BusyWindow[]>,
|
||||
bizWh: WorkingHoursMap | null,
|
||||
service: { name: string; category: string },
|
||||
startMs: number,
|
||||
endMs: number
|
||||
): { id: number; name: string } | null {
|
||||
const date = new Date(startMs);
|
||||
const dateIso = isoDateStr(date);
|
||||
let best: RankedCandidate | null = null;
|
||||
for (const c of candidates) {
|
||||
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
|
||||
if (!wh) continue;
|
||||
if (startMs < wh.startMs || endMs > wh.endMs) continue;
|
||||
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
|
||||
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
|
||||
if (!ranked) continue;
|
||||
if (!best || beats(ranked, best, dateIso, startMs)) {
|
||||
best = ranked;
|
||||
}
|
||||
}
|
||||
return best ? { id: best.info.id, name: best.info.name } : null;
|
||||
}
|
||||
|
||||
export interface AutoAssignResult {
|
||||
employeeId: number;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface AutoAssignCtx {
|
||||
businessId: number;
|
||||
serviceId: number;
|
||||
serviceName: string;
|
||||
serviceCategory: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
bizWh: WorkingHoursMap | null;
|
||||
}
|
||||
|
||||
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
|
||||
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
|
||||
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
|
||||
if (candidates.length === 0) return null;
|
||||
const dateIso = isoDateStr(new Date(ctx.startMs));
|
||||
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
|
||||
let best: RankedCandidate | null = null;
|
||||
for (const c of candidates) {
|
||||
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
|
||||
if (!wh) continue;
|
||||
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
|
||||
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
|
||||
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
|
||||
if (!ranked) continue;
|
||||
if (!best || beats(ranked, best, dateIso, ctx.startMs)) {
|
||||
best = ranked;
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
return {
|
||||
employeeId: best.info.id,
|
||||
score: best.score,
|
||||
reasons: best.reasons.length ? best.reasons : ["Disponible"],
|
||||
};
|
||||
}
|
||||
|
||||
/** Ejecuta fn dentro de una transacción BEGIN IMMEDIATE…COMMIT; ROLLBACK en error. */
|
||||
export function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const r = fn();
|
||||
db.exec("COMMIT");
|
||||
return r;
|
||||
} catch (err) {
|
||||
try { db.exec("ROLLBACK"); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// server/lib/time.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
bizDateISO,
|
||||
bizTodayISO,
|
||||
bizDayBoundsSqlite,
|
||||
bizDayBoundsIso,
|
||||
wallToUtcISO,
|
||||
wallToUtcDate,
|
||||
toSqliteUtc,
|
||||
toIsoUtc,
|
||||
} from "./time.ts";
|
||||
|
||||
const MX = "America/Mexico_City"; // UTC-6 (no DST since 2023)
|
||||
const BA = "America/Buenos_Aires"; // UTC-3
|
||||
|
||||
test("bizTodayISO: UTC instant just past midnight (00:30Z) is still the previous day in MX", () => {
|
||||
// 2026-07-27T00:30:00Z == 2026-07-26T18:30 local → still the 26th
|
||||
assert.equal(bizTodayISO(MX, new Date("2026-07-27T00:30:00Z")), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizTodayISO: late afternoon UTC (17:00Z) is same biz day in MX", () => {
|
||||
assert.equal(bizTodayISO(MX, new Date("2026-07-26T17:00:00Z")), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: Buenos Aires UTC-3 midday stays same day", () => {
|
||||
// 17:00Z == 14:00 local
|
||||
assert.equal(bizDateISO(new Date("2026-07-26T17:00:00Z"), BA), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: Buenos Aires UTC-3 evening stays same day", () => {
|
||||
// 23:00Z == 20:00 local
|
||||
assert.equal(bizDateISO(new Date("2026-07-26T23:00:00Z"), BA), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: UTC instant rolls to next local day", () => {
|
||||
// 2026-07-27T05:00:00Z == 2026-07-26T23:00 in MX → still 26th
|
||||
assert.equal(bizDateISO(new Date("2026-07-27T05:00:00Z"), MX), "2026-07-26");
|
||||
// 2026-07-27T06:30:00Z == 2026-07-27T00:30 in MX → now 27th
|
||||
assert.equal(bizDateISO(new Date("2026-07-27T06:30:00Z"), MX), "2026-07-27");
|
||||
});
|
||||
|
||||
test("wallToUtcISO: MX wall 18:00 → UTC next-day 00:00", () => {
|
||||
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 18, 0, 0), "2026-07-27T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("wallToUtcISO: MX wall 00:00 → UTC 06:00 same date", () => {
|
||||
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 0, 0, 0), "2026-07-26T06:00:00.000Z");
|
||||
});
|
||||
|
||||
test("wallToUtcDate: round-trip via bizDateISO stays on the wall day", () => {
|
||||
const utc = wallToUtcDate(MX, 2026, 7, 26, 18, 0, 0);
|
||||
assert.equal(bizDateISO(utc, MX), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDayBoundsSqlite: today bounds capture the evening rush (UTC-6)", () => {
|
||||
// "now" is 2026-07-26T23:30:00Z == 2026-07-26T17:30 local MX → biz day 2026-07-26
|
||||
const b = bizDayBoundsSqlite(MX, 0, new Date("2026-07-26T23:30:00Z"));
|
||||
// biz-local midnight 2026-07-26 in MX == 06:00Z; end of day 23:59:59 == 2026-07-27T05:59:59Z
|
||||
assert.equal(b.start, "2026-07-26 06:00:00");
|
||||
assert.equal(b.end, "2026-07-27 05:59:59");
|
||||
});
|
||||
|
||||
test("bizDayBoundsIso: ISO-Z bounds for start_at comparisons", () => {
|
||||
const b = bizDayBoundsIso(MX, 0, new Date("2026-07-26T23:30:00Z"));
|
||||
assert.equal(b.start, "2026-07-26T06:00:00Z");
|
||||
assert.equal(b.end, "2026-07-27T05:59:59Z");
|
||||
});
|
||||
|
||||
test("bizDayBoundsSqlite: negative offset reaches into the past", () => {
|
||||
// 30 days before biz day 2026-07-26 → 2026-06-26, midnight MX == 06:00Z
|
||||
const b = bizDayBoundsSqlite(MX, -30, new Date("2026-07-26T23:30:00Z"));
|
||||
assert.equal(b.start, "2026-06-26 06:00:00");
|
||||
});
|
||||
|
||||
test("toSqliteUtc / toIsoUtc format helpers", () => {
|
||||
const d = new Date("2026-07-27T00:00:00.000Z");
|
||||
assert.equal(toSqliteUtc(d), "2026-07-27 00:00:00");
|
||||
assert.equal(toIsoUtc(d), "2026-07-27T00:00:00Z");
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// server/lib/time.ts
|
||||
// Pure timezone helpers for business-local ("biz") date math.
|
||||
// Mexico is UTC-6, so SQLite date('now') (= UTC) misattributes the evening rush.
|
||||
// All functions are deterministic given an explicit instant (default = new Date()).
|
||||
|
||||
/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */
|
||||
export function bizDateISO(instant: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz || "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(instant); // en-CA yields "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */
|
||||
export function bizTodayISO(tz: string, now: Date = new Date()): string {
|
||||
return bizDateISO(now, tz);
|
||||
}
|
||||
|
||||
/** tz offset (in minutes) of the given instant, east of UTC positive. */
|
||||
function tzOffsetMinutes(instant: Date, tz: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz || "UTC",
|
||||
timeZoneName: "longOffset",
|
||||
}).formatToParts(instant);
|
||||
const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00";
|
||||
// e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC
|
||||
const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!m) return 0; // bare "GMT" → UTC
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
const h = parseInt(m[2], 10);
|
||||
const min = m[3] ? parseInt(m[3], 10) : 0;
|
||||
return sign * (h * 60 + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date.
|
||||
* We first treat the wall-clock as if it were UTC, read the tz offset at that instant,
|
||||
* then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same
|
||||
* wall-clock happens later in UTC → UTC = wall − offset.
|
||||
*/
|
||||
export function wallToUtcDate(
|
||||
tz: string,
|
||||
y: number,
|
||||
mo: number,
|
||||
d: number,
|
||||
hh: number,
|
||||
mm: number,
|
||||
ss: number
|
||||
): Date {
|
||||
const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss));
|
||||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||
return new Date(guess.getTime() - offsetMin * 60000);
|
||||
}
|
||||
|
||||
/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */
|
||||
export function bizDayBounds(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: Date; end: Date } {
|
||||
const today = bizDateISO(now, tz);
|
||||
const [y, m, d] = today.split("-").map(Number);
|
||||
const base = new Date(Date.UTC(y, m - 1, d));
|
||||
base.setUTCDate(base.getUTCDate() + dayOffset);
|
||||
const ty = base.getUTCFullYear();
|
||||
const tm = base.getUTCMonth() + 1;
|
||||
const td = base.getUTCDate();
|
||||
return {
|
||||
start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0),
|
||||
end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59),
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */
|
||||
export function toSqliteUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */
|
||||
export function toIsoUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 19) + "Z";
|
||||
}
|
||||
|
||||
/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */
|
||||
export function bizDayBoundsSqlite(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: string; end: string } {
|
||||
const b = bizDayBounds(tz, dayOffset, now);
|
||||
return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) };
|
||||
}
|
||||
|
||||
/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */
|
||||
export function bizDayBoundsIso(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: string; end: string } {
|
||||
const b = bizDayBounds(tz, dayOffset, now);
|
||||
return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) };
|
||||
}
|
||||
|
||||
/** Convenience: convert a business-local wall-clock to a UTC ISO string. */
|
||||
export function wallToUtcISO(
|
||||
tz: string,
|
||||
y: number,
|
||||
mo: number,
|
||||
d: number,
|
||||
hh: number,
|
||||
mm: number,
|
||||
ss: number
|
||||
): string {
|
||||
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
|
||||
|
||||
export const appointmentsRouter = Router();
|
||||
|
||||
@@ -25,7 +26,11 @@ function joinAppointment(a: any) {
|
||||
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
|
||||
.get(a.employee_id);
|
||||
a.client = db
|
||||
.prepare(`SELECT id, name, phone, email, tags FROM clients WHERE id = ?`)
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.phone, c.email, c.notes, c.tags,
|
||||
(SELECT COUNT(*) FROM appointments a2 WHERE a2.client_id = c.id AND a2.status = 'no_show') AS no_show_count
|
||||
FROM clients c WHERE c.id = ?`
|
||||
)
|
||||
.get(a.client_id);
|
||||
return a;
|
||||
}
|
||||
@@ -114,56 +119,60 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
|
||||
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
|
||||
|
||||
// Resolve employee: auto-assign to self for employees; default to first eligible for owner
|
||||
let empId = employee_id ? Number(employee_id) : null;
|
||||
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
||||
empId = req.user!.employee_id;
|
||||
}
|
||||
if (!empId) {
|
||||
const eligible = db
|
||||
.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`)
|
||||
.get(Number(service_id)) as { id: number } | undefined;
|
||||
empId = eligible?.id ?? null;
|
||||
}
|
||||
if (!empId) return err(res, 400, "No hay empleado asignado a este servicio");
|
||||
|
||||
const start = new Date(start_at);
|
||||
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
|
||||
const dur = Number(duration_min) || service.duration_min;
|
||||
const end = new Date(start.getTime() + dur * 60000);
|
||||
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startMs = start.getTime();
|
||||
const endMs = end.getTime();
|
||||
|
||||
const finalStatus = status || "scheduled";
|
||||
const price = price_override !== undefined ? Number(price_override) : service.price;
|
||||
|
||||
const r = db
|
||||
try {
|
||||
const r = runInTransaction(db, () => {
|
||||
// Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
|
||||
let empId = employee_id ? Number(employee_id) : null;
|
||||
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
||||
empId = req.user!.employee_id;
|
||||
}
|
||||
if (!empId) {
|
||||
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
|
||||
const aa = autoAssign(db, {
|
||||
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
|
||||
});
|
||||
empId = aa?.employeeId ?? null;
|
||||
}
|
||||
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
|
||||
|
||||
// Guard anti doble reserva
|
||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
|
||||
|
||||
const inserted = db
|
||||
.prepare(
|
||||
`INSERT INTO appointments
|
||||
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
)
|
||||
.get(
|
||||
req.user!.business_id,
|
||||
Number(service_id),
|
||||
empId,
|
||||
clientId,
|
||||
startIso,
|
||||
endIso,
|
||||
finalStatus,
|
||||
price,
|
||||
notes || null,
|
||||
req.user!.id
|
||||
) as any;
|
||||
.get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;
|
||||
|
||||
if (finalStatus === "completed") {
|
||||
db.prepare(
|
||||
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
|
||||
).run(req.user!.business_id, r.id, clientId, empId, Number(service_id), price);
|
||||
).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
|
||||
}
|
||||
|
||||
joinAppointment(r);
|
||||
joinAppointment(inserted);
|
||||
return inserted;
|
||||
});
|
||||
res.status(201).json({ appointment: r });
|
||||
} catch (e: any) {
|
||||
if (e && typeof e === "object" && e.status) return err(res, e.status, e.error);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
@@ -172,6 +181,9 @@ appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!existing) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
|
||||
const {
|
||||
service_id,
|
||||
@@ -250,6 +262,9 @@ appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
|
||||
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!appt) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && appt.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
const { tip = 0, payment_method = "card" } = req.body ?? {};
|
||||
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
|
||||
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
|
||||
@@ -274,9 +289,12 @@ appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
|
||||
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id);
|
||||
.prepare(`SELECT id, employee_id FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!existing) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
+117
-45
@@ -1,5 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import {
|
||||
parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
|
||||
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
||||
type WorkingHoursMap,
|
||||
} from "../lib/scheduling.ts";
|
||||
|
||||
export const bookingRouter = Router();
|
||||
|
||||
@@ -7,7 +12,8 @@ function publicBusiness(slug: string) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
|
||||
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct
|
||||
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct,
|
||||
auto_assign_specialist, working_hours
|
||||
FROM businesses WHERE slug = ? AND status = 'active'`
|
||||
)
|
||||
.get(slug) as any;
|
||||
@@ -47,55 +53,75 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
} else {
|
||||
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
|
||||
if (candidates.length === 0) {
|
||||
const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||||
candidates = any ? [any.id] : [];
|
||||
const anyEmp = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||||
candidates = anyEmp ? [anyEmp.id] : [];
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return res.json({ slots: [] });
|
||||
|
||||
// business hours 09:00–20:00, 30-min grid; reject past times
|
||||
const dayStart = new Date(`${date}T09:00:00`);
|
||||
const dayEnd = new Date(`${date}T20:00:00`);
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as WorkingHoursMap | null;
|
||||
|
||||
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
|
||||
const useRanking = !employeeId;
|
||||
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
|
||||
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();
|
||||
|
||||
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
|
||||
const dateObj = new Date(`${date}T12:00:00`);
|
||||
const singleEmpRow = employeeId
|
||||
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
|
||||
: null;
|
||||
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
|
||||
const window = employeeId
|
||||
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
|
||||
: getWorkingHoursForDate(null, bizWh, dateObj);
|
||||
|
||||
const now = new Date();
|
||||
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
||||
const dur = service.duration_min;
|
||||
|
||||
// gather existing appointments that day for these employees
|
||||
const startOfDayIso = `${date}T00:00:00`;
|
||||
const endOfDayIso = `${date}T23:59:59`;
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT employee_id, start_at, end_at FROM appointments
|
||||
WHERE business_id = ? AND employee_id IN (${candidates.map(() => "?").join(",")})
|
||||
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
|
||||
)
|
||||
.all(biz.id, ...candidates, startOfDayIso, endOfDayIso) as any[];
|
||||
if (!window) {
|
||||
return res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||
}
|
||||
const dayStart = window.startMs;
|
||||
const dayEnd = window.endMs;
|
||||
|
||||
for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
|
||||
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
|
||||
// citas existentes del día para el caso de especialista fijo
|
||||
const existing = employeeId
|
||||
? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
|
||||
: [];
|
||||
|
||||
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
|
||||
if (t < now.getTime() + 30 * 60000) continue; // pasado / demasiado pronto
|
||||
const slotStart = t;
|
||||
const slotEnd = t + dur * 60000;
|
||||
// find an employee free in this window
|
||||
for (const empId of candidates) {
|
||||
const busy = existing.some(
|
||||
(b) => b.employee_id === empId && slotStart < new Date(b.end_at).getTime() && slotEnd > new Date(b.start_at).getTime()
|
||||
);
|
||||
let chosen: { id: number; name: string } | null = null;
|
||||
if (useRanking) {
|
||||
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
|
||||
} else {
|
||||
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
||||
if (!busy) {
|
||||
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any;
|
||||
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(employeeId) as any;
|
||||
chosen = { id: employeeId, name: emp?.name ?? "" };
|
||||
}
|
||||
}
|
||||
if (chosen) {
|
||||
slots.push({
|
||||
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
|
||||
iso: new Date(t).toISOString(),
|
||||
employee_id: empId,
|
||||
employee_name: emp?.name,
|
||||
employee_id: chosen.id,
|
||||
employee_name: chosen.name,
|
||||
});
|
||||
break; // one free employee per slot is enough
|
||||
}
|
||||
}
|
||||
if (slots.length >= 40) break;
|
||||
}
|
||||
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||
});
|
||||
|
||||
function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||
return aStart < bEnd && aEnd > bStart;
|
||||
}
|
||||
|
||||
// Create a booking from the public site (no auth): creates/finds client + appointment
|
||||
bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const biz = publicBusiness(req.params.slug);
|
||||
@@ -107,15 +133,37 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const start = new Date(start_at);
|
||||
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
|
||||
|
||||
// resolve employee
|
||||
let empId = employee_id ? Number(employee_id) : null;
|
||||
if (!empId) {
|
||||
const cand = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`).get(Number(service_id)) as any;
|
||||
empId = cand?.id ?? null;
|
||||
}
|
||||
if (!empId) return res.status(400).json({ error: "Sin especialista disponible" });
|
||||
const startMs = start.getTime();
|
||||
const endMs = startMs + service.duration_min * 60000;
|
||||
|
||||
// resolve client (by phone/email or create)
|
||||
// ¿El cliente forzó especialista? Si auto_assign_specialist=1, se ignora al cliente y se auto-asigna.
|
||||
const autoAssignOn = !!biz.auto_assign_specialist;
|
||||
const clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null;
|
||||
|
||||
try {
|
||||
const result = runInTransaction(db, () => {
|
||||
// 1) Resolver especialista
|
||||
let empId: number | null = clientChose;
|
||||
let reasons: string[] = [];
|
||||
if (!empId) {
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as any;
|
||||
const aa = autoAssign(db, {
|
||||
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||
startMs, endMs, bizWh,
|
||||
});
|
||||
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
empId = aa.employeeId;
|
||||
reasons = aa.reasons;
|
||||
} else {
|
||||
// validar que ofrece el servicio
|
||||
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
|
||||
if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
|
||||
// guard anti doble reserva
|
||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
reasons = ["Tu especialista elegido"];
|
||||
}
|
||||
|
||||
// 2) Resolver cliente (por phone/email o crear)
|
||||
let clientId: number | null = null;
|
||||
const match = client.phone
|
||||
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
|
||||
@@ -130,25 +178,49 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
clientId = created.id;
|
||||
}
|
||||
|
||||
const endIso = new Date(start.getTime() + service.duration_min * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
// F18: soft no-show enforcement (nunca bloquea la reserva)
|
||||
const nsCount = (db.prepare("SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'").get(clientId) as { c: number })?.c ?? 0;
|
||||
const ns = typeof nsCount === "number" ? nsCount : Number(nsCount) || 0;
|
||||
const riskFlag = ns >= 2;
|
||||
let notesBase = client.notes || "Reserva online";
|
||||
if (ns >= 3) {
|
||||
notesBase = `[Riesgo de no-show] ${notesBase}`;
|
||||
db.prepare(`UPDATE clients SET tags = 'Riesgo' WHERE id = ? AND (tags IS NULL OR tags NOT LIKE '%Riesgo%')`).run(clientId);
|
||||
}
|
||||
|
||||
const endIso = new Date(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = new Date(startMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const appt = db
|
||||
.prepare(
|
||||
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
|
||||
)
|
||||
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;
|
||||
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, notesBase) as any;
|
||||
|
||||
const empName = (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name;
|
||||
return { appt, empId: empId as number, empName, reasons, clientCreated: !match, noShowCount: ns, riskFlag };
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
appointment: {
|
||||
id: appt.id,
|
||||
start_at: appt.start_at,
|
||||
end_at: appt.end_at,
|
||||
price: appt.price,
|
||||
id: result.appt.id,
|
||||
start_at: result.appt.start_at,
|
||||
end_at: result.appt.end_at,
|
||||
price: result.appt.price,
|
||||
service_name: service.name,
|
||||
employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name,
|
||||
employee_id: result.empId,
|
||||
employee_name: result.empName,
|
||||
reasons: result.reasons,
|
||||
},
|
||||
business: { name: biz.name, currency_symbol: biz.currency_symbol },
|
||||
client_created: !match,
|
||||
client_created: result.clientCreated,
|
||||
no_show_count: result.noShowCount,
|
||||
risk_flag: result.riskFlag,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e && typeof e === "object" && e.status) {
|
||||
return res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { err, ownerOnly } from "../lib/auth.ts";
|
||||
import type { ClientStats } from "../../shared/types.ts";
|
||||
|
||||
export const clientsRouter = Router();
|
||||
@@ -78,7 +78,7 @@ clientsRouter.post("/", (req: AuthedRequest, res) => {
|
||||
res.status(201).json({ client: r });
|
||||
});
|
||||
|
||||
clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
clientsRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
|
||||
@@ -100,7 +100,7 @@ clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
res.json({ client: updated });
|
||||
});
|
||||
|
||||
clientsRouter.delete("/:id", (req: AuthedRequest, res) => {
|
||||
clientsRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`)
|
||||
|
||||
+141
-72
@@ -2,73 +2,114 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { ownerOnly } from "../lib/auth.ts";
|
||||
import {
|
||||
bizDateISO,
|
||||
bizDayBoundsSqlite,
|
||||
bizDayBoundsIso,
|
||||
toIsoUtc,
|
||||
} from "../lib/time.ts";
|
||||
import { cancelRate, noShowRate, completionRate } from "../lib/metrics.ts";
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
dashboardRouter.use(ownerOnly);
|
||||
|
||||
const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/** Fetch the business timezone once per handler. */
|
||||
function bizTz(bid: number | null): string {
|
||||
const row = db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined;
|
||||
return row?.t || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
/** Clamp the range query param into [7, 120], default 30. */
|
||||
function clampRange(raw: unknown): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return 30;
|
||||
return Math.min(120, Math.max(7, Math.round(n)));
|
||||
}
|
||||
|
||||
dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
||||
|
||||
// Business-local windows, expressed as canonical SQLite UTC strings.
|
||||
// created_at is stored in a MIXED format (ISO-Z from seed, space from app default),
|
||||
// so we wrap it with datetime() for correct chronological comparison. start_at is
|
||||
// consistently ISO-Z, so it compares directly against ISO-Z bounds.
|
||||
const todaySql = bizDayBoundsSqlite(tz, 0);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start; // range days ago, biz-local midnight
|
||||
const prevStart = bizDayBoundsSqlite(tz, -range * 2).start; // preceding equal-length window
|
||||
const todayIso = bizDayBoundsIso(tz, 0);
|
||||
const weekStartIso = bizDayBoundsIso(tz, -7).start;
|
||||
const rangeStartIso = bizDayBoundsIso(tz, -range).start;
|
||||
const nowIso = toIsoUtc(new Date());
|
||||
|
||||
const revenue_today = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`,
|
||||
bid
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ? AND datetime(created_at) <= ?`,
|
||||
bid, todaySql.start, todaySql.end
|
||||
);
|
||||
const revenue_week = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-7 days')`,
|
||||
bid
|
||||
);
|
||||
const revenue_month = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
const revenue_range = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, rangeStart
|
||||
);
|
||||
const revenue_prev = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-60 days') AND created_at < datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ? AND datetime(created_at) < ?`,
|
||||
bid, prevStart, rangeStart
|
||||
);
|
||||
const revenue_change_pct =
|
||||
revenue_prev > 0 ? Math.round(((revenue_month - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
|
||||
revenue_prev > 0 ? Math.round(((revenue_range - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
|
||||
|
||||
const appts_today = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND date(start_at) = date('now')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ? AND start_at <= ?`,
|
||||
bid, todayIso.start, todayIso.end
|
||||
);
|
||||
const appts_week = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, weekStartIso
|
||||
);
|
||||
const appts_upcoming = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= ?`,
|
||||
bid, nowIso
|
||||
);
|
||||
const active_clients = scalar(
|
||||
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, bizDayBoundsSqlite(tz, -30).start
|
||||
);
|
||||
const avg_ticket = scalar(
|
||||
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, rangeStart
|
||||
);
|
||||
const total_range = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
|
||||
// Rates over appointments scheduled (start_at) in the window.
|
||||
const apptWindowTotal = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancels = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status IN ('cancelled','no_show') AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
const cancelledCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'cancelled' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancel_rate = total_range > 0 ? Math.round((cancels / total_range) * 1000) / 10 : 0;
|
||||
const completed = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
const noShowCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'no_show' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0;
|
||||
const completedCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancel_rate = cancelRate(cancelledCount, apptWindowTotal);
|
||||
const no_show_rate = noShowRate(noShowCount, apptWindowTotal);
|
||||
const completion_pct = completionRate(completedCount, apptWindowTotal);
|
||||
|
||||
res.json({
|
||||
revenue_today: Math.round(revenue_today * 100) / 100,
|
||||
revenue_week: Math.round(revenue_week * 100) / 100,
|
||||
revenue_month: Math.round(revenue_month * 100) / 100,
|
||||
revenue_range: Math.round(revenue_range * 100) / 100,
|
||||
revenue_prev: Math.round(revenue_prev * 100) / 100,
|
||||
revenue_change_pct,
|
||||
appts_today,
|
||||
appts_week,
|
||||
@@ -76,52 +117,59 @@ dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
||||
active_clients,
|
||||
avg_ticket: Math.round(avg_ticket * 100) / 100,
|
||||
cancel_rate,
|
||||
occupancy_pct,
|
||||
no_show_rate,
|
||||
completion_pct,
|
||||
range,
|
||||
});
|
||||
});
|
||||
|
||||
dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const ratingSince = bizDayBoundsSqlite(tz, -90).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role,
|
||||
COUNT(t.id) appts,
|
||||
COALESCE(SUM(t.amount+t.tip),0) revenue,
|
||||
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id), e.rating) avg_rating
|
||||
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id AND datetime(created_at) >= ?), e.rating) avg_rating
|
||||
FROM employees e
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND datetime(t.created_at) >= ?
|
||||
WHERE e.business_id = ?
|
||||
GROUP BY e.id
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(ratingSince, rangeStart, bid) as any[];
|
||||
const totalAppts = rows.reduce((a, r) => a + r.appts, 0);
|
||||
const out = rows.map((r) => ({
|
||||
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
||||
appointments: r.appts,
|
||||
revenue: Math.round(r.revenue * 100) / 100,
|
||||
avg_rating: Math.round(r.avg_rating * 10) / 10,
|
||||
utilization_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
|
||||
share_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
|
||||
}));
|
||||
res.json({ employees: out });
|
||||
});
|
||||
|
||||
dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.name, s.category, s.color,
|
||||
COUNT(t.id) count,
|
||||
COALESCE(SUM(t.amount+t.tip),0) revenue
|
||||
FROM services s
|
||||
LEFT JOIN tickets t ON t.service_id = s.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.service_id = s.id AND datetime(t.created_at) >= ?
|
||||
WHERE s.business_id = ?
|
||||
GROUP BY s.id
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(rangeStart, bid) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
service: { id: r.id, name: r.name, category: r.category, color: r.color },
|
||||
count: r.count,
|
||||
@@ -133,7 +181,9 @@ dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
|
||||
dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const limit = Math.min(50, Number(req.query.limit) || 10);
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
|
||||
@@ -143,11 +193,11 @@ dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
JOIN services s ON s.id = t.service_id
|
||||
JOIN employees e ON e.id = t.employee_id
|
||||
JOIN clients c ON c.id = t.client_id
|
||||
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
WHERE t.business_id = ? AND datetime(t.created_at) >= ?
|
||||
ORDER BY (t.amount + t.tip) DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(bid, rangeStart, limit) as any[];
|
||||
const out = rows.map((t) => ({
|
||||
ticket: {
|
||||
id: t.id,
|
||||
@@ -171,7 +221,9 @@ dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const limit = Math.min(50, Number(req.query.limit) || 10);
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.tags, c.phone,
|
||||
@@ -179,14 +231,14 @@ dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
COALESCE(SUM(t.amount+t.tip),0) total,
|
||||
MAX(t.created_at) last_visit
|
||||
FROM clients c
|
||||
LEFT JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.client_id = c.id AND datetime(t.created_at) >= ?
|
||||
WHERE c.business_id = ?
|
||||
GROUP BY c.id
|
||||
HAVING visits > 0
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(rangeStart, bid, limit) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
|
||||
visits: r.visits,
|
||||
@@ -199,7 +251,9 @@ dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const limit = Math.min(50, Number(req.query.limit) || 10);
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.tags, c.phone,
|
||||
@@ -207,13 +261,13 @@ dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
COALESCE(SUM(t.amount+t.tip),0) total,
|
||||
MAX(t.created_at) last_visit
|
||||
FROM clients c
|
||||
JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
JOIN tickets t ON t.client_id = c.id AND datetime(t.created_at) >= ?
|
||||
WHERE c.business_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY visits DESC, total DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(rangeStart, bid, limit) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
|
||||
visits: r.visits,
|
||||
@@ -225,24 +279,35 @@ dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const days = Math.min(120, Number(req.query.days) || 30);
|
||||
const days = Math.min(120, Math.max(1, Number(req.query.days) || 30));
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -days).start;
|
||||
// Pull raw tickets and bucket by BUSINESS-LOCAL date in JS (SQLite date() would use UTC,
|
||||
// misattributing the evening rush to the next day).
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT date(created_at) date, COALESCE(SUM(amount+tip),0) revenue, COUNT(*) appts
|
||||
FROM tickets
|
||||
WHERE business_id = ? AND created_at >= datetime('now','-${days} days')
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY date ASC`
|
||||
`SELECT created_at, amount, tip FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ?`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
// Fill missing days with 0
|
||||
const map = new Map(rows.map((r) => [r.date, r]));
|
||||
.all(bid, rangeStart) as { created_at: string; amount: number; tip: number }[];
|
||||
const map = new Map<string, { revenue: number; appts: number }>();
|
||||
for (const r of rows) {
|
||||
// Stored created_at may be "YYYY-MM-DD HH:MM:SS" (space) or "YYYY-MM-DDTHH:MM:SSZ";
|
||||
// normalize to an ISO UTC instant before converting to the biz date.
|
||||
const iso = typeof r.created_at === "string" && r.created_at.includes("T")
|
||||
? r.created_at
|
||||
: String(r.created_at).replace(" ", "T") + "Z";
|
||||
const key = bizDateISO(new Date(iso), tz);
|
||||
const cur = map.get(key) ?? { revenue: 0, appts: 0 };
|
||||
cur.revenue += Number(r.amount) + Number(r.tip);
|
||||
cur.appts += 1;
|
||||
map.set(key, cur);
|
||||
}
|
||||
const out: { date: string; revenue: number; appts: number }[] = [];
|
||||
const today = new Date();
|
||||
const now = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const d = new Date(now.getTime() - i * 86400000);
|
||||
const key = bizDateISO(d, tz);
|
||||
const row = map.get(key);
|
||||
out.push({
|
||||
date: key,
|
||||
@@ -255,17 +320,19 @@ dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.category, COUNT(t.id) count, COALESCE(SUM(t.amount+t.tip),0) revenue
|
||||
FROM tickets t
|
||||
JOIN services s ON s.id = t.service_id
|
||||
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
WHERE t.business_id = ? AND datetime(t.created_at) >= ?
|
||||
GROUP BY s.category
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(bid, rangeStart) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
category: r.category,
|
||||
count: r.count,
|
||||
@@ -312,7 +379,9 @@ dashboardRouter.get("/tickets", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role,
|
||||
@@ -320,12 +389,12 @@ dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
COALESCE(SUM(t.amount + t.tip), 0) revenue,
|
||||
COALESCE(SUM(t.commission), 0) commission
|
||||
FROM employees e
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND datetime(t.created_at) >= ?
|
||||
WHERE e.business_id = ?
|
||||
GROUP BY e.id
|
||||
ORDER BY commission DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(rangeStart, bid) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
||||
sales: r.sales,
|
||||
|
||||
+28
-12
@@ -6,6 +6,13 @@ import type { EmployeeStats } from "../../shared/types.ts";
|
||||
|
||||
export const employeesRouter = Router();
|
||||
|
||||
// commission_pct is sensitive (drives payouts) — only owner/admin may read it.
|
||||
const EMP_PUBLIC_COLS =
|
||||
"id, business_id, name, email, phone, color, role, active, rating, hire_date, created_at, specialties, working_hours, efficiency_score";
|
||||
function empCols(req: AuthedRequest): string {
|
||||
return req.user!.role === "owner" || req.user!.role === "admin" ? `${EMP_PUBLIC_COLS}, commission_pct` : EMP_PUBLIC_COLS;
|
||||
}
|
||||
|
||||
function attachServices(emp: any) {
|
||||
const ids = db
|
||||
.prepare(`SELECT service_id FROM employee_services WHERE employee_id = ?`)
|
||||
@@ -45,13 +52,13 @@ function computeStats(employeeId: number, businessId: number): EmployeeStats {
|
||||
appointments_completed: completed.c,
|
||||
revenue_total: completed.s,
|
||||
avg_rating: Math.round((rating.r ?? 5) * 10) / 10,
|
||||
utilization_pct: utilization,
|
||||
share_pct: utilization,
|
||||
};
|
||||
}
|
||||
|
||||
employeesRouter.get("/", (req: AuthedRequest, res) => {
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
|
||||
.prepare(`SELECT ${empCols(req)} FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
|
||||
.all(req.user!.business_id) as any[];
|
||||
const out = rows.map((r) => {
|
||||
attachServices(r);
|
||||
@@ -62,7 +69,7 @@ employeesRouter.get("/", (req: AuthedRequest, res) => {
|
||||
});
|
||||
|
||||
employeesRouter.get("/:id", (req: AuthedRequest, res) => {
|
||||
const emp = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(
|
||||
const emp = db.prepare(`SELECT ${empCols(req)} FROM employees WHERE id = ? AND business_id = ?`).get(
|
||||
Number(req.params.id),
|
||||
req.user!.business_id
|
||||
) as any;
|
||||
@@ -73,11 +80,12 @@ employeesRouter.get("/:id", (req: AuthedRequest, res) => {
|
||||
});
|
||||
|
||||
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const { name, role, email, phone, color, service_ids } = req.body ?? {};
|
||||
const { name, role, email, phone, color, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
|
||||
if (!name) return err(res, 400, "El nombre es obligatorio");
|
||||
const r = db
|
||||
.prepare(
|
||||
`INSERT INTO employees (business_id, name, role, email, phone, color) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
`INSERT INTO employees (business_id, name, role, email, phone, color, specialties, working_hours, efficiency_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
)
|
||||
.get(
|
||||
req.user!.business_id,
|
||||
@@ -85,7 +93,10 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
role || "Especialista",
|
||||
email || null,
|
||||
phone || null,
|
||||
color || "#3b66ff"
|
||||
color || "#3b66ff",
|
||||
JSON.stringify(Array.isArray(specialties) ? specialties : []),
|
||||
working_hours ? (typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours)) : null,
|
||||
typeof efficiency_score === "number" ? efficiency_score : 50
|
||||
) as any;
|
||||
if (Array.isArray(service_ids)) {
|
||||
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
||||
@@ -98,11 +109,9 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
|
||||
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id as number) as any;
|
||||
const existing = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id as number) as any;
|
||||
if (!existing) return err(res, 404, "Empleado no encontrado");
|
||||
const { name, role, email, phone, color, active, service_ids } = req.body ?? {};
|
||||
const { name, role, email, phone, color, active, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
|
||||
const merged = {
|
||||
name: name ?? existing.name,
|
||||
role: role ?? existing.role,
|
||||
@@ -110,10 +119,17 @@ employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
phone: phone ?? existing.phone,
|
||||
color: color ?? existing.color,
|
||||
active: active === undefined ? existing.active : active ? 1 : 0,
|
||||
specialties: specialties !== undefined
|
||||
? (typeof specialties === "string" ? specialties : JSON.stringify(Array.isArray(specialties) ? specialties : []))
|
||||
: existing.specialties,
|
||||
working_hours: working_hours !== undefined
|
||||
? (working_hours === null ? null : typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours))
|
||||
: existing.working_hours,
|
||||
efficiency_score: efficiency_score !== undefined ? Number(efficiency_score) : (existing.efficiency_score ?? 50),
|
||||
};
|
||||
db.prepare(
|
||||
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ? WHERE id = ?`
|
||||
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, id);
|
||||
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ?, specialties = ?, working_hours = ?, efficiency_score = ? WHERE id = ?`
|
||||
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, merged.specialties, merged.working_hours, merged.efficiency_score, id);
|
||||
if (Array.isArray(service_ids)) {
|
||||
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
|
||||
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { authRequired } from "../lib/auth.ts";
|
||||
import { bizDayBoundsSqlite, bizDayBoundsIso, toIsoUtc } from "../lib/time.ts";
|
||||
|
||||
export const meRouter = Router();
|
||||
meRouter.use(authRequired);
|
||||
|
||||
const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/** Fetch the business timezone once per handler. */
|
||||
function bizTz(bid: number | null): string {
|
||||
const row = db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined;
|
||||
return row?.t || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
const EMPTY_PERF = {
|
||||
appointments_today: 0,
|
||||
upcoming: 0,
|
||||
revenue_30d: 0,
|
||||
avg_rating: 0,
|
||||
share_pct: 0,
|
||||
next_appointment: null,
|
||||
};
|
||||
|
||||
/** Employee self-service analytics, tz-correct via the business timezone. */
|
||||
meRouter.get("/performance", (req: AuthedRequest, res) => {
|
||||
const eid = req.user!.employee_id;
|
||||
const bid = req.user!.business_id;
|
||||
if (!eid) return res.json(EMPTY_PERF);
|
||||
|
||||
const tz = bizTz(bid);
|
||||
// start_at is stored ISO-Z → pair with ISO bounds.
|
||||
const todayIso = bizDayBoundsIso(tz, 0);
|
||||
const rangeStartIso = bizDayBoundsIso(tz, -30).start;
|
||||
const nowIso = toIsoUtc(new Date());
|
||||
// created_at is stored in a mixed format → wrap with datetime() and use SQLite bounds.
|
||||
const rangeStartSql = bizDayBoundsSqlite(tz, -30).start;
|
||||
const ratingStartSql = bizDayBoundsSqlite(tz, -90).start;
|
||||
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
||||
|
||||
const appointments_today = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments
|
||||
WHERE employee_id = ? AND start_at >= ? AND start_at <= ? AND status != 'cancelled'`,
|
||||
eid, todayIso.start, todayIso.end
|
||||
);
|
||||
const upcoming = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments
|
||||
WHERE employee_id = ? AND status = 'scheduled' AND start_at >= ?`,
|
||||
eid, nowIso
|
||||
);
|
||||
const revenue_30d = scalar(
|
||||
`SELECT COALESCE(SUM(amount + tip), 0) v FROM tickets
|
||||
WHERE employee_id = ? AND datetime(created_at) >= ?`,
|
||||
eid, rangeStartSql
|
||||
);
|
||||
// Avg of last-90d reviews, falling back to the employee's stored rating.
|
||||
const avg_rating = scalar(
|
||||
`SELECT COALESCE(
|
||||
(SELECT AVG(rating) FROM reviews WHERE employee_id = ? AND datetime(created_at) >= ?),
|
||||
(SELECT rating FROM employees WHERE id = ?)
|
||||
) v`,
|
||||
eid, ratingStartSql, eid
|
||||
);
|
||||
const empAppts = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE employee_id = ? AND start_at >= ?`,
|
||||
eid, rangeStartIso
|
||||
);
|
||||
const bizAppts = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const share_pct = bizAppts > 0 ? Math.round((empAppts / bizAppts) * 100) : 0;
|
||||
|
||||
const next = db
|
||||
.prepare(
|
||||
`SELECT a.start_at, s.name service_name, c.name client_name
|
||||
FROM appointments a
|
||||
JOIN services s ON s.id = a.service_id
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
WHERE a.employee_id = ? AND a.status = 'scheduled' AND a.start_at >= ?
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(eid, nowIso) as { start_at: string; service_name: string; client_name: string } | undefined;
|
||||
|
||||
res.json({
|
||||
appointments_today,
|
||||
upcoming,
|
||||
revenue_30d: Math.round(revenue_30d * 100) / 100,
|
||||
avg_rating: Math.round((avg_rating ?? 0) * 10) / 10,
|
||||
share_pct,
|
||||
next_appointment: next
|
||||
? { start_at: next.start_at, service_name: next.service_name, client_name: next.client_name }
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
/** Per-ticket commissions for the signed-in employee, last 30 business-local days. */
|
||||
meRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
const eid = req.user!.employee_id;
|
||||
if (!eid) return res.json({ rows: [], total: 0 });
|
||||
|
||||
const bid = req.user!.business_id;
|
||||
const tz = bizTz(bid);
|
||||
const rangeStartSql = bizDayBoundsSqlite(tz, -30).start;
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT t.appointment_id, s.name service_name, c.name client_name,
|
||||
t.amount, t.commission, t.created_at
|
||||
FROM tickets t
|
||||
JOIN services s ON s.id = t.service_id
|
||||
JOIN clients c ON c.id = t.client_id
|
||||
WHERE t.employee_id = ? AND datetime(t.created_at) >= ?
|
||||
ORDER BY datetime(t.created_at) DESC
|
||||
LIMIT 100`
|
||||
)
|
||||
.all(eid, rangeStartSql) as any[];
|
||||
|
||||
const out = rows.map((r) => ({
|
||||
appointment_id: r.appointment_id,
|
||||
service_name: r.service_name,
|
||||
client_name: r.client_name,
|
||||
amount: Math.round(Number(r.amount) * 100) / 100,
|
||||
commission: Math.round(Number(r.commission) * 100) / 100,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
const total = Math.round(out.reduce((a, r) => a + r.commission, 0) * 100) / 100;
|
||||
|
||||
res.json({ rows: out, total });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { bizDayBoundsSqlite } from "../lib/time.ts";
|
||||
|
||||
export const notificationsRouter = Router();
|
||||
|
||||
@@ -30,7 +31,10 @@ function buildMessage(biz: any, appt: any, kind: string): string {
|
||||
export function scheduleReminders(businessId: number) {
|
||||
const biz = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(businessId) as any;
|
||||
if (!biz) return;
|
||||
// upcoming scheduled appointments in next 7 days
|
||||
// upcoming scheduled appointments in next 7 days (UTC instants — the window is
|
||||
// instant-relative, so we bind clean UTC values instead of SQLite's date('now')).
|
||||
const nowUtc = new Date().toISOString().slice(0, 19) + "Z";
|
||||
const plus7Utc = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19) + "Z";
|
||||
const appts = db
|
||||
.prepare(
|
||||
`SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email,
|
||||
@@ -40,10 +44,10 @@ export function scheduleReminders(businessId: number) {
|
||||
JOIN services s ON s.id = a.service_id
|
||||
LEFT JOIN employees e ON e.id = a.employee_id
|
||||
WHERE a.business_id = ? AND a.status = 'scheduled'
|
||||
AND a.start_at >= datetime('now') AND a.start_at <= datetime('now','+7 days')
|
||||
AND a.start_at >= ? AND a.start_at <= ?
|
||||
ORDER BY a.start_at ASC`
|
||||
)
|
||||
.all(businessId) as any[];
|
||||
.all(businessId, nowUtc, plus7Utc) as any[];
|
||||
// delete pending reminders that no longer have a matching upcoming scheduled appt
|
||||
db.prepare(
|
||||
`DELETE FROM notifications WHERE business_id = ? AND status = 'pending' AND kind IN ('reminder','confirmation')`
|
||||
@@ -133,7 +137,12 @@ notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => {
|
||||
|
||||
notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const scalar = (sql: string) => (db.prepare(sql).get(bid) as any)?.v ?? 0;
|
||||
const tz =
|
||||
(db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined)?.t ||
|
||||
"America/Mexico_City";
|
||||
// tz-correct 30-day window (business-local). created_at is mixed-format, so wrap with datetime().
|
||||
const since30 = bizDayBoundsSqlite(tz, -30).start;
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(bid, ...args) as any)?.v ?? 0;
|
||||
res.json({
|
||||
pending: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending'`),
|
||||
sent: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='sent'`),
|
||||
@@ -141,7 +150,9 @@ notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
|
||||
`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending' AND kind='reminder' AND send_at >= datetime('now')`
|
||||
),
|
||||
no_show_rate: scalar(
|
||||
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
|
||||
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v
|
||||
FROM appointments WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
since30
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,16 @@ const FIELDS = [
|
||||
"require_deposit",
|
||||
"deposit_pct",
|
||||
"timezone",
|
||||
"auto_assign_specialist",
|
||||
"working_hours",
|
||||
] as const;
|
||||
|
||||
settingsRouter.get("/", (req: AuthedRequest, res) => {
|
||||
const b = db
|
||||
.prepare(
|
||||
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
|
||||
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone
|
||||
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone,
|
||||
auto_assign_specialist, working_hours
|
||||
FROM businesses WHERE id = ?`
|
||||
)
|
||||
.get(req.user!.business_id) as any;
|
||||
@@ -39,6 +42,12 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
for (const f of FIELDS) {
|
||||
if (body[f] !== undefined) merged[f] = body[f];
|
||||
}
|
||||
if (merged.working_hours && typeof merged.working_hours !== "string") {
|
||||
merged.working_hours = JSON.stringify(merged.working_hours);
|
||||
}
|
||||
if (merged.auto_assign_specialist !== undefined) {
|
||||
merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0;
|
||||
}
|
||||
// slug uniqueness check
|
||||
if (merged.slug !== undefined) {
|
||||
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
@@ -51,7 +60,7 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
merged.id = req.user!.business_id;
|
||||
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
|
||||
const updated = db
|
||||
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone FROM businesses WHERE id = ?`)
|
||||
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone, auto_assign_specialist, working_hours FROM businesses WHERE id = ?`)
|
||||
.get(req.user!.business_id);
|
||||
res.json({ settings: updated });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// server/scripts/booking-e2e.mjs — requiere el server dev corriendo (npm run dev)
|
||||
const BASE = "http://localhost:5173/api";
|
||||
let pass = 0, fail = 0;
|
||||
async function req(method, path, body, token) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, json };
|
||||
}
|
||||
function check(name, cond, extra = "") {
|
||||
if (cond) { pass++; console.log(` \u2713 ${name}`); }
|
||||
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
|
||||
}
|
||||
|
||||
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||
const t = login.token;
|
||||
check("login owner", !!t);
|
||||
|
||||
// 1) Slug del negocio
|
||||
const { json: settings } = await req("GET", "/settings", null, t);
|
||||
const slug = settings.settings?.slug;
|
||||
check("tiene slug", !!slug, String(slug));
|
||||
|
||||
// 2) Datos públicos + servicios
|
||||
const { json: pub } = await req("GET", `/public/${slug}`);
|
||||
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
|
||||
const svc = pub.services[0];
|
||||
const tomorrow = new Date(Date.now() + 86400000);
|
||||
const date = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
// 3) Slots disponibles
|
||||
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
|
||||
check("slots devuelve lista", Array.isArray(slotsRes.slots));
|
||||
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
|
||||
|
||||
// 4) Reservar (auto-asignación, sin employee_id)
|
||||
const slot = slotsRes.slots[0];
|
||||
const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/book`, {
|
||||
service_id: svc.id,
|
||||
start_at: slot.iso,
|
||||
client: { name: "Cliente Prueba Auto", phone: "+525500000001" },
|
||||
});
|
||||
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
|
||||
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);
|
||||
check("response trae no_show_count", typeof booked.no_show_count === "number", `got ${typeof booked.no_show_count}`);
|
||||
check("response trae risk_flag", typeof booked.risk_flag === "boolean", `got ${typeof booked.risk_flag}`);
|
||||
check("cliente nuevo → sin riesgo", booked.no_show_count === 0 && booked.risk_flag === false, `ns=${booked.no_show_count} risk=${booked.risk_flag}`);
|
||||
|
||||
// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
|
||||
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
|
||||
service_id: svc.id,
|
||||
employee_id: booked.appointment.employee_id,
|
||||
start_at: slot.iso,
|
||||
client: { name: "Cliente Conflictivo", phone: "+525500000002" },
|
||||
});
|
||||
check("doble reserva → 409", conflict === 409, `status=${conflict}`);
|
||||
|
||||
// 6) Activar auto_assign_specialist y verificar que la respuesta pública lo expone
|
||||
await req("PATCH", "/settings", { auto_assign_specialist: 1 }, t);
|
||||
const { json: pub2 } = await req("GET", `/public/${slug}`);
|
||||
check("auto_assign_specialist expuesto en público", pub2.business?.auto_assign_specialist === 1);
|
||||
await req("PATCH", "/settings", { auto_assign_specialist: 0 }, t); // cleanup
|
||||
|
||||
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
+23
-4
@@ -204,14 +204,14 @@ export function seedBusiness(opts: SeedBusinessOptions) {
|
||||
let pastCount = 0;
|
||||
let upCount = 0;
|
||||
|
||||
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null) => {
|
||||
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null, fixedHour?: number) => {
|
||||
const svc = template.services[svcIdx];
|
||||
if (!svc) return null;
|
||||
const empLocalIdx = pick(svc.employees);
|
||||
const empId = empIds[empLocalIdx];
|
||||
const svcId = svcIds[svcIdx];
|
||||
const clientId = pick(clientIds);
|
||||
const hour = pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const hour = fixedHour ?? pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const minute = pick([0, 30]);
|
||||
const start = isoOffsetDays(dayOffset, hour, minute);
|
||||
const end = addMinutes(start, svc.duration_min);
|
||||
@@ -273,6 +273,25 @@ export function seedBusiness(opts: SeedBusinessOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee visible appointments across the current week (Mon–Sat) regardless of today's weekday
|
||||
{
|
||||
const now = new Date();
|
||||
const dow = now.getDay(); // 0=Sun ... 6=Sat
|
||||
const mondayOffset = dow === 0 ? -6 : 1 - dow;
|
||||
for (let d = 0; d < 6; d++) {
|
||||
const dayOffset = mondayOffset + d;
|
||||
if (dayOffset < -60) continue;
|
||||
const past = dayOffset < 0;
|
||||
const isToday_ = dayOffset === 0;
|
||||
const hours = pickN([10, 12, 14, 16, 18], rint(2, 4));
|
||||
for (const hour of hours) {
|
||||
const svcIdx = rint(0, template.services.length - 1);
|
||||
const status = past ? "completed" : (isToday_ && hour <= now.getHours()) ? "completed" : "scheduled";
|
||||
if (makeAppt(dayOffset, svcIdx, status, ownerRow?.id ?? null, hour)) upCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recompute ratings
|
||||
db.exec(`
|
||||
UPDATE employees SET rating = COALESCE(
|
||||
@@ -298,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();
|
||||
}
|
||||
|
||||
@@ -311,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.`);
|
||||
|
||||
+38
-5
@@ -14,9 +14,20 @@ export interface Business {
|
||||
status?: string;
|
||||
template?: string | null;
|
||||
trial_ends_at?: string | null;
|
||||
booking_enabled?: number | boolean;
|
||||
cancel_window_hours?: number;
|
||||
cancel_penalty_pct?: number;
|
||||
require_deposit?: number | boolean;
|
||||
deposit_pct?: number;
|
||||
timezone?: string;
|
||||
auto_assign_specialist?: number | boolean;
|
||||
working_hours?: WorkingHoursMap | string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export type WorkingDay = { start: string; end: string };
|
||||
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
business_id: number | null;
|
||||
@@ -39,6 +50,9 @@ export interface Employee {
|
||||
rating: number;
|
||||
hire_date: string | null;
|
||||
service_ids?: number[];
|
||||
specialties?: string[];
|
||||
working_hours?: WorkingHoursMap | string | null;
|
||||
efficiency_score?: number;
|
||||
stats?: EmployeeStats;
|
||||
}
|
||||
|
||||
@@ -47,7 +61,7 @@ export interface EmployeeStats {
|
||||
appointments_completed: number;
|
||||
revenue_total: number;
|
||||
avg_rating: number;
|
||||
utilization_pct: number;
|
||||
share_pct: number;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
@@ -121,8 +135,8 @@ export interface Ticket {
|
||||
|
||||
export interface DashboardOverview {
|
||||
revenue_today: number;
|
||||
revenue_week: number;
|
||||
revenue_month: number;
|
||||
revenue_range: number;
|
||||
revenue_prev: number;
|
||||
revenue_change_pct: number;
|
||||
appts_today: number;
|
||||
appts_week: number;
|
||||
@@ -130,7 +144,9 @@ export interface DashboardOverview {
|
||||
active_clients: number;
|
||||
avg_ticket: number;
|
||||
cancel_rate: number;
|
||||
occupancy_pct: number;
|
||||
no_show_rate: number;
|
||||
completion_pct: number;
|
||||
range: number;
|
||||
}
|
||||
|
||||
export interface RankedEmployee {
|
||||
@@ -138,7 +154,7 @@ export interface RankedEmployee {
|
||||
appointments: number;
|
||||
revenue: number;
|
||||
avg_rating: number;
|
||||
utilization_pct: number;
|
||||
share_pct: number;
|
||||
}
|
||||
|
||||
export interface RankedService {
|
||||
@@ -169,3 +185,20 @@ export interface CategorySlice {
|
||||
revenue: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BookResponse {
|
||||
appointment: {
|
||||
id: number;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
price: number;
|
||||
service_name: string;
|
||||
employee_id: number;
|
||||
employee_name: string;
|
||||
reasons: string[];
|
||||
};
|
||||
business: { name: string; currency_symbol: string };
|
||||
client_created: boolean;
|
||||
no_show_count?: number;
|
||||
risk_flag?: boolean;
|
||||
}
|
||||
|
||||
+90
-20
@@ -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";
|
||||
@@ -13,33 +12,96 @@ import { ClientDetailPage } from "./pages/ClientDetailPage";
|
||||
import { CashPage } from "./pages/CashPage";
|
||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
||||
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
||||
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
||||
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
||||
import BookingPage from "./pages/public/BookingPage";
|
||||
|
||||
function Protected() {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
// 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 }))
|
||||
);
|
||||
|
||||
// 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 items-center justify-center bg-[#f6f7fb]">
|
||||
<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 AgendaPro…</span>
|
||||
<span className="text-sm font-medium">Cargando AgendaMax…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <LoginPage />;
|
||||
|
||||
const isAdmin = user.role === "admin";
|
||||
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 />} />
|
||||
@@ -47,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 />} />
|
||||
) : (
|
||||
@@ -59,15 +120,16 @@ function Protected() {
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="/me" element={<MyPerformancePage />} />
|
||||
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
|
||||
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
|
||||
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
||||
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
|
||||
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
|
||||
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
|
||||
{/* 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>
|
||||
@@ -77,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">AgendaPro</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="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="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={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>
|
||||
{!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 text-slate-500">Admin · {user.email}</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>
|
||||
|
||||
+84
-27
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import {
|
||||
CalendarDays,
|
||||
@@ -14,22 +14,32 @@ import {
|
||||
Wallet,
|
||||
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;
|
||||
label: string;
|
||||
icon: typeof CalendarDays;
|
||||
ownerOnly?: boolean;
|
||||
employeeOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/dashboard", label: "Tablero", icon: LayoutDashboard, ownerOnly: true },
|
||||
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
|
||||
{ to: "/clients", label: "Clientes", icon: Users },
|
||||
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
|
||||
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
|
||||
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
|
||||
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
|
||||
@@ -42,21 +52,42 @@ 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";
|
||||
const items = NAV.filter((n) => !n.ownerOnly || isOwner);
|
||||
const items = NAV.filter((n) => {
|
||||
if (n.ownerOnly) return isOwner;
|
||||
if (n.employeeOnly) return user.role === "employee" || (isOwner && !!user.employee_id);
|
||||
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">AgendaPro</div>
|
||||
<div className="text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</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">
|
||||
@@ -65,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"
|
||||
@@ -77,36 +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">
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
<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>
|
||||
@@ -116,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">AgendaPro</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">
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Stethoscope,
|
||||
Phone,
|
||||
Mail,
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
@@ -32,6 +36,13 @@ const TIME_SUGGESTIONS = [
|
||||
"16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00",
|
||||
];
|
||||
|
||||
// joinAppointment attaches notes + no_show_count directly on the client object,
|
||||
// but shared/types Appointment.client Pick omits them — augment locally.
|
||||
type ApptClientMeta = NonNullable<Appointment["client"]> & {
|
||||
notes?: string | null;
|
||||
no_show_count?: number;
|
||||
};
|
||||
|
||||
function toLocalInput(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return { date: "", time: "10:00" };
|
||||
@@ -51,9 +62,18 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
const [serviceId, setServiceId] = useState<number | null>(null);
|
||||
const [employeeId, setEmployeeId] = useState<number | null>(user?.employee_id ?? null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [pickedClientName, setPickedClientName] = useState<string>("");
|
||||
const [pickedClientPhone, setPickedClientPhone] = useState<string>("");
|
||||
const [pickedClientEmail, setPickedClientEmail] = useState<string>("");
|
||||
const [pickedClientTags, setPickedClientTags] = useState<string>("");
|
||||
const [pickedClientNotes, setPickedClientNotes] = useState<string>("");
|
||||
const [pickedClientNoShows, setPickedClientNoShows] = useState<number>(0);
|
||||
const [clientMode, setClientMode] = useState<"existing" | "new">("existing");
|
||||
const [newClient, setNewClient] = useState({ name: "", phone: "", email: "", notes: "" });
|
||||
const [search, setSearch] = useState("");
|
||||
// When editing, hide the picker behind an explicit "Cambiar cliente" action so the
|
||||
// current client is always visible — never a stray "?" or empty search field.
|
||||
const [changingClient, setChangingClient] = useState(false);
|
||||
const [dateInput, setDateInput] = useState("");
|
||||
const [timeInput, setTimeInput] = useState("10:00");
|
||||
const [duration, setDuration] = useState(60);
|
||||
@@ -88,7 +108,15 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
setServiceId(appointment.service_id);
|
||||
setEmployeeId(appointment.employee_id);
|
||||
setClientId(appointment.client_id);
|
||||
setPickedClientName(appointment.client?.name ?? "");
|
||||
setPickedClientPhone(appointment.client?.phone ?? "");
|
||||
setPickedClientEmail(appointment.client?.email ?? "");
|
||||
setPickedClientTags(appointment.client?.tags ?? "");
|
||||
const ac = appointment.client as ApptClientMeta | undefined;
|
||||
setPickedClientNotes(ac?.notes ?? "");
|
||||
setPickedClientNoShows(Number(ac?.no_show_count ?? 0));
|
||||
setClientMode("existing");
|
||||
setChangingClient(false);
|
||||
const d = toLocalInput(appointment.start_at);
|
||||
setDateInput(d.date);
|
||||
setTimeInput(d.time);
|
||||
@@ -107,7 +135,14 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
setServiceId(null);
|
||||
setEmployeeId(user?.employee_id ?? null);
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
setClientMode("existing");
|
||||
setChangingClient(true);
|
||||
setNewClient({ name: "", phone: "", email: "", notes: "" });
|
||||
setSearch("");
|
||||
const d = toLocalInput(draftSlot.start);
|
||||
@@ -297,6 +332,7 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="label mb-0">Cliente</label>
|
||||
{!isEditing && clientMode === "existing" && !clientId && (
|
||||
<div className="flex rounded-lg border border-slate-200 p-0.5">
|
||||
<button
|
||||
onClick={() => setClientMode("existing")}
|
||||
@@ -309,16 +345,126 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClientMode("new")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
|
||||
clientMode === "new" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && clientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Cambiar cliente
|
||||
</button>
|
||||
)}
|
||||
{isEditing && !changingClient && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChangingClient(true)}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Cambiar cliente
|
||||
</button>
|
||||
)}
|
||||
{isEditing && changingClient && clientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChangingClient(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Mantener actual
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current client card (edit mode, or after picking existing client) */}
|
||||
{!changingClient && clientId && pickedClientName && (
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50/70 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={pickedClientName} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{pickedClientName}</div>
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-brand-600" />
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-slate-600">
|
||||
{pickedClientPhone && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Phone className="h-3 w-3 text-slate-400" />
|
||||
{pickedClientPhone}
|
||||
</span>
|
||||
)}
|
||||
{pickedClientEmail && (
|
||||
<span className="inline-flex items-center gap-1 truncate">
|
||||
<Mail className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<span className="truncate">{pickedClientEmail}</span>
|
||||
</span>
|
||||
)}
|
||||
{!pickedClientPhone && !pickedClientEmail && (
|
||||
<span className="text-slate-400">Sin datos de contacto</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pickedClientTags && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
|
||||
<Tag className="h-3 w-3 text-slate-400" />
|
||||
{pickedClientTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (
|
||||
<span key={t} className="chip bg-white text-slate-600">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pickedClientNoShows >= 1 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
|
||||
<span
|
||||
title="Ausencias previas sin avisar"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||
pickedClientNoShows >= 2
|
||||
? "bg-amber-100 text-amber-800 ring-1 ring-amber-300"
|
||||
: "bg-amber-50 text-amber-700"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Ausencias previas: {pickedClientNoShows}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pickedClientNotes && (
|
||||
<div className="mt-2 rounded-lg border border-brand-200/60 bg-white/60 px-2.5 py-1.5 text-[11px] text-slate-700">
|
||||
<div className="font-semibold text-slate-600">Notas del cliente</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words">{pickedClientNotes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Picker (existing/new): visible when explicitly changing (edit) OR
|
||||
when no existing client has been picked yet (create). Once an existing
|
||||
client is picked in create mode, the card above takes over. */}
|
||||
{(changingClient || (!isEditing && !clientId)) && (
|
||||
<>
|
||||
{clientMode === "existing" ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
@@ -330,21 +476,16 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
}}
|
||||
/>
|
||||
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
|
||||
</div>
|
||||
{clientId && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2">
|
||||
<Avatar name={searchResults?.clients.find((c) => c.id === clientId)?.name ?? "?"} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">
|
||||
{searchResults?.clients.find((c) => c.id === clientId)?.name}
|
||||
</div>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-brand-600" />
|
||||
</div>
|
||||
)}
|
||||
{!clientId && search && searchResults && (
|
||||
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
|
||||
{searchResults.clients.length === 0 ? (
|
||||
@@ -360,7 +501,14 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setClientId(c.id);
|
||||
setPickedClientName(c.name);
|
||||
setPickedClientPhone(c.phone ?? "");
|
||||
setPickedClientEmail(c.email ?? "");
|
||||
setPickedClientTags(c.tags ?? "");
|
||||
setPickedClientNotes(c.notes ?? "");
|
||||
setPickedClientNoShows(Number(c.stats?.no_show_count ?? 0));
|
||||
setSearch(c.name);
|
||||
if (isEditing) setChangingClient(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
|
||||
>
|
||||
@@ -414,6 +562,8 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date / Time / Duration / Price */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { ChevronDown, UserCog, Check } from "lucide-react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||
|
||||
export function DemoSwitcher() {
|
||||
const { user, switchUser } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -13,6 +15,7 @@ export function DemoSwitcher() {
|
||||
api.auth.demoUsers().then((r) => setUsers(r.users)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!DEMO) return null;
|
||||
if (!user) return null;
|
||||
|
||||
const pick = async (email: string) => {
|
||||
@@ -31,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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// src/components/MonthCalendar.tsx
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/format";
|
||||
|
||||
const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
|
||||
const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
|
||||
|
||||
function toIso(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
function isoToDate(iso: string): Date {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
function atMidnight(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
export function MonthCalendar({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
availableDates,
|
||||
onSelect,
|
||||
}: {
|
||||
value: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
availableDates?: Set<string>;
|
||||
onSelect: (iso: string) => void;
|
||||
}) {
|
||||
const initial = value ? isoToDate(value) : new Date();
|
||||
const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
|
||||
const todayIso = toIso(new Date());
|
||||
const minDate = min ? atMidnight(isoToDate(min)) : null;
|
||||
const maxDate = max ? atMidnight(isoToDate(max)) : null;
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||
const lead = (first.getDay() + 6) % 7; // Lun primero
|
||||
const start = new Date(first);
|
||||
start.setDate(first.getDate() - lead);
|
||||
const out: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
out.push(d);
|
||||
}
|
||||
return out;
|
||||
}, [cursor]);
|
||||
|
||||
const inMonth = (d: Date) => d.getMonth() === cursor.getMonth();
|
||||
const selectable = (d: Date) => {
|
||||
const dm = atMidnight(d);
|
||||
if (minDate && dm < minDate) return false;
|
||||
if (maxDate && dm > maxDate) return false;
|
||||
return true;
|
||||
};
|
||||
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
|
||||
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
|
||||
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button type="button" onClick={() => move(-1)} disabled={!canPrev}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
aria-label="Mes anterior">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
|
||||
<button type="button" onClick={() => move(1)} disabled={!canNext}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
aria-label="Mes siguiente">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||
{WD.map((w) => (
|
||||
<div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((d, i) => {
|
||||
const dIso = toIso(d);
|
||||
const sel = dIso === value;
|
||||
const on = selectable(d);
|
||||
const inM = inMonth(d);
|
||||
const isToday = dIso === todayIso;
|
||||
const hasAvail = availableDates?.has(dIso);
|
||||
const weekend = d.getDay() === 0 || d.getDay() === 6;
|
||||
return (
|
||||
<button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
|
||||
className={cn(
|
||||
"relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
|
||||
!inM && "text-slate-300",
|
||||
inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
|
||||
inM && !on && "cursor-not-allowed text-slate-300",
|
||||
sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
|
||||
weekend && inM && !sel && on && "text-slate-500"
|
||||
)}>
|
||||
{d.getDate()}
|
||||
{hasAvail && !sel && (
|
||||
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
|
||||
)}
|
||||
{isToday && !sel && (
|
||||
<span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 sé 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 sí 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 sí 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 sí, 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
@@ -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 dé 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>
|
||||
|
||||
+488
-11
@@ -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,9 +44,24 @@ 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 */
|
||||
/* Evita el zoom por doble toque y el retardo de 300 ms en controles. */
|
||||
button,
|
||||
a,
|
||||
[role="button"],
|
||||
label,
|
||||
summary {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -43,6 +79,7 @@ body {
|
||||
background: #9ca3af;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* FullCalendar overrides */
|
||||
.fc {
|
||||
@@ -66,6 +103,12 @@ body {
|
||||
color: #0f172a;
|
||||
}
|
||||
.fc .fc-toolbar.fc-header-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #ffffff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin: -0.4rem -0.6rem 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
@@ -101,11 +144,18 @@ body {
|
||||
text-transform: capitalize;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.fc .fc-button:not(:disabled):hover {
|
||||
.fc .fc-button:not(:disabled):not(.fc-button-active):hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
||||
color: #ffffff;
|
||||
background: var(--fc-button-active-bg-color, #3b66ff);
|
||||
border-color: var(--fc-button-active-border-color, #3b66ff);
|
||||
}
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active:hover {
|
||||
background: #2447f5;
|
||||
border-color: #2447f5;
|
||||
color: #ffffff;
|
||||
}
|
||||
.fc .fc-button:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -136,6 +186,7 @@ body {
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-event:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -147,8 +198,36 @@ 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);
|
||||
}
|
||||
.fc-timegrid-event-harness {
|
||||
margin-right: 2px !important;
|
||||
}
|
||||
/* When slotEventOverlap is false, FullCalendar puts a small gap between
|
||||
side-by-side events; tighten it a touch so each event gets more readable
|
||||
width without bleeding into its neighbour. */
|
||||
.fc .fc-timegrid-col-events {
|
||||
column-gap: 2px;
|
||||
}
|
||||
|
||||
/* List view (great on mobile) */
|
||||
@@ -180,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 sí 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +379,48 @@ 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 sí 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,
|
||||
.select,
|
||||
.textarea {
|
||||
@@ -277,6 +440,320 @@ 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 sí 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;
|
||||
font-size: 0.78rem;
|
||||
|
||||
+25
-1
@@ -120,7 +120,8 @@ export const api = {
|
||||
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
dashboard: {
|
||||
overview: () => request<DashboardOverview>("/dashboard/overview"),
|
||||
overview: (range?: number) =>
|
||||
request<DashboardOverview>(`/dashboard/overview${range ? `?range=${range}` : ""}`),
|
||||
bestEmployees: (range = "30") => request<{ employees: RankedEmployee[] }>(`/dashboard/best-employees?range=${range}`),
|
||||
bestServices: (range = "30") => request<{ services: RankedService[] }>(`/dashboard/best-services?range=${range}`),
|
||||
topTickets: (range = "30", limit = 8) =>
|
||||
@@ -135,6 +136,29 @@ export const api = {
|
||||
tickets: (limit = 50) => request<{ tickets: Ticket[] }>(`/dashboard/tickets?limit=${limit}`),
|
||||
commissions: (range = "30") => request<{ rows: any[]; total: number }>(`/dashboard/commissions?range=${range}`),
|
||||
},
|
||||
me: {
|
||||
performance: () =>
|
||||
request<{
|
||||
appointments_today: number;
|
||||
upcoming: number;
|
||||
revenue_30d: number;
|
||||
avg_rating: number;
|
||||
share_pct: number;
|
||||
next_appointment: { start_at: string; service_name: string; client_name: string } | null;
|
||||
}>("/me/performance"),
|
||||
commissions: () =>
|
||||
request<{
|
||||
rows: {
|
||||
appointment_id: number | null;
|
||||
service_name: string;
|
||||
client_name: string;
|
||||
amount: number;
|
||||
commission: number;
|
||||
created_at: string;
|
||||
}[];
|
||||
total: number;
|
||||
}>("/me/commissions"),
|
||||
},
|
||||
settings: {
|
||||
get: () => request<{ settings: any }>("/settings"),
|
||||
update: (data: any) => request<{ settings: any }>("/settings", { method: "PATCH", body: JSON.stringify(data) }),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
+5
-13
@@ -1,3 +1,6 @@
|
||||
import type { BookResponse } from "../../shared/types";
|
||||
export type { BookResponse };
|
||||
|
||||
const BASE = "/api/public";
|
||||
|
||||
export interface PublicBusiness {
|
||||
@@ -12,6 +15,7 @@ export interface PublicBusiness {
|
||||
cancel_penalty_pct: number;
|
||||
require_deposit: boolean;
|
||||
deposit_pct: number;
|
||||
auto_assign_specialist: number | boolean;
|
||||
}
|
||||
|
||||
export interface PublicService {
|
||||
@@ -58,23 +62,11 @@ export interface BookClient {
|
||||
|
||||
export interface BookPayload {
|
||||
service_id: number;
|
||||
employee_id: number;
|
||||
employee_id?: number;
|
||||
start_at: string;
|
||||
client: BookClient;
|
||||
}
|
||||
|
||||
export interface BookResponse {
|
||||
appointment: {
|
||||
id: number;
|
||||
start_at: string;
|
||||
price: number;
|
||||
service_name: string;
|
||||
employee_name: string;
|
||||
};
|
||||
business: { name: string; currency_symbol: string };
|
||||
client_created: boolean;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
|
||||
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
|
||||
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
|
||||
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
|
||||
};
|
||||
export const DAYS = [
|
||||
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
|
||||
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
|
||||
];
|
||||
export function parseWh(v: any): Record<number, { start: string; end: string } | null> {
|
||||
let obj = v;
|
||||
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
|
||||
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
|
||||
const out: Record<number, any> = {};
|
||||
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
|
||||
return out;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user