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,102 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
export interface WsNotification {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
link: string
|
||||
read: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface UseNotificationsOptions {
|
||||
userId?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket hook for real-time notifications via STOMP over SockJS.
|
||||
* Connects to /ws, subscribes to /user/queue/notifications.
|
||||
* Falls back gracefully if WebSocket is unavailable.
|
||||
*/
|
||||
export function useNotifications({
|
||||
userId,
|
||||
enabled = true,
|
||||
}: UseNotificationsOptions) {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [lastNotification, setLastNotification] =
|
||||
useState<WsNotification | null>(null)
|
||||
const stompClientRef = useRef<unknown>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!userId || !enabled) return
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid SSR issues
|
||||
const { Client } = await import("@stomp/stompjs")
|
||||
const SockJS = (await import("sockjs-client")).default
|
||||
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_WS_URL || "http://localhost:8080"
|
||||
|
||||
const client = new Client({
|
||||
webSocketFactory: () => new SockJS(`${backendUrl}/ws`),
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
onConnect: () => {
|
||||
setConnected(true)
|
||||
|
||||
client.subscribe(`/user/${userId}/queue/notifications`, (message) => {
|
||||
try {
|
||||
const notification: WsNotification = JSON.parse(message.body)
|
||||
setLastNotification(notification)
|
||||
// Invalidate notifications query to refresh badge count
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] })
|
||||
} catch {
|
||||
console.error("Failed to parse notification message")
|
||||
}
|
||||
})
|
||||
},
|
||||
onDisconnect: () => {
|
||||
setConnected(false)
|
||||
},
|
||||
onStompError: (frame) => {
|
||||
console.error("STOMP error:", frame.headers["message"])
|
||||
setConnected(false)
|
||||
},
|
||||
})
|
||||
|
||||
client.activate()
|
||||
stompClientRef.current = client
|
||||
} catch {
|
||||
// WebSocket libraries not available (SSR or missing deps) — fail silently
|
||||
console.warn("WebSocket connection unavailable, falling back to polling")
|
||||
}
|
||||
}, [userId, enabled, queryClient])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
const client = stompClientRef.current as { deactivate?: () => void } | null
|
||||
if (client?.deactivate) {
|
||||
client.deactivate()
|
||||
}
|
||||
stompClientRef.current = null
|
||||
setConnected(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
return () => disconnect()
|
||||
}, [connect, disconnect])
|
||||
|
||||
return {
|
||||
connected,
|
||||
lastNotification,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user