feat(pwa): add useInstallPrompt hook with tests
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c80a6fa94b
commit
e23013b0af
2 changed files with 110 additions and 0 deletions
60
apps/main/src/hooks/useInstallPrompt.test.ts
Normal file
60
apps/main/src/hooks/useInstallPrompt.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInstallPrompt } from "./useInstallPrompt";
|
||||
|
||||
const DISMISSED_KEY = "pwa-install-dismissed";
|
||||
|
||||
describe("useInstallPrompt", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("starts with canInstall false and isStandalone false", () => {
|
||||
const { result } = renderHook(() => useInstallPrompt());
|
||||
expect(result.current.canInstall).toBe(false);
|
||||
expect(result.current.isStandalone).toBe(false);
|
||||
expect(result.current.isDismissed).toBe(false);
|
||||
});
|
||||
|
||||
it("captures beforeinstallprompt event and sets canInstall to true", () => {
|
||||
const { result } = renderHook(() => useInstallPrompt());
|
||||
|
||||
const event = new Event("beforeinstallprompt");
|
||||
Object.assign(event, { preventDefault: vi.fn(), prompt: vi.fn() });
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.canInstall).toBe(true);
|
||||
});
|
||||
|
||||
it("dismiss persists to localStorage and sets isDismissed", () => {
|
||||
const { result } = renderHook(() => useInstallPrompt());
|
||||
|
||||
act(() => {
|
||||
result.current.dismiss();
|
||||
});
|
||||
|
||||
expect(result.current.isDismissed).toBe(true);
|
||||
expect(localStorage.getItem(DISMISSED_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("reads dismissed state from localStorage on mount", () => {
|
||||
localStorage.setItem(DISMISSED_KEY, "true");
|
||||
const { result } = renderHook(() => useInstallPrompt());
|
||||
expect(result.current.isDismissed).toBe(true);
|
||||
});
|
||||
|
||||
it("detects iOS Safari", () => {
|
||||
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
);
|
||||
const { result } = renderHook(() => useInstallPrompt());
|
||||
expect(result.current.isIOS).toBe(true);
|
||||
});
|
||||
});
|
||||
50
apps/main/src/hooks/useInstallPrompt.ts
Normal file
50
apps/main/src/hooks/useInstallPrompt.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const DISMISSED_KEY = "pwa-install-dismissed";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export function useInstallPrompt() {
|
||||
const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null);
|
||||
const [canInstall, setCanInstall] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(
|
||||
() => localStorage.getItem(DISMISSED_KEY) === "true"
|
||||
);
|
||||
|
||||
const isStandalone =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
(window.matchMedia("(display-mode: standalone)")?.matches ?? false);
|
||||
|
||||
const isIOS =
|
||||
typeof navigator !== "undefined" &&
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt.current = e as BeforeInstallPromptEvent;
|
||||
setCanInstall(true);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||
}, []);
|
||||
|
||||
const promptInstall = useCallback(async () => {
|
||||
if (!deferredPrompt.current) return;
|
||||
const result = await deferredPrompt.current.prompt();
|
||||
if (result.outcome === "accepted") {
|
||||
deferredPrompt.current = null;
|
||||
setCanInstall(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem(DISMISSED_KEY, "true");
|
||||
}, []);
|
||||
|
||||
return { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss };
|
||||
}
|
||||
Loading…
Reference in a new issue