Compare commits
3
Commits
4c19244df9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0069d23744 | ||
|
|
4281567207 | ||
|
|
842a9ea07e |
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) ----
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user