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:
sebastjanartic
2026-03-07 15:16:12 +00:00
parent 8e7dc999f0
commit f6478a7663
11 changed files with 423 additions and 1 deletions
+2
View File
@@ -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>
);
}