import { useEffect, useState, useCallback } from "react"
import {
Bell,
BellOff,
AlertTriangle,
ChevronLeft,
ChevronRight,
MailOpen,
Mail,
CheckCheck,
MailX,
Search,
ChevronDown,
} from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom"
import {
getNotificationById,
getNotifications,
getUnreadCount,
markAsRead,
markAsUnread,
markAllRead,
markAllUnread,
} from "../../api/notifications.api"
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationTimestamp,
formatNotificationTypeLabel,
getNotificationLevelBadge,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
function NotificationItem({
notification,
onToggleRead,
toggling,
}: {
notification: Notification
onToggleRead: (id: string, currentlyRead: boolean) => void
toggling: boolean
}) {
const config = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
{/* Unread dot */}
{!notification.is_read && (
)}
{/* Icon */}
{/* Content */}
{getNotificationTitle(notification)}
{notification.level}
{getNotificationMessage(notification)}
{formatNotificationTimestamp(notification.timestamp)}
{/* Meta row */}
{formatNotificationTypeLabel(notification.type)}
{notification.delivery_channel}
{notification.delivery_status !== "delivered" && notification.delivery_status !== "pending" && (
{notification.delivery_status}
)}
{notification.payload.tags && notification.payload.tags.length > 0 && (
notification.payload.tags.map((tag) => (
{tag}
))
)}
)
}
export function NotificationsPage() {
const navigate = useNavigate()
const [notifications, setNotifications] = useState([])
const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0)
const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState(null)
const [selectedNotificationId, setSelectedNotificationId] = useState(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
const [searchTerm, setSearchTerm] = useState("")
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
const [levelFilter, setLevelFilter] = useState<"all" | string>("all")
const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true)
setError(false)
try {
const [notifRes, unreadRes] = await Promise.all([
getNotifications(pageSize, currentOffset),
getUnreadCount(),
])
setNotifications(notifRes.data.notifications ?? [])
setTotalCount(notifRes.data.total_count)
setGlobalUnread(unreadRes.data.unread)
} catch {
setError(true)
} finally {
setLoading(false)
}
}, [pageSize])
useEffect(() => {
fetchData(offset)
}, [offset, pageSize, fetchData])
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
setTogglingIds((prev) => new Set(prev).add(id))
try {
if (currentlyRead) {
await markAsUnread(id)
} else {
await markAsRead(id)
}
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: !currentlyRead } : n)),
)
setGlobalUnread((prev) => (currentlyRead ? prev + 1 : Math.max(0, prev - 1)))
} catch {
// silently fail — user can retry
} finally {
setTogglingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
}, [])
const handleMarkAllRead = useCallback(async () => {
setBulkLoading(true)
try {
await markAllRead()
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
setGlobalUnread(0)
} catch {
// silently fail
} finally {
setBulkLoading(false)
}
}, [])
const handleMarkAllUnread = useCallback(async () => {
setBulkLoading(true)
try {
await markAllUnread()
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: false })))
setGlobalUnread(totalCount)
} catch {
// silently fail
} finally {
setBulkLoading(false)
}
}, [totalCount])
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const currentPage = Math.floor(offset / pageSize) + 1
const startEntry = totalCount === 0 ? 0 : offset + 1
const endEntry = Math.min(offset + pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (currentPage > 4) pages.push("...")
if (currentPage > 3 && currentPage < totalPages - 2) pages.push(currentPage)
if (currentPage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const filteredNotifications = notifications.filter((n) => {
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
if (activeStatusTab === "read" && !n.is_read) return false
if (activeStatusTab === "unread" && n.is_read) return false
if (typeFilter !== "all" && n.type !== typeFilter) return false
if (levelFilter !== "all" && n.level !== levelFilter) return false
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase()
const haystack = [
getNotificationTitle(n),
getNotificationMessage(n),
formatNotificationTypeLabel(n.type),
n.delivery_channel,
n.level,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (!haystack.includes(q)) return false
}
return true
})
const loadNotificationDetail = useCallback(async (id: string) => {
setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true)
try {
const res = await getNotificationById(id)
if (!res.data) {
setDetailError(true)
toast.error("Notification not found")
return
}
setSelectedNotification(res.data)
if (!res.data.is_read) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
)
setGlobalUnread((prev) => Math.max(0, prev - 1))
try {
await markAsRead(id)
} catch {
// list refresh on next load will reconcile
}
}
} catch {
setDetailError(true)
toast.error("Failed to load notification details")
} finally {
setDetailLoading(false)
}
}, [])
const handleOpenDetail = (notification: Notification) => {
void loadNotificationDetail(notification.id)
}
return (
{/* Header */}
Notifications
My Notifications
{totalCount > 0 && {totalCount}}
{globalUnread > 0 && {globalUnread} unread}
{/* Bulk actions */}
{!loading && !error && (
{notifications.length > 0 && (
<>
{globalUnread > 0 ? (
) : (
)}
>
)}
)}
{/* Summary cards */}
{!loading && !error && (
Total notifications
{totalCount.toLocaleString()}
Unread
{globalUnread.toLocaleString()}
Channels used
{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
)}
{/* Loading */}
{loading && (
)}
{/* Error */}
{!loading && error && (
Failed to load notifications.
)}
{/* Empty */}
{!loading && !error && notifications.length === 0 && (
No notifications yet
When you receive notifications, they'll appear here.
)}
{/* Filters + table */}
{!loading && !error && notifications.length > 0 && (
<>
{/* Status tabs */}
{(["all", "unread", "read"] as const).map((tab) => (
))}
{/* Filters */}
setSearchTerm(e.target.value)}
/>
Channel
setChannelFilter(value as typeof channelFilter)}
>
All
Push
SMS
Type
All types
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
{formatNotificationTypeLabel(t)}
))}
Level
All levels
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
{lvl}
))}
Type
Title
Message
Channel
Status
Created
Actions
{filteredNotifications.length === 0 ? (
No notifications match your filters
Try adjusting your filters
) : (
filteredNotifications.map((n) => {
const config = NOTIFICATION_TYPE_CONFIG[n.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
const isToggling = togglingIds.has(n.id)
return (
handleOpenDetail(n)}
>
{formatNotificationTypeLabel(n.type)}
{getNotificationTitle(n)}
{getNotificationMessage(n)}
{n.delivery_channel}
{n.is_read ? "Read" : "Unread"}
{formatNotificationTimestamp(n.timestamp)}
e.stopPropagation()}
>
)
})
)}
Showing
{startEntry}-{endEntry}
of
{totalCount}
entries
Rows per page
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
...
) : (
),
)}
>
)}
void loadNotificationDetail(selectedNotificationId)
: undefined
}
/>
)
}