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]>
117 lines
3.5 KiB
TypeScript
117 lines
3.5 KiB
TypeScript
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) };
|
|
}
|