feat(sprint-6): Phase 6 — Notifications (WebSocket) + PWA
- WebSocket: Spring STOMP + SockJS, NotificationService, persistent notifications table - NotificationController: GET/PUT endpoints for notification management - Frontend: notification bell with unread badge, dropdown panel, real-time via STOMP - PWA: manifest.json, service worker (manual sw.js), offline page, install prompt - PWA icons (192+512), dark theme colors, standalone display - Full i18n (de/en) for notifications and PWA - Flyway V10 migration for notifications table - spring-boot-starter-websocket dependency added
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Download, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
|
||||
}
|
||||
|
||||
export function PwaInstallPrompt() {
|
||||
const t = useTranslations("pwa")
|
||||
const [deferredPrompt, setDeferredPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already dismissed permanently
|
||||
if (localStorage.getItem("pwa-install-dismissed") === "true") {
|
||||
setDismissed(true)
|
||||
return
|
||||
}
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler)
|
||||
return () => window.removeEventListener("beforeinstallprompt", handler)
|
||||
}, [])
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return
|
||||
await deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
if (outcome === "accepted") {
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true)
|
||||
localStorage.setItem("pwa-install-dismissed", "true")
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
|
||||
if (!deferredPrompt || dismissed) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md animate-in slide-in-from-bottom-4 rounded-lg border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Download className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{t("install")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("installDesc")}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleInstall}>
|
||||
{t("install")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDismiss}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user