Skip to contentVibraUI
Shared pagesinstalls at /notifications

Notification inbox

Every notification as a page: an unread count in the header, search, status and kind filters, inline accept and decline on an assignment, and a paged list over db.notifications.

Open the live page

Installs at /notifications. The page reads every row of db.notifications on the server, newest first, and hands them to one island that owns the filters, the page and the read states — and the unread count in the header, which is why the island renders the PageHeader. A mention or an assignment shows the teammate its title names; every other kind shows a tinted tile. Opening a row marks it read through markRead. An assignment is answered rather than read: it keeps its Accept and Decline until respond writes the decision into the row — read, with its description rewritten to say what was decided — so an open assignment can be told from a closed one after a reload; Mark all as read turns every other unread row through markAllRead and leaves the open assignments waiting, saying how many it turned and how many still need an answer; a row that is not an assignment, one already answered, or a decision the page does not know is refused in a callout under the row. The status Select, the kind facet, the search and the density toggle all narrow the same list, and the footer strip says where in it the reader is. Composes AppShell, PageHeader, Badge, SearchInput, Select, FilterBar, FilterMenu, DensityToggle, Card, Avatar, StatusBadge, Button, Callout and EmptyState.

Preview

Install

npx shadcn@latest add @vibra/notifications-page

Needs the @vibra registry in your components.json — set it up once.

Source

app/notifications/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"

import { signOut } from "./actions"
import { NotificationsView } from "./components/notifications-view"
import { currentUser, notifications, PAGE_SIZE, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * The notifications inbox. The page is a server component: it reads every
 * row through `db.notifications`, writes the dates into words, and hands the
 * rows to one client island that owns the filters, the page and the read
 * states — and the count in the header, which is why the island renders the
 * PageHeader. The shell's bell reads the same store, built once at module
 * scope so it keeps its own read record.
 */
export default function NotificationsPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <NotificationsView rows={notifications()} pageSize={PAGE_SIZE} />
    </AppShell>
  )
}
app/notifications/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/notifications"

/**
 * This product's navigation, as plain data. AppShell resolves the icon names
 * and works out which item is current from the route it is handed, so nothing
 * here is a component and nothing here says "I am the page you are on".
 *
 * Account is a disclosure with the two account pages as its leaves, so this
 * page's own route is a leaf: a parent with `items` renders as a button and
 * has no anchor for `aria-current="page"` to land on.
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [
        { title: "Overview", href: "/saas", icon: "layout-dashboard" },
        { title: "Customers", href: "/ecommerce/customers", icon: "users" },
      ],
    },
    {
      label: "Team",
      items: [
        { title: "Members", href: "/people/directory", icon: "users" },
        { title: "Invitations", href: "/people/invitations", icon: "user-round" },
      ],
    },
  ],
  footer: [
    {
      title: "Account",
      href: "/profile",
      icon: "user-round",
      items: [
        { title: "Profile", href: "/profile" },
        { title: "Notifications", href: "/notifications" },
      ],
    },
    { title: "Settings", href: "/settings", icon: "settings" },
    { title: "Support", href: "/support", icon: "life-buoy" },
  ],
}
app/notifications/data.ts
/**
 * What this page reads: `db.notifications`, every row, newest first, each
 * with its time already written for the reader and — for a mention or an
 * assignment — the teammate whose name the title carries, so the row can
 * show a person where the other kinds show an icon. "Now" is `REFERENCE_DATE`.
 */
import { formatRelative, getInitials } from "@/lib/format"
import { avatarFor, db, REFERENCE_DATE, type Notification } from "@/lib/sample-data"

export type NotificationKind = Notification["kind"]

/** How many rows a page of the list holds. */
export const PAGE_SIZE = 10

export type NotificationRow = {
  id: string
  title: string
  description?: string
  kind: NotificationKind
  at: Date
  /** How long ago, against REFERENCE_DATE, or the date once that stops being useful. */
  when: string
  read: boolean
  href?: string
  /** The teammate a mention or an assignment came from — the name the title opens with. */
  actor?: { name: string; initials: string; src?: string }
}

