src/components/notification-bell.tsx
Linked repository file from this build.
"use client";
import { useState } from "react";
import { useNotifications } from "@/features/inbox/notifications";
import { Bell } from "lucide-react";
import { useReviewNavigation } from "@/features/reviews/provider";
import { eventLabels, type InboxEvent } from "@/features/inbox/types";
import { HeaderPopover } from "./header-popover";
export function NotificationBell() {
const { navigate } = useReviewNavigation();
const { events, count, error, setEvents, setCount, setError } = useNotifications();
const [clearing, setClearing] = useState(false);
async function clear() {
if (clearing || !events.length) return;
const through = events.reduce(
(latest, event) => (event.createdAt > latest ? event.createdAt : latest),
events[0].createdAt,
);
setClearing(true);
setError("");
try {
const response = await fetch("/api/inbox/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ through }),
});
if (!response.ok) throw new Error("Could not clear notifications. Please try again.");
setEvents((rows) => rows.filter((event) => event.createdAt > through));
setCount(0);
window.dispatchEvent(new Event("qccrm:notifications"));
} catch (cause) {
setError((cause as Error).message);
} finally {
setClearing(false);
}
}
async function open(event: InboxEvent) {
try {
const ok = await navigate(`/reviews/${event.reviewKey}`, async () => {
const response = await fetch(`/api/inbox/events/${encodeURIComponent(event.id)}/read`, {
method: "POST",
});
if (!response.ok) throw new Error("Could not mark this notification read.");
setEvents((rows) => rows.filter((row) => row.id !== event.id));
setCount((value) => Math.max(0, value - (event.readAt ? 0 : 1)));
});
if (!ok) setError("Save this review before opening a notification.");
} catch {
setError("Could not mark this notification read. Please try again.");
}
}
return (
<HeaderPopover
label={`Notifications${count ? `, ${count} unread` : ""}`}
trigger={
<>
<Bell className="size-4" />
{count > 0 && (
<span className="absolute -right-1 -top-1 rounded-full bg-destructive px-1.5 text-[0.625rem] font-semibold leading-4 text-destructive-foreground">
{count > 99 ? "99+" : count}
</span>
)}
</>
}
>
<h2 className="font-semibold">Notifications</h2>
{error && (
<p role="status" className="mt-3 text-sm text-destructive">
{error}
</p>
)}
{events.length ? (
<ul className="mt-3 max-h-80 divide-y overflow-y-auto">
{events.map((event) => (
<li key={event.id} className="h-8">
<button
type="button"
onClick={() => void open(event)}
className="grid h-full w-full grid-cols-[4.5rem_7.5rem_minmax(0,1fr)] items-center gap-2 rounded-md px-1 text-left text-xs hover:bg-muted"
>
<time
className="truncate text-muted-foreground"
dateTime={event.createdAt}
title={new Date(event.createdAt).toLocaleString()}
>
{new Date(event.createdAt).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
</time>
<span className="font-medium tabular-nums">
{event.reviewNumber} · {event.fiscalYear}
</span>
<span className="truncate" title={eventLabels[event.kind] || event.kind}>
{eventLabels[event.kind] || event.kind}
</span>
</button>
</li>
))}
</ul>
) : (
!error && <p className="mt-3 text-sm text-muted-foreground">No unread notifications.</p>
)}
<div className="mt-3 flex justify-end border-t pt-3">
<button
type="button"
disabled={clearing || !events.length}
onClick={() => void clear()}
className="h-8 rounded-md border px-2 text-xs hover:bg-muted disabled:opacity-50"
>
{clearing ? "Clearing…" : "Clear notifications"}
</button>
</div>
</HeaderPopover>
);
}