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()
->orderBy('created_at', 'desc')
->limit(20)
->get();
->paginate(15);
return response()->json($notifications);
}

View File

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