const DAY_MS = 86_400_000
const shortDay = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** "2h ago" against `REFERENCE_DATE`, and the date itself past a week. */
export function since(at: Date): string {
  const days = (REFERENCE_DATE.getTime() - at.getTime()) / DAY_MS
  return days < 7 ? formatRelative(at, REFERENCE_DATE) : shortDay.format(at)
}

// A mention or an assignment is written "<Name> mentioned you…": the first
// word is the teammate, and the row shows them rather than a kind icon.
const PERSONAL: ReadonlySet<NotificationKind> = new Set(["mention", "assignment"])

/** The teammate a personal notification came from, read off its own title. */
export function actorOf(row: Pick<Notification, "kind" | "title">): NotificationRow["actor"] {
  if (!PERSONAL.has(row.kind)) return undefined
  const name = row.title.split(" ")[0]
  return name ? { name, initials: getInitials(name), src: avatarFor(name) } : undefined
}

/** A row shaped for the list; the actions use it too, so an answered row reads like the rest. */
export function toRow(row: Notification): NotificationRow {
  return {
    id: row.id,
    title: row.title,
    description: row.description,
    kind: row.kind,
    at: row.at,
    when: since(row.at),
    read: row.read,
    href: row.href,
    actor: actorOf(row),
  }
}

/** Every notification, newest first — read fresh, since the actions on this page change them. */
export function notifications(): NotificationRow[] {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .map(toRow)
}

// Read per call, never held at module scope: a rename on the profile page
// shows here on the next render.
const memberRow = () => db.members.all()[0]

