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>
193 lines
5.8 KiB
TypeScript
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),
|
|
}))
|
|
},
|
|
}))
|