Compare commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:36:46 -06:00
8 changed files with 437 additions and 58 deletions
+164 -1
View File
@@ -112,7 +112,170 @@ VERIFICACIÓN FINAL (todo en verde):
- WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin - WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin
objetivos <40px, sin errores objetivos <40px, sin errores
Sin commits (política del repo). `npm run lint` sigue roto por falta de eslint.config.js (preexistente). `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: FOLLOW-UPS DIFERIDOS:
1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta). 1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta).
+23
View File
@@ -290,6 +290,16 @@ números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico.
`prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un `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 `initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El
test lo verifica recorriendo las siete secciones. 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 ### Barra lateral
@@ -339,6 +349,19 @@ no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un t
`pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de `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`. `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 ## Convenciones de trabajo
- **No hagas commits salvo que se pidan explícitamente** (política registrada en - **No hagas commits salvo que se pidan explícitamente** (política registrada en
+19 -5
View File
@@ -44,6 +44,12 @@ npm run dev
Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000). 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 ## Producción
```bash ```bash
@@ -51,6 +57,11 @@ 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 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 ## 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. - **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.
@@ -71,10 +82,12 @@ PWA_PASSWORD=demo1234
## Tests y auditoría visual ## Tests y auditoría visual
```bash ```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: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:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide), npm run test:landing # WebKit: ruteo landing/login, acceso de un clic y prefers-reduced-motion
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/ 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, La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow,
@@ -82,7 +95,8 @@ errores de consola y confirma que el calendario, el modal de cita y el cambio de
## Cuentas demo ## 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 | Contraseña | Nombre | | Rol | Email | Contraseña | Nombre |
|----------|----------------------------------|------------|------------------| |----------|----------------------------------|------------|------------------|
@@ -1,6 +1,6 @@
import { motion } from "framer-motion"; import { useRef } from "react";
import { EASE_EXPO, inView } from "../../../lib/motion"; import { useInView } from "framer-motion";
import { useReducedMotion } from "../../../lib/useReducedMotion"; import { inView } from "../../../lib/motion";
// Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a // 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. // propósito, para que el audit visual capture siempre la misma gráfica.
@@ -23,14 +23,29 @@ function pathFrom(serie: number[]): string {
.join(" "); .join(" ");
} }
/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */ /**
* 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 }) { export function RevenueLineVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion(); 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 d = pathFrom(SERIE);
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`; const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
return ( return (
<svg <svg
ref={ref}
data-ld-rev-visible={visible ? "true" : "false"}
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`} className={`w-full ${className}`}
role="img" role="img"
@@ -57,36 +72,26 @@ export function RevenueLineVisual({ className = "" }: { className?: string }) {
/> />
))} ))}
<motion.path <path className="ld-rev-area" d={area} fill="url(#ld-rev-fill)" />
d={area} {/* `pathLength={1}` normaliza la longitud: deja que dasharray y dashoffset se
fill="url(#ld-rev-fill)" expresen como fracción del trazo, que es lo que animan los keyframes. */}
initial={{ opacity: reduced ? 1 : 0 }} <path
whileInView={{ opacity: 1 }} className="ld-rev-linea"
viewport={inView}
transition={{ duration: 0.9, delay: reduced ? 0 : 0.5, ease: EASE_EXPO }}
/>
<motion.path
d={d} d={d}
pathLength={1}
fill="none" fill="none"
stroke="#3b66ff" stroke="#3b66ff"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
initial={{ pathLength: reduced ? 1 : 0 }}
whileInView={{ pathLength: 1 }}
viewport={inView}
transition={{ duration: reduced ? 0 : 1.4, ease: EASE_EXPO }}
/> />
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */} {/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
<motion.circle <circle
className="ld-rev-punto"
cx={W - PAD} cx={W - PAD}
cy={yDe(SERIE[SERIE.length - 1])} cy={yDe(SERIE[SERIE.length - 1])}
r="5" r="5"
fill="#3b66ff" fill="#3b66ff"
initial={{ scale: reduced ? 1 : 0 }}
whileInView={{ scale: 1 }}
viewport={inView}
transition={{ duration: 0.5, delay: reduced ? 0 : 1.3, ease: EASE_EXPO }}
/> />
</svg> </svg>
); );
+77
View File
@@ -624,6 +624,83 @@ summary {
color: transparent; 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) ---- /* ---- Anti auto-zoom de iOS (fuera de @layer para ganar a las utilidades) ----
+82
View File
@@ -0,0 +1,82 @@
/**
* Captura de `beforeinstallprompt` a nivel de módulo, no dentro de un hook.
*
* El navegador dispara `beforeinstallprompt` **una sola vez** por carga de página, poco
* después del `load`, y no lo repite: no hay forma de volver a pedirlo. Un listener que
* se registra al montar un componente sencillamente se lo pierde si el componente aparece
* más tarde.
*
* Y aparece más tarde: `LoginPage` se carga con `React.lazy`, así que `InstallAppPrompt`
* monta después de una ida y vuelta de red extra para traer su chunk. En local eso es un
* milisegundo y no se nota; sobre una conexión real el evento ya pasó y el botón de
* instalar no aparecía nunca. `pwa-e2e.mjs` contra el dominio HTTPS lo detecta; contra
* `localhost` no, porque ahí el chunk llega antes que el evento.
*
* Por eso el listener se registra al evaluar este módulo, que `main.tsx` importa antes de
* montar React, y el evento queda guardado hasta que alguien lo pida.
*/
export interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
}
let capturado: BeforeInstallPromptEvent | null = null;
let instalado = false;
const suscriptores = new Set<() => void>();
function avisar() {
for (const notificar of suscriptores) notificar();
}
if (typeof window !== "undefined") {
window.addEventListener("beforeinstallprompt", (event) => {
// `preventDefault` evita el mini-infobar de Chrome; el botón propio queda a cargo.
event.preventDefault();
capturado = event as BeforeInstallPromptEvent;
avisar();
});
window.addEventListener("appinstalled", () => {
capturado = null;
instalado = true;
avisar();
});
}
/** El evento pendiente, o null si el navegador nunca lo disparó. */
export function eventoDisponible(): BeforeInstallPromptEvent | null {
return capturado;
}
export function yaInstalado(): boolean {
return instalado;
}
/** Lo saca del almacén: un `BeforeInstallPromptEvent` solo se puede usar una vez. */
export function tomarEvento(): BeforeInstallPromptEvent | null {
const evento = capturado;
if (evento) {
capturado = null;
avisar();
}
return evento;
}
/** Lo devuelve si `prompt()` falló, para no perder la única oportunidad de instalar. */
export function devolverEvento(evento: BeforeInstallPromptEvent) {
capturado = evento;
avisar();
}
export function marcarInstalado() {
capturado = null;
instalado = true;
avisar();
}
export function suscribir(notificar: () => void): () => void {
suscriptores.add(notificar);
return () => {
suscriptores.delete(notificar);
};
}
+40 -29
View File
@@ -1,12 +1,15 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import {
devolverEvento,
eventoDisponible,
marcarInstalado,
suscribir,
tomarEvento,
yaInstalado,
} from "./installPrompt";
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed"; export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
}
const DISMISSED_STORAGE_KEY = "agendamax-install-dismissed"; const DISMISSED_STORAGE_KEY = "agendamax-install-dismissed";
function hasDismissedPrompt() { function hasDismissedPrompt() {
@@ -44,13 +47,18 @@ function isIOSSafari() {
return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua); 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() { export function useInstallPrompt() {
const [state, setState] = useState<InstallPromptState>(() => { const [state, setState] = useState<InstallPromptState>(() => {
if (typeof window === "undefined") return "unsupported"; if (typeof window === "undefined") return "unsupported";
if (isStandalone()) return "installed"; if (isStandalone() || yaInstalado()) return "installed";
if (eventoDisponible()) return hasDismissedPrompt() ? "unsupported" : "available";
return isIOSSafari() ? "ios-instructions" : "unsupported"; return isIOSSafari() ? "ios-instructions" : "unsupported";
}); });
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(() => typeof window !== "undefined" && hasDismissedPrompt()); const [dismissed, setDismissed] = useState(() => typeof window !== "undefined" && hasDismissedPrompt());
const installInFlight = useRef(false); const installInFlight = useRef(false);
@@ -60,41 +68,44 @@ export function useInstallPrompt() {
return; return;
} }
const onBeforeInstallPrompt = (event: Event) => { const sincronizar = () => {
event.preventDefault(); if (yaInstalado()) {
setDeferred(event as BeforeInstallPromptEvent); // Instalar borra el descarte: el aviso ya cumplió su función.
if (!hasDismissedPrompt()) setState("available"); clearDismissedPrompt();
}; setDismissed(false);
const onInstalled = () => { setState("installed");
setDeferred(null); return;
clearDismissedPrompt(); }
setDismissed(false); if (eventoDisponible()) {
setState("installed"); if (!hasDismissedPrompt()) setState("available");
return;
}
// Sin evento pendiente: se cae al comportamiento de iOS, que nunca lo dispara.
setState(isIOSSafari() ? "ios-instructions" : "unsupported");
}; };
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); sincronizar();
window.addEventListener("appinstalled", onInstalled); return suscribir(sincronizar);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []); }, []);
const install = async () => { const install = async () => {
if (!deferred || installInFlight.current) return; if (installInFlight.current) return;
const event = deferred; const event = tomarEvento();
if (!event) return;
installInFlight.current = true; installInFlight.current = true;
setDeferred(null);
try { try {
await event.prompt(); await event.prompt();
const choice = await event.userChoice; const choice = await event.userChoice;
if (choice.outcome === "accepted") setState("installed"); if (choice.outcome === "accepted") {
else { marcarInstalado();
setState("installed");
} else {
persistDismissedPrompt(); persistDismissedPrompt();
setDismissed(true); setDismissed(true);
} }
} catch { } catch {
setDeferred(event); // Devolver el evento al almacén: es la única oportunidad de instalar de esta carga.
devolverEvento(event);
setState("available"); setState("available");
} finally { } finally {
installInFlight.current = false; installInFlight.current = false;
+4
View File
@@ -2,6 +2,10 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 App from "./App.tsx";
import "./index.css"; import "./index.css";