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,85 +266,103 @@ export function NotificationBell() {
Tidak ada notifikasi Tidak ada notifikasi
</div> </div>
) : ( ) : (
notifications.map((notification) => ( <>
<div key={notification.id}> {notifications.map((notification) => (
<div <div key={notification.id}>
className={`flex items-start gap-2 px-4 py-3 ${
!notification.is_read
? 'border-l-2 border-l-primary bg-primary/5'
: 'opacity-60'
}`}
>
<div <div
className="min-w-0 flex-1 cursor-pointer" className={`flex items-start gap-2 px-4 py-3 ${
onClick={() => { !notification.is_read
if (notification.url) { ? 'border-l-2 border-l-primary bg-primary/5'
if (!notification.is_read) { : 'opacity-60'
void markAsRead(notification.id); }`}
}
router.visit(notification.url);
}
}}
> >
<span <div
className={`text-sm ${ className="min-w-0 flex-1 cursor-pointer"
!notification.is_read onClick={() => {
? 'font-semibold' if (notification.url) {
: 'font-medium' if (!notification.is_read) {
}`} void markAsRead(notification.id);
}
router.visit(notification.url);
}
}}
> >
{notification.title} <span
</span> className={`text-sm ${
{notification.body && ( !notification.is_read
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground"> ? 'font-semibold'
{notification.body} : 'font-medium'
}`}
>
{notification.title}
</span> </span>
)} {notification.body && (
<span className="mt-0.5 block text-xs text-muted-foreground"> <span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
{new Date( {notification.body}
notification.created_at, </span>
).toLocaleDateString('id-ID', { )}
day: 'numeric', <span className="mt-0.5 block text-xs text-muted-foreground">
month: 'short', {new Date(
hour: '2-digit', notification.created_at,
minute: '2-digit', ).toLocaleDateString('id-ID', {
})} day: 'numeric',
</span> month: 'short',
</div> hour: '2-digit',
<div className="flex shrink-0 flex-col gap-1 pt-0.5"> minute: '2-digit',
{!notification.is_read && ( })}
</span>
</div>
<div className="flex shrink-0 flex-col gap-1 pt-0.5">
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Tandai sudah dibaca"
onClick={() => {
void markAsRead(
notification.id,
);
}}
>
<Check className="h-3 w-3" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6 text-destructive hover:text-destructive"
title="Tandai sudah dibaca" title="Hapus"
onClick={() => { onClick={() => {
void markAsRead( void deleteNotification(
notification.id, notification.id,
); );
}} }}
> >
<Check className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
)} </div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
title="Hapus"
onClick={() => {
void deleteNotification(
notification.id,
);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
<DropdownMenuSeparator />
</div> </div>
<DropdownMenuSeparator /> ))}
{/* 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> </div>
)) </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>