fe integration for notification system with context, components, and API integration

This commit is contained in:
2026-10-20 16:53:15 +05:30
parent 6b9f27573b
commit 209ee7da2d
11 changed files with 696 additions and 17 deletions
@@ -0,0 +1,122 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { Bell } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useNotifications } from '@/contexts/notification-context'
import { cn } from '@/lib/utils'
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
const SEVERITY_DOT: Record<NotificationSeverity, string> = {
Info: 'bg-sky-500',
Success: 'bg-emerald-500',
Warning: 'bg-amber-500',
Critical: 'bg-red-500',
}
export function timeAgo(iso: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(iso).toLocaleDateString()
}
export function NotificationRow({
notification: n,
onOpen,
}: {
notification: NotificationResponse
onOpen: (n: NotificationResponse) => void
}) {
return (
<button
type="button"
onClick={() => onOpen(n)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
!n.isRead && 'bg-muted/30'
)}
>
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', n.isRead ? 'bg-transparent' : SEVERITY_DOT[n.severity])} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={cn('truncate text-sm', n.isRead ? 'text-foreground' : 'font-medium text-foreground')}>
{n.title}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{n.message}</span>
<span className="text-[11px] text-muted-foreground/70">{timeAgo(n.createdAt)}</span>
</span>
</button>
)
}
export function NotificationBell() {
const { recent, unreadCount, open, markAllRead } = useNotifications()
const [isOpen, setIsOpen] = useState(false)
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon"
className="relative h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
/>
}
>
<Bell size={16} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</PopoverTrigger>
<PopoverContent>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="xs"
disabled={unreadCount === 0}
onClick={() => { void markAllRead() }}
>
Mark all read
</Button>
</div>
<div className="max-h-96 divide-y divide-border overflow-y-auto">
{recent.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">You&apos;re all caught up.</p>
) : (
recent.map(n => (
<NotificationRow
key={n.id}
notification={n}
onOpen={item => { setIsOpen(false); void open(item) }}
/>
))
)}
</div>
<div className="border-t border-border p-2">
<Link
href="/notifications"
onClick={() => setIsOpen(false)}
className="block rounded-xl px-3 py-2 text-center text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
View all
</Link>
</div>
</PopoverContent>
</Popover>
)
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
align = "end",
alignOffset = 0,
side = "bottom",
sideOffset = 6,
className,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 w-[min(24rem,calc(100vw-2rem))] origin-(--transform-origin) rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }