142 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|