{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-bell",
  "title": "Notification Bell",
  "description": "Bell button with unread badge and a dropdown of recent notifications; optional polling through a fetchSummary callback, type labels and link targets as props.",
  "dependencies": [
    "cn",
    "lucide-react"
  ],
  "registryDependencies": [
    "@sav/button",
    "@sav/dropdown-menu"
  ],
  "files": [
    {
      "path": "registry/sav/components/notification-bell.tsx",
      "content": "// @sav-registry: notification-bell\n\"use client\"\n\n// App-shell notification bell: unread-count badge + a dropdown of recent\n// notifications. Starts from server-rendered props and, when `fetchSummary` is\n// given, polls it (simple polling, no WebSocket). Read-only: mark-read and\n// delete live on the page behind `viewAllHref`.\n\nimport Link from \"next/link\"\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { BellIcon } from \"lucide-react\"\nimport { cn } from \"cn\"\n\nimport { Button } from \"@/registry/sav/ui/button\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/registry/sav/ui/dropdown-menu\"\n\n/** Serializable notification shape passed from the server. */\nexport interface NotificationItem {\n  id: string\n  /** Category key; shown through `typeLabels` when provided, raw otherwise. */\n  type?: string\n  title: string\n  message: string\n  isRead: boolean\n  /** ISO timestamp. */\n  createdAt: string\n  /** Deep link for the row; falls back to `viewAllHref`. */\n  href?: string | null\n}\n\nexport interface NotificationSummary {\n  unreadCount: number\n  items: NotificationItem[]\n}\n\n/** Compact relative time for the dropdown (\"just now\", \"3h\", \"2d\"). */\nexport function formatRelativeTime(iso: string, now = Date.now()): string {\n  const then = new Date(iso).getTime()\n  if (Number.isNaN(then)) return \"\"\n  const seconds = Math.max(0, Math.floor((now - then) / 1000))\n  if (seconds < 60) return \"just now\"\n  const minutes = Math.floor(seconds / 60)\n  if (minutes < 60) return `${minutes}m`\n  const hours = Math.floor(minutes / 60)\n  if (hours < 24) return `${hours}h`\n  const days = Math.floor(hours / 24)\n  if (days < 30) return `${days}d`\n  const months = Math.floor(days / 30)\n  if (months < 12) return `${months}mo`\n  return `${Math.floor(months / 12)}y`\n}\n\nexport function NotificationBell({\n  initialUnread = 0,\n  initialItems = [],\n  fetchSummary,\n  pollIntervalMs = 30_000,\n  viewAllHref = \"/notifications\",\n  typeLabels,\n  formatTime = formatRelativeTime,\n  title = \"Notifications\",\n  emptyText = \"No notifications yet\",\n  viewAllLabel = \"View all notifications\",\n  className,\n}: {\n  initialUnread?: number\n  initialItems?: NotificationItem[]\n  /** Returns the latest summary; polled every `pollIntervalMs`. Omit to render static props only. */\n  fetchSummary?: (signal: AbortSignal) => Promise<NotificationSummary>\n  pollIntervalMs?: number\n  viewAllHref?: string\n  /** Display labels per `type` key. */\n  typeLabels?: Record<string, string>\n  formatTime?: (iso: string) => string\n  title?: string\n  emptyText?: string\n  viewAllLabel?: string\n  className?: string\n}) {\n  const [unread, setUnread] = useState(initialUnread)\n  const [items, setItems] = useState<NotificationItem[]>(initialItems)\n  const abortRef = useRef<AbortController | null>(null)\n\n  const refresh = useCallback(async () => {\n    if (!fetchSummary) return\n    abortRef.current?.abort()\n    const controller = new AbortController()\n    abortRef.current = controller\n    try {\n      const data = await fetchSummary(controller.signal)\n      if (controller.signal.aborted) return\n      setUnread(data.unreadCount ?? 0)\n      setItems(Array.isArray(data.items) ? data.items : [])\n    } catch {\n      // Network hiccup / aborted — keep the last known state.\n    }\n  }, [fetchSummary])\n\n  useEffect(() => {\n    if (!fetchSummary || pollIntervalMs <= 0) return\n    const id = setInterval(refresh, pollIntervalMs)\n    return () => {\n      clearInterval(id)\n      abortRef.current?.abort()\n    }\n  }, [fetchSummary, pollIntervalMs, refresh])\n\n  const badge = unread > 99 ? \"99+\" : String(unread)\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className={cn(\"relative\", className)}\n            aria-label={\n              unread > 0 ? `${title}, ${unread} unread` : title\n            }\n          />\n        }\n      >\n        <BellIcon />\n        {unread > 0 ? (\n          <span className=\"absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground tabular-nums\">\n            {badge}\n          </span>\n        ) : null}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"end\"\n        style={{ width: \"20rem\" }}\n        className=\"max-h-[70vh] p-0\"\n      >\n        <div className=\"flex items-center justify-between border-b px-3 py-2\">\n          <span className=\"text-sm font-semibold\">{title}</span>\n          {unread > 0 ? (\n            <span className=\"text-xs text-muted-foreground\">\n              {unread} unread\n            </span>\n          ) : null}\n        </div>\n        {items.length === 0 ? (\n          <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n            {emptyText}\n          </p>\n        ) : (\n          <ul className=\"divide-y\">\n            {items.map((item) => {\n              const typeLabel = item.type\n                ? (typeLabels?.[item.type] ?? item.type)\n                : null\n              return (\n                <li key={item.id}>\n                  <Link\n                    href={item.href ?? viewAllHref}\n                    className={cn(\n                      \"flex flex-col gap-0.5 px-3 py-2.5 transition-colors hover:bg-muted\",\n                      !item.isRead && \"bg-primary/[0.04]\"\n                    )}\n                  >\n                    <div className=\"flex items-center gap-2\">\n                      <span\n                        className={cn(\n                          \"truncate text-sm\",\n                          !item.isRead && \"font-semibold\"\n                        )}\n                      >\n                        {item.title}\n                      </span>\n                      <span className=\"ml-auto shrink-0 text-[11px] text-muted-foreground\">\n                        {formatTime(item.createdAt)}\n                      </span>\n                    </div>\n                    <span className=\"line-clamp-2 text-xs text-muted-foreground\">\n                      {item.message}\n                    </span>\n                    {typeLabel ? (\n                      <span className=\"text-[10px] font-medium tracking-wide text-muted-foreground uppercase\">\n                        {typeLabel}\n                      </span>\n                    ) : null}\n                  </Link>\n                </li>\n              )\n            })}\n          </ul>\n        )}\n        <div className=\"border-t p-1\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"w-full justify-center\"\n            render={<Link href={viewAllHref} />}\n          >\n            {viewAllLabel}\n          </Button>\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/notification-bell.tsx"
    }
  ],
  "categories": [
    "patterns"
  ],
  "type": "registry:component"
}