Files
NextChair/src/features/pwa/hooks/use-install-prompt.ts
T
2026-08-04 09:57:58 +01:00

74 lines
2.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
export interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari's own (non-standard, but the only signal it exposes).
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isIos(): boolean {
if (typeof navigator === "undefined") return false;
return /iphone|ipad|ipod/i.test(navigator.userAgent);
}
/**
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
* one PWA install implementation both `InstallAppButton` (Settings, every
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
* See the brief's explicit "do not create a competing second installer".
* All detection lives in `useEffect`, never a `useState` lazy
* initializer — reading `window`/`navigator` during the initializer would
* run during SSR too and mismatch the client's first render (the same
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
*/
export function useInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(false);
const [ios, setIos] = useState(false);
useEffect(() => {
// Async chain, not a direct setState call in the effect body — same
// pattern push-subscribe-toggle.tsx uses (see that file's own
// comment) to satisfy react-hooks/set-state-in-effect while still
// reading browser-only APIs that can't run during SSR.
void Promise.resolve().then(() => {
setInstalled(isStandalone());
setIos(isIos());
});
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const promptInstall = async (): Promise<void> => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
await deferredPrompt.userChoice;
setDeferredPrompt(null);
};
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
}