Add web push notification system for user engagement and updates
Implement a web push notification system, including service worker integration, user subscription management, and an admin interface for sending broadcast messages. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: f585829f-898b-492f-82a5-11f4a76c87fb 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:
@@ -0,0 +1,31 @@
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = { title: "FOLX TV", body: "Neuer Inhalt verfügbar!", url: "/" };
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (e) {}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: "/favicon.png",
|
||||
badge: "/favicon.png",
|
||||
data: { url: data.url || "/" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
let url = event.notification.data?.url || "/";
|
||||
if (!url.startsWith("/") || url.startsWith("//")) url = "/";
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && "focus" in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import ImpressumPage from "@/pages/impressum";
|
||||
import DatenschutzPage from "@/pages/datenschutz";
|
||||
import KontaktPage from "@/pages/kontakt";
|
||||
import AdminGalleryPage from "@/pages/admin-gallery";
|
||||
import AdminPushPage from "@/pages/admin-push";
|
||||
import CookieConsent from "@/components/cookie-consent";
|
||||
|
||||
function ScrollToTop() {
|
||||
@@ -49,6 +50,7 @@ function Router() {
|
||||
<Route path="/datenschutz" component={DatenschutzPage} />
|
||||
<Route path="/kontakt" component={KontaktPage} />
|
||||
<Route path="/admin/gallery" component={AdminGalleryPage} />
|
||||
<Route path="/admin/push" component={AdminPushPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Menu, X, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import folxLogo from "@assets/folx_MT_poz_b_1772296729169.png";
|
||||
import PushNotificationBell from "./push-notification-bell";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Start", href: "/" },
|
||||
@@ -63,6 +64,7 @@ export default function Header() {
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<PushNotificationBell />
|
||||
{searchOpen ? (
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-1" data-testid="form-header-search">
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Bell, BellRing } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
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() {
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [supported, setSupported] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const checkSubscription = useCallback(async () => {
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||
setSupported(true);
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
if (reg) {
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
setIsSubscribed(!!sub);
|
||||
} else {
|
||||
setIsSubscribed(false);
|
||||
}
|
||||
} catch {
|
||||
setIsSubscribed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkSubscription();
|
||||
}, [checkSubscription]);
|
||||
|
||||
const subscribe = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const keyRes = await fetch("/api/push/vapid-key");
|
||||
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);
|
||||
toast({ title: "Benachrichtigungen aktiviert", description: "Sie werden über neue Inhalte informiert." });
|
||||
} catch (err: any) {
|
||||
if (Notification.permission === "denied") {
|
||||
toast({ title: "Benachrichtigungen blockiert", description: "Bitte erlauben Sie Benachrichtigungen in Ihren Browsereinstellungen.", variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: "Fehler", description: "Benachrichtigungen konnten nicht aktiviert werden.", variant: "destructive" });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
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);
|
||||
toast({ title: "Benachrichtigungen deaktiviert", description: "Sie erhalten keine weiteren Benachrichtigungen." });
|
||||
} catch {
|
||||
toast({ title: "Fehler", description: "Abmeldung fehlgeschlagen.", variant: "destructive" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 relative"
|
||||
onClick={isSubscribed ? unsubscribe : subscribe}
|
||||
disabled={isLoading}
|
||||
title={isSubscribed ? "Benachrichtigungen deaktivieren" : "Benachrichtigungen aktivieren"}
|
||||
data-testid="button-push-notifications"
|
||||
>
|
||||
{isSubscribed ? (
|
||||
<BellRing className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<Bell className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Send, Bell, Users, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
export default function AdminPushPage() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: countData } = useQuery<{ count: number }>({
|
||||
queryKey: ["/api/push/count"],
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/admin/push/send", { title, body, url: url || "/" });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
toast({
|
||||
title: "Push gesendet",
|
||||
description: `Erfolgreich: ${data.sent}, Fehlgeschlagen: ${data.failed} von ${data.total} Abonnenten`,
|
||||
});
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setUrl("");
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Fehler", description: "Push-Nachricht konnte nicht gesendet werden.", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="icon" data-testid="button-back-home">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Bell className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold text-foreground" data-testid="text-admin-push-title">Push-Benachrichtigungen</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Users className="w-5 h-5" />
|
||||
Abonnenten
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-primary" data-testid="text-subscriber-count">
|
||||
{countData?.count ?? 0}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">aktive Push-Abonnenten</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Send className="w-5 h-5" />
|
||||
Neue Benachrichtigung senden
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1 block">Titel</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Neuer Artikel auf FOLX TV"
|
||||
data-testid="input-push-title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1 block">Nachricht</label>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="z.B. Lesen Sie jetzt unseren neuesten Beitrag..."
|
||||
rows={3}
|
||||
data-testid="input-push-body"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1 block">Link (optional)</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="z.B. /article/mein-artikel-slug"
|
||||
data-testid="input-push-url"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leer lassen für Startseite</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={!title.trim() || !body.trim() || sendMutation.isPending}
|
||||
className="w-full"
|
||||
data-testid="button-send-push"
|
||||
>
|
||||
{sendMutation.isPending ? "Wird gesendet..." : `An ${countData?.count ?? 0} Abonnenten senden`}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user