This commit is contained in:
Rajpreet0 2026-09-14 18:05:13 +02:00
parent 711b02dffb
commit 669c769878
6 changed files with 282 additions and 81 deletions

View file

@ -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",

View file

@ -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<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
);
}
// 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.
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);
}
const result = await enablePushOnThisDevice();
if (result.success) {
dismiss();
} catch (error) {
console.error("Push-Aktivierung fehlgeschlagen:", error);
} 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();
}
} finally {
setIsSubscribing(false);
}
@ -194,16 +144,3 @@ export function NotificationPrompt() {
</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(() => {});
}

109
src/lib/push-client.ts Normal file
View file

@ -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<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;
}
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<EnablePushResult> {
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<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(() => {});
}
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<PushDeviceStatus> {
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";
}

View file

@ -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;
}

View file

@ -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<PushDeviceStatus | null>(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 (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6 space-y-5">
<div>
<h2 className="text-base font-semibold text-gray-900">Push-Benachrichtigungen</h2>
<p className="text-xs text-gray-400 mt-0.5">Für dieses Gerät/diesen Browser</p>
</div>
<div className="flex items-start gap-3">
<div
className={`shrink-0 flex items-center justify-center size-9 rounded-lg ${content.iconBg} ${content.iconColor}`}
>
<Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{content.title}</p>
<p className="text-xs text-gray-500 mt-0.5">{content.description}</p>
</div>
</div>
{status === "disabled" && (
<Button size="sm" onClick={handleEnable} disabled={isWorking}>
{isWorking ? "Aktiviert…" : "Benachrichtigungen aktivieren"}
</Button>
)}
{status === "enabled" && (
<Button size="sm" variant="outline" onClick={handleDisable} disabled={isWorking}>
{isWorking ? "Deaktiviert…" : "Auf diesem Gerät deaktivieren"}
</Button>
)}
</div>
);
};
export default NotificationPermissionSettings;

View file

@ -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 () => {
</p>
</div>
<PersonalSettingsForm {...props} />
<NotificationPermissionSettings />
</div>
);
};