import { router } from '@inertiajs/react'; import { Bell, Check, CheckCheck, Loader2, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; interface Notification { id: number; title: string; body: string | null; url: string | null; is_read: boolean; created_at: string; } interface PaginatedResponse { data: Notification[]; current_page: number; last_page: number; next_page_url: string | null; } export function NotificationBell() { const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [isOpen, setIsOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [lastPage, setLastPage] = useState(1); const [loadingMore, setLoadingMore] = useState(false); const intervalRef = useRef | null>(null); const mountedRef = useRef(true); const sentinelRef = useRef(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const fetchUnreadCount = useCallback(async () => { try { const response = await fetch('/api/notifications/unread-count', { headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (response.ok && mountedRef.current) { const data = await response.json(); setUnreadCount(data.count); } } catch { // Silently fail } }, []); const fetchNotifications = useCallback(async (page: number = 1) => { try { const response = await fetch(`/api/notifications?page=${page}`, { headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (response.ok && mountedRef.current) { const data: PaginatedResponse = await response.json(); if (page === 1) { setNotifications(data.data); } else { setNotifications((prev) => [...prev, ...data.data]); } setCurrentPage(data.current_page); setLastPage(data.last_page); } } catch { // Silently fail } }, []); const fetchNextPage = useCallback(async () => { if (loadingMore || currentPage >= lastPage) { return; } setLoadingMore(true); await fetchNotifications(currentPage + 1); setLoadingMore(false); }, [loadingMore, currentPage, lastPage, fetchNotifications]); const markAsRead = useCallback(async (id: number) => { try { await fetch(`/api/notifications/${id}/read`, { method: 'PATCH', headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)), ); setUnreadCount((prev) => Math.max(0, prev - 1)); } catch { // Silently fail } }, []); const deleteNotification = useCallback(async (id: number) => { try { await fetch(`/api/notifications/${id}`, { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); setNotifications((prev) => prev.filter((n) => n.id !== id)); setUnreadCount((prev) => Math.max(0, prev - 1)); } catch { // Silently fail } }, []); const markAllAsRead = useCallback(async () => { try { await fetch('/api/notifications/read-all', { method: 'PATCH', headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })), ); setUnreadCount(0); } catch { // Silently fail } }, []); const deleteAll = useCallback(async () => { try { await fetch('/api/notifications', { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); setNotifications([]); setUnreadCount(0); } catch { // Silently fail } }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe void fetchUnreadCount(); intervalRef.current = setInterval(() => { void fetchUnreadCount(); }, 30000); return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [fetchUnreadCount]); useEffect(() => { if (isOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect -- reset pagination on open is safe setCurrentPage(1); setLastPage(1); void fetchNotifications(1); } else { setNotifications([]); } }, [isOpen, fetchNotifications]); useEffect(() => { if (!isOpen || !sentinelRef.current) { return; } const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !loadingMore && currentPage < lastPage) { void fetchNextPage(); } }, { rootMargin: '100px' }, ); observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [isOpen, loadingMore, currentPage, lastPage, fetchNextPage]); return (
Notifikasi
{unreadCount > 0 && ( )} {notifications.length > 0 && ( )}
{notifications.length === 0 ? (
Tidak ada notifikasi
) : ( <> {notifications.map((notification) => (
{ if (notification.url) { if (!notification.is_read) { void markAsRead(notification.id); } router.visit(notification.url); } }} > {notification.title} {notification.body && ( {notification.body} )} {new Date( notification.created_at, ).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', })}
{!notification.is_read && ( )}
))} {/* Sentinel for infinite scroll */}
{loadingMore && (
Memuat lainnya...
)} {!loadingMore && currentPage >= lastPage && notifications.length > 0 && (

Semua notifikasi sudah dimuat

)}
)}
); }