mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
154 lines
5.8 KiB
TypeScript
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(() => {});
|
|
}
|