mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:01:16 +00:00
fix pwa
This commit is contained in:
parent
711b02dffb
commit
669c769878
6 changed files with 282 additions and 81 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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() {
|
|||
</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
109
src/lib/push-client.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue