Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user