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:
parent
3cfcbdc0ca
commit
093903162e
1 changed files with 100 additions and 44 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue