fix(notifications): harden bell dropdown against failed mark-read
This commit is contained in:
parent
bc1456db88
commit
2984c72d77
2 changed files with 82 additions and 34 deletions
|
|
@ -49,6 +49,8 @@ export function NotificationsBell() {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [marking, setMarking] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
|
@ -78,9 +80,24 @@ export function NotificationsBell() {
|
|||
}, [fetchNotifications]);
|
||||
|
||||
async function handleItemClick(item: NotificationItem) {
|
||||
if (marking) return;
|
||||
if (!item.read) {
|
||||
await markNotificationsRead([item.id]);
|
||||
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
|
||||
setMarking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await markNotificationsRead([item.id]);
|
||||
if (result.success) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)),
|
||||
);
|
||||
} else {
|
||||
setActionError(result.error ?? "Failed to update notifications.");
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to update notifications.");
|
||||
} finally {
|
||||
setMarking(false);
|
||||
}
|
||||
}
|
||||
if (item.link) {
|
||||
router.push(item.link);
|
||||
|
|
@ -89,9 +106,21 @@ export function NotificationsBell() {
|
|||
}
|
||||
|
||||
async function handleMarkAll() {
|
||||
if (unreadCount === 0) return;
|
||||
await markAllNotificationsRead();
|
||||
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
|
||||
if (unreadCount === 0 || marking) return;
|
||||
setMarking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await markAllNotificationsRead();
|
||||
if (result.success) {
|
||||
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
|
||||
} else {
|
||||
setActionError(result.error ?? "Failed to update notifications.");
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to update notifications.");
|
||||
} finally {
|
||||
setMarking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -104,6 +133,11 @@ export function NotificationsBell() {
|
|||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{unreadCount > 0
|
||||
? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`
|
||||
: "No unread notifications"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 sm:w-96">
|
||||
|
|
@ -112,9 +146,11 @@ export function NotificationsBell() {
|
|||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => void handleMarkAll()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={marking}
|
||||
aria-label="Mark all notifications as read"
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Mark all read
|
||||
{marking ? "Marking..." : "Mark all read"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -131,34 +167,44 @@ export function NotificationsBell() {
|
|||
No notifications yet.
|
||||
</p>
|
||||
) : (
|
||||
notifications.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-1 py-2.5",
|
||||
!item.read && "bg-accent/50",
|
||||
)}
|
||||
onClick={() => void handleItemClick(item)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{!item.read && (
|
||||
<span className="mr-1.5 inline-block size-1.5 rounded-full bg-blue-500 align-middle" />
|
||||
)}
|
||||
{item.title}
|
||||
<>
|
||||
{actionError && (
|
||||
<p role="alert" className="px-3 pb-2 pt-1 text-xs text-red-400">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
{notifications.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-1 py-2.5",
|
||||
!item.read && "bg-accent/50",
|
||||
)}
|
||||
disabled={marking}
|
||||
onClick={() => void handleItemClick(item)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{!item.read && (
|
||||
<span className="mr-1.5 inline-block size-1.5 rounded-full bg-blue-500 align-middle" />
|
||||
)}
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{timeAgo(item.createdAt)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{timeAgo(item.createdAt)}
|
||||
{item.message && (
|
||||
<span className="line-clamp-3 w-full text-xs text-muted-foreground">
|
||||
{item.message}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{notificationLabel(item.type)}
|
||||
</span>
|
||||
</span>
|
||||
{item.message && (
|
||||
<span className="w-full text-xs text-muted-foreground">{item.message}</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{notificationLabel(item.type)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -185,7 +185,9 @@ export function NotificationsInbox({
|
|||
{timeAgo(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<ItemTitle className="max-w-full text-foreground">{item.title}</ItemTitle>
|
||||
<ItemTitle className="line-clamp-2 max-w-full text-foreground">
|
||||
{item.title}
|
||||
</ItemTitle>
|
||||
{item.message && <ItemDescription>{item.message}</ItemDescription>}
|
||||
</ItemContent>
|
||||
{item.link && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue