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 () => {
+
);
};