Files
vyndr/web/src/components/InstallPrompt.tsx
T

142 lines
4.4 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
// PWA install banner. Shown only after the user has completed ≥2 Reads — we
// don't want to nag visitors before they've seen the product work. Trigger
// counter is incremented elsewhere via incrementReadCount() in lib/reads.ts.
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
const READS_KEY = 'vyndr_reads_completed';
const DISMISSED_KEY = 'vyndr_install_dismissed';
const REQUIRED_READS = 2;
const DISMISSAL_COOLDOWN_DAYS = 7;
function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
if (window.matchMedia('(display-mode: standalone)').matches) return true;
// iOS Safari exposes navigator.standalone only for installed PWAs.
const nav = window.navigator as Navigator & { standalone?: boolean };
return nav.standalone === true;
}
function isIOS(): boolean {
if (typeof window === 'undefined') return false;
const ua = window.navigator.userAgent;
return /iPad|iPhone|iPod/.test(ua) && !(window as unknown as { MSStream?: unknown }).MSStream;
}
function readsCompleted(): number {
if (typeof window === 'undefined') return 0;
const raw = window.localStorage.getItem(READS_KEY);
return raw ? parseInt(raw, 10) || 0 : 0;
}
function dismissedRecently(): boolean {
if (typeof window === 'undefined') return false;
const raw = window.localStorage.getItem(DISMISSED_KEY);
if (!raw) return false;
const ts = parseInt(raw, 10);
if (!ts) return false;
const ageDays = (Date.now() - ts) / (1000 * 60 * 60 * 24);
return ageDays < DISMISSAL_COOLDOWN_DAYS;
}
export default function InstallPrompt() {
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
const [iosHint, setIosHint] = useState(false);
useEffect(() => {
if (isStandalone()) return;
if (readsCompleted() < REQUIRED_READS) return;
if (dismissedRecently()) return;
if (isIOS()) {
// iOS doesn't fire beforeinstallprompt — show manual instructions.
setIosHint(true);
setVisible(true);
return;
}
const onBeforeInstall = (e: Event) => {
e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent);
setVisible(true);
};
window.addEventListener('beforeinstallprompt', onBeforeInstall);
return () => window.removeEventListener('beforeinstallprompt', onBeforeInstall);
}, []);
const handleInstall = async () => {
if (!deferred) return;
await deferred.prompt();
const choice = await deferred.userChoice;
if (choice.outcome === 'dismissed') {
window.localStorage.setItem(DISMISSED_KEY, String(Date.now()));
}
setVisible(false);
setDeferred(null);
};
const handleDismiss = () => {
window.localStorage.setItem(DISMISSED_KEY, String(Date.now()));
setVisible(false);
};
if (!visible) return null;
return (
<div
role="dialog"
aria-label="Install VYNDR"
className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md rounded-lg border p-4 shadow-lg"
style={{
background: 'var(--bg-surface)',
borderColor: 'var(--border-light)',
color: 'var(--text-primary)',
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Install VYNDR
</div>
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{iosHint
? 'Tap the Share button, then "Add to Home Screen" for instant access.'
: 'Add VYNDR to your home screen for instant access.'}
</div>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss install prompt"
className="rounded p-1 text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
</button>
</div>
{!iosHint && (
<button
type="button"
onClick={handleInstall}
className="mt-3 w-full rounded px-3 py-2 text-sm font-semibold"
style={{
background: 'var(--grade-a)',
color: 'var(--bg-0)',
}}
>
Install
</button>
)}
</div>
);
}