222 lines
5.6 KiB
TypeScript
222 lines
5.6 KiB
TypeScript
import React from 'react';
|
|
import { useUploadQueue } from '../queue/hooks';
|
|
import type { QueueItem } from '../queue/queue';
|
|
import {
|
|
dismissGuestNotification,
|
|
fetchGuestNotifications,
|
|
markGuestNotificationRead,
|
|
type GuestNotificationItem,
|
|
} from '../services/notificationApi';
|
|
|
|
export type NotificationCenterValue = {
|
|
notifications: GuestNotificationItem[];
|
|
unreadCount: number;
|
|
queueItems: QueueItem[];
|
|
queueCount: number;
|
|
totalCount: number;
|
|
loading: boolean;
|
|
refresh: () => Promise<void>;
|
|
markAsRead: (id: number) => Promise<void>;
|
|
dismiss: (id: number) => Promise<void>;
|
|
eventToken: string;
|
|
};
|
|
|
|
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
|
|
|
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
|
|
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
|
|
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
|
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
|
const etagRef = React.useRef<string | null>(null);
|
|
const fetchLockRef = React.useRef(false);
|
|
|
|
const queueCount = React.useMemo(
|
|
() => items.filter((item) => item.status !== 'done').length,
|
|
[items]
|
|
);
|
|
|
|
const loadNotifications = React.useCallback(
|
|
async (options: { silent?: boolean } = {}) => {
|
|
if (!eventToken) {
|
|
if (!options.silent) {
|
|
setLoadingNotifications(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fetchLockRef.current) {
|
|
return;
|
|
}
|
|
|
|
fetchLockRef.current = true;
|
|
if (!options.silent) {
|
|
setLoadingNotifications(true);
|
|
}
|
|
|
|
try {
|
|
const result = await fetchGuestNotifications(eventToken, etagRef.current);
|
|
if (!result.notModified) {
|
|
setNotifications(result.notifications);
|
|
setUnreadCount(result.unreadCount);
|
|
}
|
|
etagRef.current = result.etag;
|
|
} catch (error) {
|
|
console.error('Failed to load guest notifications', error);
|
|
if (!options.silent) {
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
}
|
|
} finally {
|
|
fetchLockRef.current = false;
|
|
if (!options.silent) {
|
|
setLoadingNotifications(false);
|
|
}
|
|
}
|
|
},
|
|
[eventToken]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
setNotifications([]);
|
|
setUnreadCount(0);
|
|
etagRef.current = null;
|
|
|
|
if (!eventToken) {
|
|
setLoadingNotifications(false);
|
|
return;
|
|
}
|
|
|
|
setLoadingNotifications(true);
|
|
void loadNotifications();
|
|
}, [eventToken, loadNotifications]);
|
|
|
|
React.useEffect(() => {
|
|
if (!eventToken) {
|
|
return;
|
|
}
|
|
|
|
const interval = window.setInterval(() => {
|
|
void loadNotifications({ silent: true });
|
|
}, 90000);
|
|
|
|
return () => window.clearInterval(interval);
|
|
}, [eventToken, loadNotifications]);
|
|
|
|
const markAsRead = React.useCallback(
|
|
async (id: number) => {
|
|
if (!eventToken) {
|
|
return;
|
|
}
|
|
|
|
let decremented = false;
|
|
setNotifications((prev) =>
|
|
prev.map((item) => {
|
|
if (item.id !== id) {
|
|
return item;
|
|
}
|
|
|
|
if (item.status === 'new') {
|
|
decremented = true;
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
status: 'read',
|
|
readAt: new Date().toISOString(),
|
|
};
|
|
})
|
|
);
|
|
|
|
if (decremented) {
|
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
}
|
|
|
|
try {
|
|
await markGuestNotificationRead(eventToken, id);
|
|
} catch (error) {
|
|
console.error('Failed to mark notification as read', error);
|
|
void loadNotifications({ silent: true });
|
|
}
|
|
},
|
|
[eventToken, loadNotifications]
|
|
);
|
|
|
|
const dismiss = React.useCallback(
|
|
async (id: number) => {
|
|
if (!eventToken) {
|
|
return;
|
|
}
|
|
|
|
let decremented = false;
|
|
setNotifications((prev) =>
|
|
prev.map((item) => {
|
|
if (item.id !== id) {
|
|
return item;
|
|
}
|
|
|
|
if (item.status === 'new') {
|
|
decremented = true;
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
status: 'dismissed',
|
|
dismissedAt: new Date().toISOString(),
|
|
};
|
|
})
|
|
);
|
|
|
|
if (decremented) {
|
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
}
|
|
|
|
try {
|
|
await dismissGuestNotification(eventToken, id);
|
|
} catch (error) {
|
|
console.error('Failed to dismiss notification', error);
|
|
void loadNotifications({ silent: true });
|
|
}
|
|
},
|
|
[eventToken, loadNotifications]
|
|
);
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
await Promise.all([loadNotifications(), refreshQueue()]);
|
|
}, [loadNotifications, refreshQueue]);
|
|
|
|
const loading = loadingNotifications || queueLoading;
|
|
const totalCount = unreadCount + queueCount;
|
|
|
|
const value: NotificationCenterValue = {
|
|
notifications,
|
|
unreadCount,
|
|
queueItems: items,
|
|
queueCount,
|
|
totalCount,
|
|
loading,
|
|
refresh,
|
|
markAsRead,
|
|
dismiss,
|
|
eventToken,
|
|
};
|
|
|
|
return (
|
|
<NotificationCenterContext.Provider value={value}>
|
|
{children}
|
|
</NotificationCenterContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useNotificationCenter(): NotificationCenterValue {
|
|
const ctx = React.useContext(NotificationCenterContext);
|
|
if (!ctx) {
|
|
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
|
|
return React.useContext(NotificationCenterContext);
|
|
}
|