mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
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>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user