/** The person looking at the page: the first member of the workspace, who owns it. */
export function currentUser() {
  const member = memberRow()
  return { name: member.name, email: member.email, initials: getInitials(member.name), avatarUrl: member.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/notifications/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"

import { toRow, type NotificationRow } from "./data"

/**
 * What this page changes — all of it over `db.notifications`, so a page that
 * re-reads the store sees the same rows the island shows. Server actions so
 * the page can stay a server component, and `Result` so every caller reads
 * one success-or-error shape: a refusal renders as a callout where the row is.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** An assignment nobody has answered yet: unread, and not one a read may close. */
const isOpenAssignment = (row: { kind: string; read: boolean }) => row.kind === "assignment" && !row.read

/**
 * Marks one notification read; opening a row is what calls it. An assignment
 * is answered, not read: the row is looked up first, and one still waiting
 * for its answer is refused here as well as in the island, so a consumer who
 * wires this to a row of their own cannot close it without a decision.
 */
export async function markRead(id: string): Promise<Result<NotificationRow>> {
  const row = await db.notifications.get(id)
  if (!row) {
    return { ok: false, error: { code: "not_found", message: "That notification is no longer here." } }
  }
  if (isOpenAssignment(row)) {
    return { ok: false, error: { code: "conflict", message: "Accept or decline the assignment to close it." } }
  }
  const updated = await db.notifications.update(id, { read: true })
  if (!updated.ok) return updated
  return { ok: true, data: toRow(updated.data) }
}

/**
 * Marks every unread notification read — except the assignments still
 * waiting for an answer, which stay open until `respond` closes them: an
 * assignment is answered, not read. Says how many it turned and how many it
 * left open, so the page can say both.
 */
export async function markAllRead(): Promise<Result<{ count: number; open: number }>> {
  const unread = db.notifications.all().filter((row) => !row.read)
  const toTurn = unread.filter((row) => !isOpenAssignment(row))
  for (const row of toTurn) {
    const updated = await db.notifications.update(row.id, { read: true })
    if (!updated.ok) return updated
  }
  return { ok: true, data: { count: toTurn.length, open: unread.length - toTurn.length } }
}

export type Decision = "accept" | "decline"

const ANSWERS: Record<Decision, string> = {
  accept: "Accepted — it is in your queue.",
  decline: "Declined.",
}

const isDecision = (value: string): value is Decision => value === "accept" || value === "decline"

/**
 * Answers an assignment. The row is trusted, not the form: only an
 * assignment can be answered, and only once — an answered row is read, and
 * its description is rewritten to say what was decided, which is how the
 * page tells an open assignment from a closed one after a reload.
 */
export async function respond(id: string, decision: Decision): Promise<Result<NotificationRow & { decision: Decision }>> {
  if (!isDecision(decision)) {
    return { ok: false, error: { code: "invalid", message: "An assignment is accepted or declined, nothing else.", field: "decision" } }
  }
  const row = await db.notifications.get(id)
  if (!row) {
    return { ok: false, error: { code: "not_found", message: "That notification is no longer here." } }
  }
  if (row.kind !== "assignment") {
    return { ok: false, error: { code: "invalid", message: "Only an assignment can be accepted or declined." } }
  }
  if (row.read) {
    return { ok: false, error: { code: "conflict", message: "That assignment has already been answered." } }
  }
  const updated = await db.notifications.update(id, { read: true, description: ANSWERS[decision] })
  if (!updated.ok) return updated
  return { ok: true, data: { ...toRow(updated.data), decision } }
}
app/notifications/components/notification-row.tsx
"use client"

import * as React from "react"
import { AtSignIcon, CheckIcon, ClipboardListIcon, ReceiptIcon, ServerIcon, ShieldAlertIcon, XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { StatusBadge } from "@/components/ui/status-badge"

import { type Decision } from "../actions"
import { type NotificationKind, type NotificationRow as Row } from "../data"
import { KIND_MAP } from "./notification-vocabulary"

const KIND_ICONS: Record<NotificationKind, React.ReactNode> = {
  mention: <AtSignIcon aria-hidden="true" />,
  assignment: <ClipboardListIcon aria-hidden="true" />,
  billing: <ReceiptIcon aria-hidden="true" />,
  system: <ServerIcon aria-hidden="true" />,
  security: <ShieldAlertIcon aria-hidden="true" />,
}

// The tile is tinted in the kind's tone, the same tone its pill wears — except
// the assignment's, whose pill wears the accent: a decorative fill is never
// the accent, so that tile is ink.
const TILE_TONES: Record<NotificationKind, string> = {
  mention: "bg-info-muted text-info",
  assignment: "bg-foreground text-background",
  billing: "bg-warning-muted text-warning",
  system: "bg-muted text-muted-foreground",
  security: "bg-danger-muted text-danger",
}

export type NotificationRowProps = {
  row: Row
  /** Opening the row is what marks it read. */
  onOpen: (row: Row) => void
  onRespond: (row: Row, decision: Decision) => void
  /** A refusal about this row, rendered under it. */
  error?: string
}

/**
 * One notification: a person or a kind tile, the title with its unread dot,
 * the line under it, the inline answer for an open assignment, the kind pill
 * and the time. An assignment stays open — unread, with its buttons — until
 * it is answered; every other row is read the moment it is opened.
 */
export function NotificationRow({ row, onOpen, onRespond, error }: NotificationRowProps) {
  const open = row.kind === "assignment" && !row.read

  return (
    <li
      data-slot="notification-row"
      data-kind={row.kind}
      data-read={String(row.read)}
      className={cn(
        "flex flex-col gap-2 px-4 py-[var(--density-cell-y,0.75rem)] transition-colors",
        !row.read && "bg-brand-muted/40"
      )}
    >
      <div className="flex items-start gap-3">
        {row.actor ? (
          <Avatar className="mt-0.5">
            {row.actor.src ? <AvatarImage src={row.actor.src} alt="" /> : null}
            <AvatarFallback>{row.actor.initials}</AvatarFallback>
          </Avatar>
        ) : (
          <span
            aria-hidden="true"
            className={cn(
              "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full [&_svg]:size-4",
              TILE_TONES[row.kind]
            )}
          >
            {KIND_ICONS[row.kind]}
          </span>
        )}

        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <div className="flex items-center gap-2">
            {/* The title is the control that opens the row: a real button, so
                the keyboard has it, named after what it opens. */}
            <button
              type="button"
              aria-label={`Open ${row.title}`}
              className={cn(
                // Two lines on a phone, one on anything wider: a title cut to
                // "Scheduled maintenance compl…" says less than the row has room for.
                "min-w-0 rounded-sm text-left text-sm font-medium text-foreground line-clamp-2 focus-ring sm:line-clamp-none sm:truncate",
                !open && "hover:underline"
              )}
              onClick={() => onOpen(row)}
            >
              {row.title}
            </button>
            {!row.read ? (
              <>
                <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-foreground" />
                <span className="sr-only">Unread.</span>
              </>
            ) : null}
          </div>
          {row.description ? <p className="text-sm text-muted-foreground">{row.description}</p> : null}
          {open ? (
            <div className="mt-1.5 flex flex-wrap items-center gap-2">
              <Button type="button" size="sm" onClick={() => onRespond(row, "accept")}>
                <CheckIcon data-icon="inline-start" aria-hidden="true" />
                Accept
              </Button>
              <Button type="button" size="sm" variant="outline" onClick={() => onRespond(row, "decline")}>
                <XIcon data-icon="inline-start" aria-hidden="true" />
                Decline
              </Button>
            </div>
          ) : null}
        </div>

        <div className="flex shrink-0 flex-col items-end gap-1 sm:flex-row sm:items-center sm:gap-3">
          <StatusBadge status={row.kind} map={KIND_MAP} size="sm" />
          <time dateTime={row.at.toISOString()} className="text-xs tabular-nums whitespace-nowrap text-muted-foreground">
            {row.when}
          </time>
        </div>
      </div>
      {error ? (
        <Callout variant="danger" role="alert" className="ml-11">
          {error}
        </Callout>
      ) : null}
    </li>
  )
}
app/notifications/components/notification-vocabulary.ts
/**
 * The words this page brings of its own: how each kind reads, the tone its
 * tile is tinted in, and the status filter's three options. UI vocabulary,
 * so it lives beside the island rather than in data.ts, which reads the
 * store and must never reach the browser.
 */
import { type NotificationKind } from "../data"

export type KindTone = "info" | "primary" | "warning" | "neutral" | "danger"

export const KINDS: { value: NotificationKind; label: string; tone: KindTone }[] = [
  { value: "mention", label: "Mention", tone: "info" },
  { value: "assignment", label: "Assignment", tone: "primary" },
  { value: "billing", label: "Billing", tone: "warning" },
  { value: "system", label: "System", tone: "neutral" },
  { value: "security", label: "Security", tone: "danger" },
]

/** The kind's pill: `StatusBadge` reads the tone off this map. */
export const KIND_MAP: Record<NotificationKind, KindTone> = Object.fromEntries(
  KINDS.map((kind) => [kind.value, kind.tone])
) as Record<NotificationKind, KindTone>

export type StatusFilter = "all" | "unread" | "read"

export const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
  { value: "all", label: "All" },
  { value: "unread", label: "Unread" },
  { value: "read", label: "Read" },
]
app/notifications/components/notifications-toolbar.tsx
"use client"

import * as React from "react"

import { DensityToggle } from "@/components/ui/density-toggle"
import { FilterBar, FilterMenu } from "@/components/ui/filter-bar"
import { SearchInput } from "@/components/ui/search-input"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

import { KINDS, STATUS_OPTIONS, type StatusFilter } from "./notification-vocabulary"

export type NotificationsToolbarProps = {
  search: string
  onSearchChange: (value: string) => void
  status: StatusFilter
  onStatusChange: (value: StatusFilter) => void
  kinds: string[]
  onKindsChange: (value: string[]) => void
  /** Rows each kind would leave, for the counts in the menu. */
  kindCounts: Record<string, number>
}

/** The row above the list: search, the status, the kind facet, and the rhythm. */
export function NotificationsToolbar({
  search,
  onSearchChange,
  status,
  onStatusChange,
  kinds,
  onKindsChange,
  kindCounts,
}: NotificationsToolbarProps) {
  const active = (status === "all" ? 0 : 1) + (kinds.length ? 1 : 0)

  return (
    <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
      <SearchInput
        aria-label="Search notifications"
        placeholder="Search notifications…"
        value={search}
        onValueChange={onSearchChange}
        className="sm:w-72"
      />
      <FilterBar
        activeCount={active}
        onClearAll={() => {
          onStatusChange("all")
          onKindsChange([])
        }}
      >
        <Select value={status} onValueChange={(next) => onStatusChange((next ?? "all") as StatusFilter)} items={STATUS_OPTIONS}>
          <SelectTrigger aria-label="Status" className="min-w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {STATUS_OPTIONS.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <FilterMenu
          label="Kind"
          options={KINDS.map((kind) => ({ value: kind.value, label: kind.label, count: kindCounts[kind.value] ?? 0 }))}
          value={kinds}
          onValueChange={onKindsChange}
          searchable={false}
        />
        <DensityToggle iconOnly />
      </FilterBar>
    </div>
  )
}
app/notifications/components/notifications-view.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { CheckCheckIcon, ChevronLeftIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { PageHeader } from "@/components/ui/page-header"

import { markAllRead, markRead, respond, type Decision } from "../actions"
import { type NotificationRow as Row } from "../data"
import { NotificationRow } from "./notification-row"
import { NotificationsToolbar } from "./notifications-toolbar"
import { type StatusFilter } from "./notification-vocabulary"

export type NotificationsViewProps = {
  rows: Row[]
  pageSize: number
}

function passes(row: Row, search: string, status: StatusFilter, kinds: string[]): boolean {
  if (status === "unread" && row.read) return false
  if (status === "read" && !row.read) return false
  if (kinds.length && !kinds.includes(row.kind)) return false
  if (search) {
    const needle = search.toLowerCase()
    return row.title.toLowerCase().includes(needle) || (row.description ?? "").toLowerCase().includes(needle)
  }
  return true
}

/**
 * The inbox. One island, because the filters, the page and the read states
 * are the same piece of state, and the count in the header follows them.
 * Every change goes through a server action over db.notifications and is
 * applied only once that action says it took.
 */
export function NotificationsView({ rows: initial, pageSize }: NotificationsViewProps) {
  const [rows, setRows] = React.useState(initial)
  const [search, setSearch] = React.useState("")
  const [status, setStatus] = React.useState<StatusFilter>("all")
  const [kinds, setKinds] = React.useState<string[]>([])
  const [page, setPage] = React.useState(1)
  const [rowErrors, setRowErrors] = React.useState<Record<string, string>>({})
  const [error, setError] = React.useState<string | undefined>(undefined)
  const [notice, setNotice] = React.useState<string | undefined>(undefined)

  const unread = rows.filter((row) => !row.read).length
  // What Mark all as read can still turn: an open assignment is answered, not read.
  const turnable = rows.filter((row) => !row.read && row.kind !== "assignment").length
  const visible = rows.filter((row) => passes(row, search, status, kinds))
  const pageCount = Math.max(1, Math.ceil(visible.length / pageSize))
  const current = Math.min(page, pageCount)
  const start = (current - 1) * pageSize
  const shown = visible.slice(start, start + pageSize)

  const kindCounts = rows.reduce<Record<string, number>>((counts, row) => {
    counts[row.kind] = (counts[row.kind] ?? 0) + 1
    return counts
  }, {})

  function patch(next: Row) {
    setRows((list) => list.map((row) => (row.id === next.id ? next : row)))
  }

  /** Drops a row's refusal once something about it succeeded. */
  function clearRowError(id: string) {
    setRowErrors((errors) => Object.fromEntries(Object.entries(errors).filter(([key]) => key !== id)))
  }

  /** A filter change starts the list over: page one of the new rows. */
  function refilter<T>(set: (value: T) => void) {
    return (value: T) => {
      set(value)
      setPage(1)
    }
  }

  async function open(row: Row) {
    if (row.kind === "assignment" && !row.read) {
      setNotice("Accept or decline the assignment to close it.")
      return
    }
    if (row.read) return
    const result = await markRead(row.id)
    if (!result.ok) {
      setRowErrors((errors) => ({ ...errors, [row.id]: result.error.message }))
      return
    }
    clearRowError(row.id)
    patch(result.data)
  }

  async function answer(row: Row, decision: Decision) {
    const result = await respond(row.id, decision)
    if (!result.ok) {
      setRowErrors((errors) => ({ ...errors, [row.id]: result.error.message }))
      return
    }
    clearRowError(row.id)
    patch(result.data)
    setNotice(`${decision === "accept" ? "Accepted" : "Declined"}: ${row.title}.`)
  }

  async function clearAll() {
    const result = await markAllRead()
    if (!result.ok) {
      setError(result.error.message)
      return
    }
    setError(undefined)
    setRows((list) => list.map((row) => (row.read || row.kind === "assignment" ? row : { ...row, read: true })))
    const { count, open } = result.data
    setNotice(
      open > 0
        ? `${formatNumber(count)} marked as read; ${formatNumber(open)} ${open === 1 ? "assignment still needs" : "assignments still need"} an answer.`
        : `${formatNumber(count)} marked as read.`
    )
  }

  return (
    <div className="flex flex-col gap-4">
      <PageHeader
        title="Notifications"
        description="Everything that needs you, newest first."
        badge={<Badge variant={unread > 0 ? "default" : "secondary"}>{formatNumber(unread)} unread</Badge>}
        actions={
          <>
            <Button type="button" variant="outline" onClick={() => void clearAll()} disabled={turnable === 0}>
              <CheckCheckIcon data-icon="inline-start" aria-hidden="true" />
              Mark all as read
            </Button>
            <Link
              href="/settings/notifications"
              aria-label="Notification settings"
              className={buttonVariants({ variant: "outline", size: "icon" })}
            >
              <SettingsIcon aria-hidden="true" />
            </Link>
          </>
        }
      />

      <NotificationsToolbar
        search={search}
        onSearchChange={refilter(setSearch)}
        status={status}
        onStatusChange={refilter(setStatus)}
        kinds={kinds}
        onKindsChange={refilter(setKinds)}
        kindCounts={kindCounts}
      />

      {error ? (
        <Callout variant="danger" role="alert">
          {error}
        </Callout>
      ) : null}
      <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
        {notice}
      </p>

      <Card>
        <CardContent className="p-0">
          {shown.length === 0 ? (
            <EmptyState title="Nothing here" description="No notification matches these filters." />
          ) : (
            <ol aria-label="Notifications" className="flex flex-col divide-y">
              {shown.map((row) => (
                <NotificationRow
                  key={row.id}
                  row={row}
                  error={rowErrors[row.id]}
                  onOpen={(entry) => void open(entry)}
                  onRespond={(entry, decision) => void answer(entry, decision)}
                />
              ))}
            </ol>
          )}
        </CardContent>
        <CardFooter className="justify-between">
          <p aria-live="polite" className="text-sm tabular-nums text-muted-foreground">
            {visible.length === 0
              ? "Showing 0 of 0"
              : `Showing ${formatNumber(start + 1)}–${formatNumber(Math.min(start + pageSize, visible.length))} of ${formatNumber(visible.length)}`}
          </p>
          <div className="flex items-center gap-1">
            <Button type="button" variant="outline" size="sm" disabled={current <= 1} onClick={() => setPage(current - 1)}>
              <ChevronLeftIcon data-icon="inline-start" aria-hidden="true" />
              Previous
            </Button>
            <Button type="button" variant="outline" size="sm" disabled={current >= pageCount} onClick={() => setPage(current + 1)}>
              Next
              <ChevronRightIcon data-icon="inline-end" aria-hidden="true" />
            </Button>
          </div>
        </CardFooter>
      </Card>
    </div>
  )
}