Add a prompt to encourage users to subscribe to push notifications

Introduce a new push notification prompt banner that appears on the homepage after a delay, encouraging users to subscribe to notifications. The banner will not appear if the user is already subscribed, has previously dismissed it, or if push notifications are not supported. Refactor existing push notification logic to be more modular and reusable across components.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: cf18a9e4-eed8-448c-9c3c-694f18134403
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/ICRgny1
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic 2026-03-07 15:21:17 +00:00
parent d5b8134b9d
commit 2c74322b1c
6 changed files with 99 additions and 60 deletions

BIN
after-dismiss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
after-reload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -2,17 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { Bell, BellRing } from "lucide-react"; import { Bell, BellRing } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { isPushSupported, getExistingSubscription, subscribeToPush, unsubscribeFromPush } from "@/lib/push-utils";
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default function PushNotificationBell() { export default function PushNotificationBell() {
const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false);
@ -21,19 +11,10 @@ export default function PushNotificationBell() {
const { toast } = useToast(); const { toast } = useToast();
const checkSubscription = useCallback(async () => { const checkSubscription = useCallback(async () => {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return; if (!isPushSupported()) return;
setSupported(true); setSupported(true);
try { const sub = await getExistingSubscription();
const reg = await navigator.serviceWorker.getRegistration("/sw.js");
if (reg) {
const sub = await reg.pushManager.getSubscription();
setIsSubscribed(!!sub); setIsSubscribed(!!sub);
} else {
setIsSubscribed(false);
}
} catch {
setIsSubscribed(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -43,32 +24,10 @@ export default function PushNotificationBell() {
const subscribe = async () => { const subscribe = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const keyRes = await fetch("/api/push/vapid-key"); await subscribeToPush();
const { publicKey } = await keyRes.json();
if (!publicKey) throw new Error("VAPID key not configured");
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const subJson = sub.toJSON();
const res = await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
}),
});
if (!res.ok) throw new Error("Subscription save failed");
setIsSubscribed(true); setIsSubscribed(true);
toast({ title: "Benachrichtigungen aktiviert", description: "Sie werden über neue Inhalte informiert." }); toast({ title: "Benachrichtigungen aktiviert", description: "Sie werden über neue Inhalte informiert." });
} catch (err: any) { } catch {
if (Notification.permission === "denied") { if (Notification.permission === "denied") {
toast({ title: "Benachrichtigungen blockiert", description: "Bitte erlauben Sie Benachrichtigungen in Ihren Browsereinstellungen.", variant: "destructive" }); toast({ title: "Benachrichtigungen blockiert", description: "Bitte erlauben Sie Benachrichtigungen in Ihren Browsereinstellungen.", variant: "destructive" });
} else { } else {
@ -82,19 +41,7 @@ export default function PushNotificationBell() {
const unsubscribe = async () => { const unsubscribe = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const reg = await navigator.serviceWorker.getRegistration("/sw.js"); await unsubscribeFromPush();
if (reg) {
const sub = await reg.pushManager.getSubscription();
if (sub) {
const res = await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
if (!res.ok) throw new Error("Unsubscribe failed");
await sub.unsubscribe();
}
}
setIsSubscribed(false); setIsSubscribed(false);
toast({ title: "Benachrichtigungen deaktiviert", description: "Sie erhalten keine weiteren Benachrichtigungen." }); toast({ title: "Benachrichtigungen deaktiviert", description: "Sie erhalten keine weiteren Benachrichtigungen." });
} catch { } catch {

View File

@ -0,0 +1,90 @@
import { useState, useEffect } from "react";
import { Bell, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { isPushSupported, getExistingSubscription, subscribeToPush } from "@/lib/push-utils";
const DISMISS_KEY = "folx-push-prompt-dismissed";
export default function PushPromptBanner() {
const [visible, setVisible] = useState(false);
const { toast } = useToast();
useEffect(() => {
if (!isPushSupported()) return;
if (localStorage.getItem(DISMISS_KEY)) return;
if (Notification.permission === "denied") return;
const timer = setTimeout(async () => {
const sub = await getExistingSubscription();
if (!sub) setVisible(true);
}, 5000);
return () => clearTimeout(timer);
}, []);
const dismiss = () => {
setVisible(false);
localStorage.setItem(DISMISS_KEY, "1");
};
const accept = async () => {
try {
await subscribeToPush();
toast({ title: "Benachrichtigungen aktiviert", description: "Sie werden über neue Inhalte informiert." });
} catch {
if (Notification.permission === "denied") {
toast({ title: "Benachrichtigungen blockiert", description: "Bitte erlauben Sie Benachrichtigungen in Ihren Browsereinstellungen.", variant: "destructive" });
}
}
dismiss();
};
if (!visible) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-6 md:max-w-sm z-50 animate-in slide-in-from-bottom-4 fade-in duration-500" data-testid="push-prompt-banner">
<div className="bg-card border border-card-border rounded-xl shadow-2xl p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Bell className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-sm" data-testid="text-push-prompt-title">
Nichts mehr verpassen!
</p>
<p className="text-xs text-muted-foreground mt-1">
Erhalten Sie Benachrichtigungen über neue Artikel und Videos von FOLX TV.
</p>
<div className="flex items-center gap-2 mt-3">
<Button
size="sm"
onClick={accept}
className="text-xs h-8"
data-testid="button-push-prompt-accept"
>
Ja, gerne!
</Button>
<Button
variant="ghost"
size="sm"
onClick={dismiss}
className="text-xs h-8 text-muted-foreground"
data-testid="button-push-prompt-dismiss"
>
Nein, danke
</Button>
</div>
</div>
<button
onClick={dismiss}
className="text-muted-foreground hover:text-foreground transition-colors"
data-testid="button-push-prompt-close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}

View File

@ -15,6 +15,7 @@ import { RecipeWidget } from "@/components/recipe-widget";
import { NewsWidget } from "@/components/news-widget"; import { NewsWidget } from "@/components/news-widget";
import { SidebarWeatherWidget } from "@/components/weather-widget"; import { SidebarWeatherWidget } from "@/components/weather-widget";
import { BreakingNewsWidget } from "@/components/breaking-news-widget"; import { BreakingNewsWidget } from "@/components/breaking-news-widget";
import PushPromptBanner from "@/components/push-prompt-banner";
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react"; import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
function useFocalPoints() { function useFocalPoints() {
@ -748,6 +749,7 @@ export default function Home() {
</main> </main>
<Footer /> <Footer />
<PushPromptBanner />
</div> </div>
); );
} }

BIN
push-prompt-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB