refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
304
resources/js/shared/guest/context/NotificationCenterContext.tsx
Normal file
304
resources/js/shared/guest/context/NotificationCenterContext.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import React from 'react';
|
||||
import { useUploadQueue } from '../queue/hooks';
|
||||
import type { QueueItem } from '../queue/queue';
|
||||
import {
|
||||
dismissGuestNotification,
|
||||
fetchGuestNotifications,
|
||||
markGuestNotificationRead,
|
||||
type GuestNotificationItem,
|
||||
} from '../services/notificationApi';
|
||||
import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi';
|
||||
import { updateAppBadge } from '../lib/badges';
|
||||
|
||||
export type NotificationCenterValue = {
|
||||
notifications: GuestNotificationItem[];
|
||||
unreadCount: number;
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
pendingCount: number;
|
||||
loading: boolean;
|
||||
pendingLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void;
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
dismiss: (id: number) => Promise<void>;
|
||||
eventToken: string;
|
||||
lastFetchedAt: Date | null;
|
||||
isOffline: boolean;
|
||||
};
|
||||
|
||||
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 [pendingCount, setPendingCount] = React.useState(0);
|
||||
const [pendingLoading, setPendingLoading] = React.useState(true);
|
||||
const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({
|
||||
status: 'new',
|
||||
scope: 'all',
|
||||
});
|
||||
const etagRef = React.useRef<string | null>(null);
|
||||
const fetchLockRef = React.useRef(false);
|
||||
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
|
||||
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : 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 statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined;
|
||||
const result = await fetchGuestNotifications(eventToken, etagRef.current, {
|
||||
status: statusFilter as any,
|
||||
scope: filters.scope,
|
||||
});
|
||||
if (!result.notModified) {
|
||||
setNotifications(result.notifications);
|
||||
setUnreadCount(result.unreadCount);
|
||||
setLastFetchedAt(new Date());
|
||||
}
|
||||
etagRef.current = result.etag;
|
||||
setIsOffline(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest notifications', error);
|
||||
if (!options.silent) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
setIsOffline(true);
|
||||
}
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[eventToken]
|
||||
);
|
||||
|
||||
const loadPendingUploads = React.useCallback(async () => {
|
||||
if (!eventToken) {
|
||||
setPendingLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingLoading(true);
|
||||
const result = await fetchPendingUploadsSummary(eventToken, 1);
|
||||
setPendingCount(result.totalCount);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pending uploads', error);
|
||||
setPendingCount(0);
|
||||
} finally {
|
||||
setPendingLoading(false);
|
||||
}
|
||||
}, [eventToken]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
etagRef.current = null;
|
||||
setPendingCount(0);
|
||||
|
||||
if (!eventToken) {
|
||||
setLoadingNotifications(false);
|
||||
setPendingLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingNotifications(true);
|
||||
void loadNotifications();
|
||||
void loadPendingUploads();
|
||||
}, [eventToken, loadNotifications, loadPendingUploads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNotifications({ silent: true });
|
||||
void loadPendingUploads();
|
||||
}, 90000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [eventToken, loadNotifications, loadPendingUploads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'guest-notification-refresh') {
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handler);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handler);
|
||||
};
|
||||
}, [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 setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => {
|
||||
setFiltersState((prev) => ({ ...prev, ...next }));
|
||||
void loadNotifications({ silent: true });
|
||||
}, [loadNotifications]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
await Promise.all([loadNotifications(), refreshQueue(), loadPendingUploads()]);
|
||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||
React.useEffect(() => {
|
||||
void updateAppBadge(unreadCount);
|
||||
}, [unreadCount]);
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
pendingCount,
|
||||
loading,
|
||||
pendingLoading,
|
||||
refresh,
|
||||
setFilters,
|
||||
markAsRead,
|
||||
dismiss,
|
||||
eventToken,
|
||||
lastFetchedAt,
|
||||
isOffline,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user