140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
"use client"
|
|
|
|
import { Bell } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
import { useEffect, useState } from "react"
|
|
import { getApiUrl } from "@/lib/api-config"
|
|
import Link from "next/link"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface Notification {
|
|
id: number
|
|
type: string
|
|
title: string
|
|
message: string
|
|
link?: string
|
|
is_read: boolean
|
|
created_at: string
|
|
}
|
|
|
|
export function NotificationBell() {
|
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const fetchNotifications = () => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) return
|
|
|
|
fetch(`${getApiUrl()}/notifications/`, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setNotifications(data)
|
|
setUnreadCount(data.filter((n: Notification) => !n.is_read).length)
|
|
})
|
|
.catch(console.error)
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchNotifications()
|
|
// Poll every 60 seconds
|
|
const interval = setInterval(fetchNotifications, 60000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
const markAsRead = (id: number) => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) return
|
|
|
|
fetch(`${getApiUrl()}/notifications/${id}/read`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
}).then(() => {
|
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n))
|
|
setUnreadCount(prev => Math.max(0, prev - 1))
|
|
})
|
|
}
|
|
|
|
const markAllRead = () => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) return
|
|
|
|
fetch(`${getApiUrl()}/notifications/mark-all-read`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
}).then(() => {
|
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
|
|
setUnreadCount(0)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="relative">
|
|
<Bell className="h-5 w-5" />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-red-600" />
|
|
)}
|
|
<span className="sr-only">Notifications</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 p-0" align="end">
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<h4 className="font-semibold">Notifications</h4>
|
|
{unreadCount > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={markAllRead} className="text-xs h-auto p-0 text-muted-foreground hover:text-foreground">
|
|
Mark all read
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-[300px] overflow-y-auto">
|
|
{notifications.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
No notifications
|
|
</div>
|
|
) : (
|
|
notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
className={cn(
|
|
"p-4 border-b last:border-0 hover:bg-muted/50 transition-colors cursor-pointer",
|
|
!notification.is_read && "bg-muted/20"
|
|
)}
|
|
onClick={() => {
|
|
if (!notification.is_read) markAsRead(notification.id)
|
|
}}
|
|
>
|
|
<Link href={notification.link || "#"} className="block">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium leading-none">
|
|
{notification.title}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{notification.message}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(notification.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
{!notification.is_read && (
|
|
<span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0 mt-1" />
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|