Files
Nexus/apps/web/src/components/layout/InstallPrompt.tsx
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

106 lines
3.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISS_KEY = "nexus_pwa_dismiss";
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function InstallPrompt() {
const [visible, setVisible] = useState(false);
const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
// Check if dismissed recently
try {
const dismissed = localStorage.getItem(DISMISS_KEY);
if (dismissed) {
const timestamp = parseInt(dismissed, 10);
if (Date.now() - timestamp < DISMISS_DURATION_MS) return;
}
} catch {
// localStorage unavailable
}
// Check if already installed (standalone mode)
if (window.matchMedia("(display-mode: standalone)").matches) return;
const handler = (e: Event) => {
e.preventDefault();
deferredPromptRef.current = e as BeforeInstallPromptEvent;
setVisible(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = useCallback(async () => {
const prompt = deferredPromptRef.current;
if (!prompt) return;
await prompt.prompt();
const { outcome } = await prompt.userChoice;
if (outcome === "accepted") {
setVisible(false);
}
deferredPromptRef.current = null;
}, []);
const handleDismiss = useCallback(() => {
setVisible(false);
deferredPromptRef.current = null;
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
// ignore
}
}, []);
if (!visible) return null;
return (
<div className="fixed bottom-20 left-4 right-4 z-50 mx-auto max-w-md animate-in slide-in-from-bottom-4 duration-300 sm:left-auto sm:right-6">
<div className="flex items-center gap-3 rounded-2xl border border-brand-200/60 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-brand-900/40 dark:bg-slate-900/95">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-md shadow-brand-600/25">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">Install Nexus</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Add to home screen for quick access
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={handleDismiss}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-800"
>
Later
</button>
<button
type="button"
onClick={() => void handleInstall()}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-brand-700"
>
Install
</button>
</div>
</div>
</div>
);
}