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 = $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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user