feat(tickets): Web-Push-Benachrichtigungen (PWA)
- Die Züchterin kann auf der Tickets-Seite Push-Benachrichtigungen aktivieren (🔔-Schalter). Service Worker ist BEWUSST push-only (kein fetch/Caching), damit das Laden der App nie beeinträchtigt wird. - Backend: WebPush-Bibliothek + VAPID; PushNotifier sendet an alle Abos, räumt veraltete (404/410) auf. Endpoints: GET /push/vapid-public-key, POST /push/subscribe|unsubscribe. - Ausgelöst über ein notify-Flag auf PUT /feedback: NUR die KI setzt es (z. B. neue Rückfrage / Ticket gelöst) → keine Selbst-Pushes durch Aktionen der Züchterin. Text wird aus dem Status abgeleitet, Klick öffnet das Ticket (?focus=). - Schalter/Logik blenden sich aus, wenn das Gerät kein Push kann oder der Server keine VAPID-Schlüssel hat (z. B. e2e-Mock). Migration WebPushSubscriptions. VAPID-Schlüssel in appsettings.json (lokale Single-User-App im Heimnetz — bewusste, vertretbare Vereinfachung). Tests: 264 Backend grün (+Push-Endpoints), e2e Tickets Desktop grün, vitest 149. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
|
||||
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
|
||||
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { useToast } from './toast'
|
||||
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
|
||||
|
||||
export default function PushToggle() {
|
||||
const t = de.feedback.tickets
|
||||
const toast = useToast()
|
||||
const [available, setAvailable] = useState(false)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
if (!isPushSupported()) return
|
||||
const cfg = await fetchPushConfig()
|
||||
if (cancelled || !cfg.enabled) return
|
||||
setAvailable(true)
|
||||
setSubscribed(await isSubscribed())
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!available) return null
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (subscribed) {
|
||||
await disablePush()
|
||||
setSubscribed(false)
|
||||
toast.success(t.pushDisabledToast)
|
||||
} else {
|
||||
const res = await enablePush()
|
||||
if (res === 'enabled') {
|
||||
setSubscribed(true)
|
||||
toast.success(t.pushEnabledToast)
|
||||
} else if (res === 'denied') {
|
||||
toast.error(t.pushDenied)
|
||||
} else {
|
||||
toast.error(t.pushUnavailable)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t.pushUnavailable)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
|
||||
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user