From 669c76987866a6c2db096b25c39fd14c0a6b3b3e Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Mon, 14 Sep 2026 18:05:13 +0200 Subject: [PATCH] fix pwa --- public/manifest.json | 2 +- src/components/NotificationPrompt.tsx | 97 +++---------- src/lib/push-client.ts | 109 ++++++++++++++ src/middleware.ts | 16 ++ .../NotificationPermissionSettings.tsx | 137 ++++++++++++++++++ .../ui/views/settings-personal-page-view.tsx | 2 + 6 files changed, 282 insertions(+), 81 deletions(-) create mode 100644 src/lib/push-client.ts create mode 100644 src/modules/settings/ui/components/NotificationPermissionSettings.tsx diff --git a/public/manifest.json b/public/manifest.json index 393bcf6..1351a7c 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,7 +2,7 @@ "name": "Kumasy", "short_name": "Kumasy", "description": "Kumasy – Kurse buchen und verwalten", - "start_url": "/", + "start_url": "/?source=pwa", "display": "standalone", "background_color": "#1E293B", "theme_color": "#F4C00C", diff --git a/src/components/NotificationPrompt.tsx b/src/components/NotificationPrompt.tsx index c82f498..809d8cd 100644 --- a/src/components/NotificationPrompt.tsx +++ b/src/components/NotificationPrompt.tsx @@ -4,38 +4,10 @@ import { useEffect, useState, useSyncExternalStore } 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"; +import { enablePushOnThisDevice, isPushBaseSupported } from "@/src/lib/push-client"; const DISMISSED_KEY = "kumasy-notification-prompt-dismissed"; -function urlBase64ToUint8Array(base64String: string): Uint8Array { - 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 - ); -} - -// Basis-Voraussetzungen, die sich zur Laufzeit nicht ändern (API-Support, -// VAPID-Konfiguration) - anders als Notification.permission/localStorage -// unten, die erst nach einem Nutzer-Entscheid entstehen. -function isBaseSupported(): boolean { - return Boolean(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) && isPushSupported(); -} - // Anders als in InstallPrompt.tsx gibt es hier ein echtes externes Signal, // auf das reagiert werden muss: computeBaseEligible() hängt (über // isInstallPromptPending()) vom Install-Prompt-Status ab, der sich NACH @@ -60,7 +32,7 @@ function subscribeToInstallPromptSettled(onChange: () => void) { */ function computeBaseEligible(): boolean { if (typeof window === "undefined") return false; - if (!isBaseSupported()) return false; + if (!isPushBaseSupported()) return false; if (Notification.permission === "denied") return false; // Wartet, bis InstallPrompt nicht mehr sichtbar ist (installiert oder // weggeklickt) - sonst überlappen sich beide "fixed bottom-4"-Banner. @@ -89,7 +61,7 @@ export function NotificationPrompt() { const eligible = (baseEligible || missingServerRegistration) && !dismissed; useEffect(() => { - if (isBaseSupported() && Notification.permission === "granted") { + if (isPushBaseSupported() && Notification.permission === "granted") { navigator.serviceWorker.ready .then((registration) => registration.pushManager.getSubscription()) .then((subscription) => { @@ -113,44 +85,22 @@ export function NotificationPrompt() { const handleEnable = async () => { setIsSubscribing(true); try { - const permission = await Notification.requestPermission(); - if (permission !== "granted") { - // "denied" wird schon von computeBaseEligible() dauerhaft - // ausgeschlossen, kein zusätzliches localStorage-Flag nötig. + const result = await enablePushOnThisDevice(); + if (result.success) { + dismiss(); + } else { + // Technischer Fehler ODER Permission nicht erteilt: Prompt nur + // ausblenden, NICHT dauerhaft in localStorage merken (siehe + // hideWithoutPersisting) - eine "denied"-Permission wird bereits von + // computeBaseEligible() dauerhaft ausgeschlossen, ein Retry-Pfad für + // technische Fehler (Server/Netzwerk) muss aber bestehen bleiben. + // Best-effort: schlägt die Server-Registrierung fehl, bleibt evtl. + // eine verwaiste Browser-Subscription zurück, die der useEffect-Check + // oben beim nächsten Laden über getSubscription() erkennt und den + // Prompt trotzdem wieder anbietet. + console.error("Push-Aktivierung fehlgeschlagen:", result.error); hideWithoutPersisting(); - 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. Best-effort: - // schlägt das fehl, bleibt eine verwaiste Browser-Subscription zurück, - // die der useEffect-Check oben beim nächsten Laden über - // getSubscription() erkennt und den Prompt trotzdem wieder anbietet. - await subscription.unsubscribe().catch(() => {}); - throw new Error(result.error); - } - - dismiss(); - } catch (error) { - console.error("Push-Aktivierung fehlgeschlagen:", error); - hideWithoutPersisting(); } finally { setIsSubscribing(false); } @@ -194,16 +144,3 @@ export function NotificationPrompt() { ); } - -// 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 { - 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(() => {}); -} diff --git a/src/lib/push-client.ts b/src/lib/push-client.ts new file mode 100644 index 0000000..6bd9089 --- /dev/null +++ b/src/lib/push-client.ts @@ -0,0 +1,109 @@ +/** + * Client-seitige Web-Push-Helfer, gemeinsam genutzt von NotificationPrompt + * (Erst-Opt-in-Banner) und der Push-Sektion in /settings/personal + * (nachträgliches Aktivieren/Status/Deaktivieren). + */ + +import { + subscribeToPush, + unsubscribeFromPush, +} from "@/src/modules/notifications/actions/push-subscription-actions"; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + 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; +} + +export function isPushSupported(): boolean { + return ( + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +/** API-Support + VAPID-Konfiguration - ändert sich zur Laufzeit nicht. */ +export function isPushBaseSupported(): boolean { + return Boolean(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) && isPushSupported(); +} + +export type EnablePushResult = { success: true } | { success: false; error: string }; + +/** + * Fragt die Browser-Permission an (falls nötig), erstellt eine + * PushSubscription und registriert sie serverseitig. Wirft nie - Fehler + * kommen als { success: false, error }. + */ +export async function enablePushOnThisDevice(): Promise { + if (!isPushBaseSupported()) { + return { success: false, error: "Push wird von diesem Browser nicht unterstützt." }; + } + + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + return { success: false, error: "Berechtigung wurde nicht erteilt." }; + } + + 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(() => {}); + return { success: false, error: result.error }; + } + + return { success: true }; + } catch (error) { + console.error("enablePushOnThisDevice fehlgeschlagen:", error); + return { success: false, error: "Push-Aktivierung fehlgeschlagen." }; + } +} + +export async function disablePushOnThisDevice(): Promise { + 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(() => {}); +} + +export type PushDeviceStatus = "unsupported" | "denied" | "disabled" | "enabled"; + +/** + * Ermittelt den aktuellen Push-Status DIESES Geräts/Browsers. "enabled" + * verlangt eine tatsächlich beim Server registrierte Subscription, nicht nur + * Browser-Permission="granted" - sonst zeigt die Settings-Seite fälschlich + * "aktiv" an, wenn die Server-Registrierung zuvor fehlgeschlagen ist. + */ +export async function getPushDeviceStatus(): Promise { + if (!isPushBaseSupported()) return "unsupported"; + if (Notification.permission === "denied") return "denied"; + if (Notification.permission !== "granted") return "disabled"; + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + return subscription ? "enabled" : "disabled"; +} diff --git a/src/middleware.ts b/src/middleware.ts index ca861fd..516e624 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -47,6 +47,22 @@ export async function middleware(request: NextRequest) { return redirectResponse; } + // manifest.json's start_url ("/?source=pwa") landet nur hier, wenn das + // Betriebssystem die installierte PWA vom Homescreen öffnet - ein normaler + // Browser-Aufruf von "/" hat diesen Parameter nie. Die Marketing- + // Landingpage ist für Homescreen-Nutzer nutzlos (kein neuer Besucher würde + // sie so erreichen), daher direkt zu Login/Dashboard weiterleiten. + if (request.nextUrl.pathname === "/" && request.nextUrl.searchParams.get("source") === "pwa") { + const url = request.nextUrl.clone(); + url.pathname = user ? "/dashboard" : "/login"; + url.searchParams.delete("source"); + const redirectResponse = NextResponse.redirect(url); + supabaseResponse.cookies.getAll().forEach(({ name, value, ...options }) => { + redirectResponse.cookies.set(name, value, options); + }); + return redirectResponse; + } + return supabaseResponse; } diff --git a/src/modules/settings/ui/components/NotificationPermissionSettings.tsx b/src/modules/settings/ui/components/NotificationPermissionSettings.tsx new file mode 100644 index 0000000..ae0eb30 --- /dev/null +++ b/src/modules/settings/ui/components/NotificationPermissionSettings.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Bell, BellOff, BellRing, ShieldAlert } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import { + disablePushOnThisDevice, + enablePushOnThisDevice, + getPushDeviceStatus, + type PushDeviceStatus, +} from "@/src/lib/push-client"; + +const STATUS_CONTENT: Record< + PushDeviceStatus, + { icon: typeof Bell; iconBg: string; iconColor: string; title: string; description: string } +> = { + unsupported: { + icon: BellOff, + iconBg: "bg-gray-100", + iconColor: "text-gray-400", + title: "Nicht unterstützt", + description: "Dieser Browser unterstützt keine Push-Benachrichtigungen.", + }, + denied: { + icon: ShieldAlert, + iconBg: "bg-orange-50", + iconColor: "text-orange-600", + title: "Blockiert", + description: + "Du hast Benachrichtigungen für diese Seite im Browser blockiert. Erlaube sie in den Website-Einstellungen deines Browsers, um sie hier wieder zu aktivieren.", + }, + disabled: { + icon: Bell, + iconBg: "bg-yellow-50", + iconColor: "text-yellow-600", + title: "Deaktiviert", + description: + "Aktiviere Benachrichtigungen, um über Buchungsbestätigungen und freie Wartelisten-Plätze informiert zu werden.", + }, + enabled: { + icon: BellRing, + iconBg: "bg-green-50", + iconColor: "text-green-600", + title: "Aktiv auf diesem Gerät", + description: "Du erhältst Push-Benachrichtigungen auf diesem Gerät/Browser.", + }, +}; + +/** + * Push-Status + (De-)Aktivierung für DIESES Gerät/Browser - unabhängig von + * NotificationPrompt (dem Erst-Opt-in-Banner), der einmalig weggeklickt + * werden kann. Hier kann Push jederzeit nachträglich aktiviert werden, ohne + * auf den Banner warten oder Browser-Daten zurücksetzen zu müssen. + */ +const NotificationPermissionSettings = () => { + // null = wird noch ermittelt (getPushDeviceStatus ist async, siehe + // getSubscription() in push-client.ts) - erst nach dem Mount bekannt, um + // serverseitig identisch mit dem ersten Client-Render zu bleiben. + const [status, setStatus] = useState(null); + const [isWorking, setIsWorking] = useState(false); + + // getPushDeviceStatus() deckt den "unsupported"-Fall (isPushBaseSupported()) + // bereits selbst ab - kein synchroner Kurzschluss hier nötig, sonst würde + // setState synchron im Effect-Body statt in der .then()-Callback laufen. + const refreshStatus = () => { + getPushDeviceStatus() + .then(setStatus) + .catch(() => setStatus("unsupported")); + }; + + useEffect(() => { + refreshStatus(); + }, []); + + const handleEnable = async () => { + setIsWorking(true); + try { + const result = await enablePushOnThisDevice(); + if (!result.success) { + console.error("Push-Aktivierung fehlgeschlagen:", result.error); + } + } finally { + refreshStatus(); + setIsWorking(false); + } + }; + + const handleDisable = async () => { + setIsWorking(true); + try { + await disablePushOnThisDevice(); + } finally { + refreshStatus(); + setIsWorking(false); + } + }; + + if (status === null) return null; + + const content = STATUS_CONTENT[status]; + const Icon = content.icon; + + return ( +
+
+

Push-Benachrichtigungen

+

Für dieses Gerät/diesen Browser

+
+ +
+
+ +
+
+

{content.title}

+

{content.description}

+
+
+ + {status === "disabled" && ( + + )} + + {status === "enabled" && ( + + )} +
+ ); +}; + +export default NotificationPermissionSettings; diff --git a/src/modules/settings/ui/views/settings-personal-page-view.tsx b/src/modules/settings/ui/views/settings-personal-page-view.tsx index 3e4de44..9a59f32 100644 --- a/src/modules/settings/ui/views/settings-personal-page-view.tsx +++ b/src/modules/settings/ui/views/settings-personal-page-view.tsx @@ -1,5 +1,6 @@ import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser"; import PersonalSettingsForm, { PersonalSettingsProps } from "../components/PersonalSettingsForm"; +import NotificationPermissionSettings from "../components/NotificationPermissionSettings"; const SettingsPersonalPageView = async () => { const userData = await requireAuthWithData(); @@ -51,6 +52,7 @@ const SettingsPersonalPageView = async () => {

+ ); };