improve: smarter install prompt — visit gate + snooze instead of permanent dismiss

Old behavior: dismiss once -> never shown again forever.
New behavior:
- Only shows after 3+ sessions (user has seen value first)
- "Later" -> snoozes for 7 days, re-asks after that
- "No thanks" -> snoozes for 30 days
- Once actually installed (Android accepted) -> permanently hidden
- iOS: two buttons (Later / No thanks) instead of bare X, so intent is clear
- Android: tapping X now snoozes rather than permanently suppressing
- Dark mode support added to both banners

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-29 01:00:52 +05:30
parent 3cfcbdc0ca
commit 093903162e

View file

@ -8,98 +8,158 @@ interface BeforeInstallPromptEvent extends Event {
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
} }
function IOSInstallInstructions({ onDismiss }: { onDismiss: () => void }) { // ─── Storage keys ────────────────────────────────────────────────────────────
const KEY_SNOOZED_UNTIL = "tia_install_snoozed_until"; // timestamp ms — re-ask after this
const KEY_VISIT_COUNT = "tia_install_visit_count"; // int — increment on each page load
const KEY_INSTALLED = "tia_installed"; // "1" once the user installs
// How many sessions before we first ask (0-indexed — show on the 3rd session)
const MIN_VISITS = 3;
// How long to wait before re-asking after a "Later" tap (7 days)
const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
// After a hard "No thanks" (second dismiss) wait 30 days
const LONG_SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
function isSnoozed(): boolean {
const until = localStorage.getItem(KEY_SNOOZED_UNTIL);
return !!until && Date.now() < Number(until);
}
function snooze(hard = false) {
localStorage.setItem(KEY_SNOOZED_UNTIL, String(Date.now() + (hard ? LONG_SNOOZE_MS : SNOOZE_MS)));
}
function incrementVisit(): number {
const current = Number(localStorage.getItem(KEY_VISIT_COUNT) || "0");
const next = current + 1;
localStorage.setItem(KEY_VISIT_COUNT, String(next));
return next;
}
// ─── iOS instructions banner ─────────────────────────────────────────────────
function IOSInstallInstructions({
onLater, onNo,
}: { onLater: () => void; onNo: () => void }) {
return ( return (
<div className="fixed bottom-4 left-4 right-4 z-50 bg-white border border-rose-100 rounded-2xl shadow-lg p-4"> <div className="fixed bottom-4 left-4 right-4 z-50 bg-white dark:bg-gray-800 border border-rose-100 dark:border-gray-700 rounded-2xl shadow-xl p-4">
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">🌸</span> <span className="text-2xl">🌸</span>
<span className="font-semibold text-gray-900">Install Tia</span> <div>
<p className="font-semibold text-gray-900 dark:text-white text-sm">Add Tia to Home Screen</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Works like a native app loads instantly</p>
</div>
</div> </div>
<button
onClick={onDismiss}
className="text-gray-400 text-xl leading-none p-1"
aria-label="Dismiss"
>
</button>
</div> </div>
<p className="text-sm text-gray-600 mb-2"> <div className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 mb-3 bg-gray-50 dark:bg-gray-700/50 rounded-xl px-3 py-2">
Add Tia to your home screen for the best experience.
</p>
<div className="flex items-center gap-2 text-sm text-gray-700">
<span>Tap</span> <span>Tap</span>
<span className="inline-flex items-center justify-center w-7 h-7 bg-gray-100 rounded-md text-base"></span> <span className="inline-flex items-center justify-center w-7 h-7 bg-white dark:bg-gray-600 rounded-md text-base shadow-sm border border-gray-200 dark:border-gray-500"></span>
<span>then</span> <span>then choose</span>
<span className="font-medium">"Add to Home Screen"</span> <span className="font-semibold">"Add to Home Screen"</span>
</div>
<div className="flex gap-2">
<button
onClick={onNo}
className="flex-1 py-2 rounded-xl text-sm text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700"
>
No thanks
</button>
<button
onClick={onLater}
className="flex-1 py-2 rounded-xl text-sm font-semibold bg-rose-400 text-white"
>
Remind me later
</button>
</div> </div>
</div> </div>
); );
} }
const DISMISSED_KEY = "tia_install_prompt_dismissed"; // ─── Main component ───────────────────────────────────────────────────────────
export function InstallPrompt() { export function InstallPrompt() {
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null); const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [isIOS, setIsIOS] = useState(false); const [isIOS, setIsIOS] = useState(false);
const [dismissed, setDismissed] = useState(true); // start hidden to avoid flash const [show, setShow] = useState(false); // start hidden to avoid flash
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const alreadyDismissed = localStorage.getItem(DISMISSED_KEY) === "1"; // Already installed as PWA — never show
if (alreadyDismissed) return; if (window.matchMedia("(display-mode: standalone)").matches) return;
if (localStorage.getItem(KEY_INSTALLED) === "1") return;
const standalone = window.matchMedia("(display-mode: standalone)").matches; // Track visit count; don't ask until the user has had a few sessions
if (standalone) return; // already installed const visits = incrementVisit();
if (visits < MIN_VISITS) return;
// iOS Safari: no beforeinstallprompt, needs manual instructions // Currently snoozed from a previous "Later" tap
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent); if (isSnoozed()) return;
// iOS Safari — no native install event, we show manual instructions
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (ios && isSafari) { if (ios && isSafari) {
setIsIOS(true); setIsIOS(true);
setDismissed(false); setShow(true);
} }
// Android / Chrome: capture the deferred install event // Android / Chrome — capture the browser's deferred install prompt
const handler = (e: Event) => { const handler = (e: Event) => {
e.preventDefault(); e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent); setDeferred(e as BeforeInstallPromptEvent);
setDismissed(false); setShow(true);
}; };
window.addEventListener("beforeinstallprompt", handler); window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler); return () => window.removeEventListener("beforeinstallprompt", handler);
}, []); }, []);
const handleDismiss = () => { // "Remind me later" — snooze for 7 days
setDismissed(true); const handleLater = () => {
localStorage.setItem(DISMISSED_KEY, "1"); setShow(false);
snooze(false);
}; };
// "No thanks" — snooze for 30 days
const handleNo = () => {
setShow(false);
snooze(true);
};
// Android: user tapped Install
const handleInstall = async () => { const handleInstall = async () => {
if (!deferred) return; if (!deferred) return;
await deferred.prompt(); await deferred.prompt();
const { outcome } = await deferred.userChoice; const { outcome } = await deferred.userChoice;
if (outcome === "accepted") { if (outcome === "accepted") {
localStorage.setItem(KEY_INSTALLED, "1");
setDeferred(null); setDeferred(null);
setDismissed(true); setShow(false);
} else {
// They dismissed the native prompt — snooze our custom one too
handleLater();
} }
}; };
if (dismissed) return null; if (!show) return null;
// iOS: show manual instructions
if (isIOS) {
return <IOSInstallInstructions onLater={handleLater} onNo={handleNo} />;
}
// Android / Chrome: show install button
if (deferred) { if (deferred) {
return ( return (
<div className="fixed bottom-4 left-4 right-4 z-50 bg-white border border-rose-100 rounded-2xl shadow-lg p-4 flex items-center gap-3"> <div className="fixed bottom-4 left-4 right-4 z-50 bg-white dark:bg-gray-800 border border-rose-100 dark:border-gray-700 rounded-2xl shadow-xl p-4 flex items-center gap-3">
<span className="text-2xl">🌸</span> <span className="text-2xl">🌸</span>
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-gray-900 text-sm">Install Tia</p> <p className="font-semibold text-gray-900 dark:text-white text-sm">Install Tia</p>
<p className="text-xs text-gray-500">Add to home screen for quick access</p> <p className="text-xs text-gray-500 dark:text-gray-400">Add to home screen loads instantly</p>
</div> </div>
<button onClick={handleDismiss} className="text-gray-400 p-1 text-lg" aria-label="Dismiss"></button> <button onClick={handleLater} className="text-gray-400 dark:text-gray-500 p-1 text-lg" aria-label="Later"></button>
<button <button
onClick={handleInstall} onClick={handleInstall}
className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-semibold" className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-semibold whitespace-nowrap"
> >
Install Install
</button> </button>
@ -107,9 +167,5 @@ export function InstallPrompt() {
); );
} }
if (isIOS) {
return <IOSInstallInstructions onDismiss={handleDismiss} />;
}
return null; return null;
} }