From f6478a76630f8b398569f1960af297f3fd9fefe3 Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Sat, 7 Mar 2026 15:16:12 +0000 Subject: [PATCH] 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 --- client/public/sw.js | 31 +++++ client/src/App.tsx | 2 + client/src/components/header.tsx | 2 + .../src/components/push-notification-bell.tsx | 126 ++++++++++++++++++ client/src/pages/admin-push.tsx | 122 +++++++++++++++++ package-lock.json | 10 ++ package.json | 1 + replit.md | 6 + server/routes.ts | 79 +++++++++++ server/storage.ts | 29 +++- shared/schema.ts | 16 +++ 11 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 client/public/sw.js create mode 100644 client/src/components/push-notification-bell.tsx create mode 100644 client/src/pages/admin-push.tsx diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..188a153 --- /dev/null +++ b/client/public/sw.js @@ -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); + }) + ); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index 6c88d8f..f3280bd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + diff --git a/client/src/components/header.tsx b/client/src/components/header.tsx index 66f9097..d1429fb 100644 --- a/client/src/components/header.tsx +++ b/client/src/components/header.tsx @@ -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() {
+ {searchOpen ? (
{ + 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 ( + + ); +} diff --git a/client/src/pages/admin-push.tsx b/client/src/pages/admin-push.tsx new file mode 100644 index 0000000..0989be7 --- /dev/null +++ b/client/src/pages/admin-push.tsx @@ -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 ( +
+
+
+ + + + +

Push-Benachrichtigungen

+
+ + + + + + Abonnenten + + + +

+ {countData?.count ?? 0} +

+

aktive Push-Abonnenten

+
+
+ + + + + + Neue Benachrichtigung senden + + + +
+
+ + setTitle(e.target.value)} + placeholder="z.B. Neuer Artikel auf FOLX TV" + data-testid="input-push-title" + /> +
+
+ +