feat: update notification retrieval to use pagination and enhance loading experience

This commit is contained in:
Yoga Pangestu 2026-08-15 15:23:44 +07:00
parent 41a32b08f2
commit 7371a9e517
2 changed files with 142 additions and 74 deletions

View File

@ -14,8 +14,7 @@ public function index(Request $request): JsonResponse
$notifications = $request->user() $notifications = $request->user()
->notifications() ->notifications()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->limit(20) ->paginate(15);
->get();
return response()->json($notifications); return response()->json($notifications);
} }

View File

@ -1,12 +1,11 @@
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import { Bell, Check, CheckCheck, Trash2 } from 'lucide-react'; import { Bell, Check, CheckCheck, Loader2, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
@ -20,12 +19,23 @@ interface Notification {
created_at: string; created_at: string;
} }
interface PaginatedResponse {
data: Notification[];
current_page: number;
last_page: number;
next_page_url: string | null;
}
export function NotificationBell() { export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [loadingMore, setLoadingMore] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@ -52,23 +62,41 @@ export function NotificationBell() {
} }
}, []); }, []);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async (page: number = 1) => {
try { try {
const response = await fetch('/api/notifications', { const response = await fetch(`/api/notifications?page=${page}`, {
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}, },
}); });
if (response.ok && mountedRef.current) { if (response.ok && mountedRef.current) {
const data = await response.json(); const data: PaginatedResponse = await response.json();
setNotifications(data);
if (page === 1) {
setNotifications(data.data);
} else {
setNotifications((prev) => [...prev, ...data.data]);
}
setCurrentPage(data.current_page);
setLastPage(data.last_page);
} }
} catch { } catch {
// Silently fail // 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) => { const markAsRead = useCallback(async (id: number) => {
try { try {
await fetch(`/api/notifications/${id}/read`, { await fetch(`/api/notifications/${id}/read`, {
@ -153,11 +181,34 @@ export function NotificationBell() {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe // eslint-disable-next-line react-hooks/set-state-in-effect -- reset pagination on open is safe
void fetchNotifications(); setCurrentPage(1);
setLastPage(1);
void fetchNotifications(1);
} else {
setNotifications([]);
} }
}, [isOpen, fetchNotifications]); }, [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 ( return (
<DropdownMenu onOpenChange={setIsOpen}> <DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -178,7 +229,7 @@ export function NotificationBell() {
<span className="sr-only">Notifikasi</span> <span className="sr-only">Notifikasi</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[350px] w-80"> <DropdownMenuContent align="end" className="max-h-[350px] w-80 overflow-y-auto">
<div className="flex items-center justify-between border-b px-4 py-2"> <div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">Notifikasi</span> <span className="text-sm font-semibold">Notifikasi</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -215,7 +266,8 @@ export function NotificationBell() {
Tidak ada notifikasi Tidak ada notifikasi
</div> </div>
) : ( ) : (
notifications.map((notification) => ( <>
{notifications.map((notification) => (
<div key={notification.id}> <div key={notification.id}>
<div <div
className={`flex items-start gap-2 px-4 py-3 ${ className={`flex items-start gap-2 px-4 py-3 ${
@ -231,6 +283,7 @@ export function NotificationBell() {
if (!notification.is_read) { if (!notification.is_read) {
void markAsRead(notification.id); void markAsRead(notification.id);
} }
router.visit(notification.url); router.visit(notification.url);
} }
}} }}
@ -293,7 +346,23 @@ export function NotificationBell() {
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</div> </div>
)) ))}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="px-4 py-2">
{loadingMore && (
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Memuat lainnya...</span>
</div>
)}
{!loadingMore && currentPage >= lastPage && notifications.length > 0 && (
<p className="text-center text-xs text-muted-foreground">
Semua notifikasi sudah dimuat
</p>
)}
</div>
</>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>