Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -0,0 +1,73 @@
|
||||
"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 };
|
||||
}
|
||||
Reference in new issue
Block a user