265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { router } from '@inertiajs/react';
|
|
import { Bell, Check, 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';
|
|
|
|
interface Notification {
|
|
id: number;
|
|
title: string;
|
|
body: string | null;
|
|
url: string | null;
|
|
is_read: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export function NotificationBell() {
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const mountedRef = useRef(true);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
const fetchUnreadCount = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/notifications/unread-count', {
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
|
|
if (response.ok && mountedRef.current) {
|
|
const data = await response.json();
|
|
setUnreadCount(data.count);
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}, []);
|
|
|
|
const fetchNotifications = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/notifications', {
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
|
|
if (response.ok && mountedRef.current) {
|
|
const data = await response.json();
|
|
setNotifications(data);
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}, []);
|
|
|
|
const markAsRead = useCallback(async (id: number) => {
|
|
try {
|
|
await fetch(`/api/notifications/${id}/read`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
|
|
setNotifications((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
|
|
);
|
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}, []);
|
|
|
|
const deleteNotification = useCallback(async (id: number) => {
|
|
try {
|
|
await fetch(`/api/notifications/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
|
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}, []);
|
|
|
|
const markAllAsRead = useCallback(async () => {
|
|
try {
|
|
await fetch('/api/notifications/read-all', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
|
|
setNotifications((prev) =>
|
|
prev.map((n) => ({ ...n, is_read: true })),
|
|
);
|
|
setUnreadCount(0);
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe
|
|
void fetchUnreadCount();
|
|
intervalRef.current = setInterval(() => {
|
|
void fetchUnreadCount();
|
|
}, 30000);
|
|
|
|
return () => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
}
|
|
};
|
|
}, [fetchUnreadCount]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe
|
|
void fetchNotifications();
|
|
}
|
|
}, [isOpen, fetchNotifications]);
|
|
|
|
return (
|
|
<DropdownMenu onOpenChange={setIsOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="relative h-9 w-9"
|
|
>
|
|
<Bell className="h-4 w-4" />
|
|
{unreadCount > 0 && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs"
|
|
>
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</Badge>
|
|
)}
|
|
<span className="sr-only">Notifikasi</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="max-h-[350px] w-80">
|
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
|
<span className="text-sm font-semibold">Notifikasi</span>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={() => {
|
|
void markAllAsRead();
|
|
}}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Tandai semua dibaca
|
|
</button>
|
|
)}
|
|
</div>
|
|
{notifications.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
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'
|
|
}`}
|
|
>
|
|
<div
|
|
className="min-w-0 flex-1 cursor-pointer"
|
|
onClick={() => {
|
|
if (notification.url) {
|
|
router.visit(notification.url);
|
|
}
|
|
}}
|
|
>
|
|
<span
|
|
className={`text-sm ${
|
|
!notification.is_read
|
|
? 'font-semibold'
|
|
: 'font-medium'
|
|
}`}
|
|
>
|
|
{notification.title}
|
|
</span>
|
|
{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 text-destructive hover:text-destructive"
|
|
title="Hapus"
|
|
onClick={() => {
|
|
void deleteNotification(
|
|
notification.id,
|
|
);
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
</div>
|
|
))
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|