From 7371a9e517ee22a96775ee83c28dafdc9d5a6c8b Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 15 Aug 2026 15:23:44 +0700 Subject: [PATCH] feat: update notification retrieval to use pagination and enhance loading experience --- .../Api/NotificationController.php | 3 +- .../notifications/notification-bell.tsx | 213 ++++++++++++------ 2 files changed, 142 insertions(+), 74 deletions(-) diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index 8aa6fcf..c2d05f8 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -14,8 +14,7 @@ public function index(Request $request): JsonResponse $notifications = $request->user() ->notifications() ->orderBy('created_at', 'desc') - ->limit(20) - ->get(); + ->paginate(15); return response()->json($notifications); } diff --git a/resources/js/components/notifications/notification-bell.tsx b/resources/js/components/notifications/notification-bell.tsx index 7a84a8d..7a8dbec 100644 --- a/resources/js/components/notifications/notification-bell.tsx +++ b/resources/js/components/notifications/notification-bell.tsx @@ -1,12 +1,11 @@ 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 { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; @@ -20,12 +19,23 @@ interface Notification { 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; @@ -52,23 +62,41 @@ export function NotificationBell() { } }, []); - const fetchNotifications = useCallback(async () => { + const fetchNotifications = useCallback(async (page: number = 1) => { try { - const response = await fetch('/api/notifications', { + const response = await fetch(`/api/notifications?page=${page}`, { headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (response.ok && mountedRef.current) { - const data = await response.json(); - setNotifications(data); + 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`, { @@ -153,11 +181,34 @@ export function NotificationBell() { useEffect(() => { if (isOpen) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe - void fetchNotifications(); + // 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 ( @@ -178,7 +229,7 @@ export function NotificationBell() { Notifikasi - +
Notifikasi
@@ -215,85 +266,103 @@ export function NotificationBell() { Tidak ada notifikasi
) : ( - notifications.map((notification) => ( -
-
+ <> + {notifications.map((notification) => ( +
{ - if (notification.url) { - if (!notification.is_read) { - void markAsRead(notification.id); - } - router.visit(notification.url); - } - }} + className={`flex items-start gap-2 px-4 py-3 ${ + !notification.is_read + ? 'border-l-2 border-l-primary bg-primary/5' + : 'opacity-60' + }`} > - { + if (notification.url) { + if (!notification.is_read) { + void markAsRead(notification.id); + } + + router.visit(notification.url); + } + }} > - {notification.title} - - {notification.body && ( - - {notification.body} + + {notification.title} - )} - - {new Date( - notification.created_at, - ).toLocaleDateString('id-ID', { - day: 'numeric', - month: 'short', - hour: '2-digit', - minute: '2-digit', - })} - -
-
- {!notification.is_read && ( + {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 +

+ )}
- )) + )}