feat: update notification retrieval to use pagination and enhance loading experience
This commit is contained in:
parent
41a32b08f2
commit
7371a9e517
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user