Files
TREK/client/src/store/inAppNotificationStore.ts
jubnl c0e9a771d6 feat: add in-app notification system with real-time delivery
Introduces a full in-app notification system with three types (simple,
boolean with server-side callbacks, navigate), three scopes (user, trip,
admin), fan-out persistence per recipient, and real-time push via
WebSocket. Includes a notification bell in the navbar, dropdown, dedicated
/notifications page, and a dev-only admin tab for testing all notification
variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:57:52 +02:00

193 lines
5.8 KiB
TypeScript

import { create } from 'zustand'
import { inAppNotificationsApi } from '../api/client'
export interface InAppNotification {
id: number
type: 'simple' | 'boolean' | 'navigate'
scope: 'trip' | 'user' | 'admin'
target: number
sender_id: number | null
sender_username: string | null
sender_avatar: string | null
recipient_id: number
title_key: string
title_params: Record<string, string>
text_key: string
text_params: Record<string, string>
positive_text_key: string | null
negative_text_key: string | null
response: 'positive' | 'negative' | null
navigate_text_key: string | null
navigate_target: string | null
is_read: boolean
created_at: string
}
interface RawNotification extends Omit<InAppNotification, 'title_params' | 'text_params' | 'is_read'> {
title_params: string | Record<string, string>
text_params: string | Record<string, string>
is_read: number | boolean
}
function normalizeNotification(raw: RawNotification): InAppNotification {
return {
...raw,
title_params: typeof raw.title_params === 'string' ? JSON.parse(raw.title_params || '{}') : raw.title_params,
text_params: typeof raw.text_params === 'string' ? JSON.parse(raw.text_params || '{}') : raw.text_params,
is_read: Boolean(raw.is_read),
}
}
interface NotificationState {
notifications: InAppNotification[]
unreadCount: number
total: number
isLoading: boolean
hasMore: boolean
fetchNotifications: (reset?: boolean) => Promise<void>
fetchUnreadCount: () => Promise<void>
markRead: (id: number) => Promise<void>
markUnread: (id: number) => Promise<void>
markAllRead: () => Promise<void>
deleteNotification: (id: number) => Promise<void>
deleteAll: () => Promise<void>
respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise<void>
handleNewNotification: (notification: RawNotification) => void
handleUpdatedNotification: (notification: RawNotification) => void
}
const PAGE_SIZE = 20
export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0,
total: 0,
isLoading: false,
hasMore: false,
fetchNotifications: async (reset = false) => {
const { notifications, isLoading } = get()
if (isLoading) return
set({ isLoading: true })
try {
const offset = reset ? 0 : notifications.length
const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset })
const normalized = (data.notifications as RawNotification[]).map(normalizeNotification)
set({
notifications: reset ? normalized : [...notifications, ...normalized],
total: data.total,
unreadCount: data.unread_count,
hasMore: (reset ? normalized.length : notifications.length + normalized.length) < data.total,
isLoading: false,
})
} catch {
set({ isLoading: false })
}
},
fetchUnreadCount: async () => {
try {
const data = await inAppNotificationsApi.unreadCount()
set({ unreadCount: data.count })
} catch {
// best-effort
}
},
markRead: async (id: number) => {
try {
await inAppNotificationsApi.markRead(id)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: true } : n),
unreadCount: Math.max(0, state.unreadCount - (state.notifications.find(n => n.id === id)?.is_read ? 0 : 1)),
}))
} catch {
// best-effort
}
},
markUnread: async (id: number) => {
try {
await inAppNotificationsApi.markUnread(id)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: false } : n),
unreadCount: state.unreadCount + (state.notifications.find(n => n.id === id)?.is_read ? 1 : 0),
}))
} catch {
// best-effort
}
},
markAllRead: async () => {
try {
await inAppNotificationsApi.markAllRead()
set(state => ({
notifications: state.notifications.map(n => ({ ...n, is_read: true })),
unreadCount: 0,
}))
} catch {
// best-effort
}
},
deleteNotification: async (id: number) => {
const notification = get().notifications.find(n => n.id === id)
try {
await inAppNotificationsApi.delete(id)
set(state => ({
notifications: state.notifications.filter(n => n.id !== id),
total: Math.max(0, state.total - 1),
unreadCount: notification && !notification.is_read ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
}))
} catch {
// best-effort
}
},
deleteAll: async () => {
try {
await inAppNotificationsApi.deleteAll()
set({ notifications: [], total: 0, unreadCount: 0, hasMore: false })
} catch {
// best-effort
}
},
respondToBoolean: async (id: number, response: 'positive' | 'negative') => {
try {
const data = await inAppNotificationsApi.respond(id, response)
if (data.notification) {
const normalized = normalizeNotification(data.notification as RawNotification)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? normalized : n),
unreadCount: !state.notifications.find(n => n.id === id)?.is_read
? Math.max(0, state.unreadCount - 1)
: state.unreadCount,
}))
}
} catch {
// best-effort
}
},
handleNewNotification: (raw: RawNotification) => {
const notification = normalizeNotification(raw)
set(state => ({
notifications: [notification, ...state.notifications],
total: state.total + 1,
unreadCount: state.unreadCount + 1,
}))
},
handleUpdatedNotification: (raw: RawNotification) => {
const notification = normalizeNotification(raw)
set(state => ({
notifications: state.notifications.map(n => n.id === notification.id ? notification : n),
}))
},
}))