Kuma/src/components/NotificationPrompt.tsx
2026-09-14 16:26:18 +02:00

154 lines
5.8 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { X, BellRing } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { INSTALL_PROMPT_SETTLED_EVENT, isInstallPromptPending } from "@/src/lib/pwa";
import {
subscribeToPush,
unsubscribeFromPush,
} from "@/src/modules/notifications/actions/push-subscription-actions";
const DISMISSED_KEY = "kumasy-notification-prompt-dismissed";
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const output = new Uint8Array(new ArrayBuffer(rawData.length));
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i);
return output;
}
function isPushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
// Lazy-Initializer statt Effect (siehe InstallPrompt.tsx): Browser-API-Werte
// werden serverseitig identisch mit dem allerersten Client-Render (false)
// beantwortet, kein Hydration-Mismatch möglich.
function computeEligible(): boolean {
if (typeof window === "undefined") return false;
if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) return false;
if (!isPushSupported()) return false;
if (Notification.permission !== "default") return false; // schon erlaubt oder blockiert
// Wartet, bis InstallPrompt nicht mehr sichtbar ist (installiert oder
// weggeklickt) - sonst überlappen sich beide "fixed bottom-4"-Banner.
if (isInstallPromptPending()) return false;
return localStorage.getItem(DISMISSED_KEY) !== "1";
}
export function NotificationPrompt() {
const [eligible, setEligible] = useState(computeEligible);
const [isSubscribing, setIsSubscribing] = useState(false);
// Reagiert auf InstallPrompt, das erst nach unserem Mount weggeklickt wird
// (siehe computeEligible) - ansonsten bliebe eligible dauerhaft false.
useEffect(() => {
const handleSettled = () => setEligible(computeEligible());
window.addEventListener(INSTALL_PROMPT_SETTLED_EVENT, handleSettled);
return () => window.removeEventListener(INSTALL_PROMPT_SETTLED_EVENT, handleSettled);
}, []);
const dismiss = () => {
localStorage.setItem(DISMISSED_KEY, "1");
setEligible(false);
};
const handleEnable = async () => {
setIsSubscribing(true);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
dismiss();
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
});
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
throw new Error("Unvollständige Push-Subscription vom Browser erhalten.");
}
const result = await subscribeToPush(
{ endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth } },
navigator.userAgent
);
if (!result.success) {
// Serverseitig nicht gespeichert - Subscription auf dem Gerät nicht
// verwaist zurücklassen, sonst denkt der Browser dauerhaft, er sei
// angemeldet, ohne dass Push je zugestellt werden könnte.
await subscription.unsubscribe().catch(() => {});
throw new Error(result.error);
}
dismiss();
} catch (error) {
console.error("Push-Aktivierung fehlgeschlagen:", error);
dismiss();
} finally {
setIsSubscribing(false);
}
};
if (!eligible) return null;
return (
<div className="fixed bottom-4 inset-x-4 z-50 sm:left-auto sm:right-4 sm:w-80">
<div className="border border-white/60 bg-white/80 backdrop-blur-xl rounded-xl shadow-lg p-4">
<div className="flex items-start gap-3">
<div className="shrink-0 flex items-center justify-center size-9 rounded-lg bg-yellow-50 text-yellow-600">
<BellRing size={18} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">Benachrichtigungen aktivieren</p>
<p className="text-xs text-gray-500 mt-0.5">
Verpasse keine Buchungsbestätigung oder freien Wartelisten-Plätze mehr.
</p>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 -mt-1 -mr-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
onClick={dismiss}
title="Schließen"
>
<X size={14} />
</Button>
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" className="flex-1" onClick={handleEnable} disabled={isSubscribing}>
{isSubscribing ? "Aktiviert…" : "Aktivieren"}
</Button>
<Button size="sm" variant="outline" className="bg-white/50" onClick={dismiss}>
Nicht jetzt
</Button>
</div>
</div>
</div>
);
}
// Wird von einer Einstellungen-UI importiert, um Push wieder zu deaktivieren
// (z.B. künftige "Geräte verwalten"-Seite) - hier co-located, weil sie
// denselben Browser-API-Zugriff (serviceWorker.ready) braucht wie handleEnable.
export async function disablePushOnThisDevice(): Promise<void> {
if (!isPushSupported()) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
await unsubscribeFromPush(subscription.endpoint).catch(() => {});
await subscription.unsubscribe().catch(() => {});
}