1
0
Fork 0

feat(notifications): add notification inbox page and preference-aware muting

- Add /notifications inbox with mark-read and mark-all-read actions
- Respect mutedAll/mutedTypes preferences when creating notifications
- Add View all link and Discord request label to the notifications bell
This commit is contained in:
Jason Fraley 2026-08-11 23:25:16 -04:00
parent 5ee98d61da
commit b8cbb85dc8
4 changed files with 271 additions and 3 deletions

View file

@ -0,0 +1,43 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { BellIcon } from "lucide-react";
import { NotificationsInbox } from "@/components/frontend/notifications/NotificationsInbox";
export const metadata = {
title: "Notifications — Polaris Task Force",
};
export default async function NotificationsPage() {
const payload = await getPayload({ config });
const { user } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const userId = user?.id ?? 0;
const notificationsRes = await payload.find({
collection: "user-notifications",
where: { user: { equals: userId } },
limit: 200,
sort: "-createdAt",
depth: 0,
});
const notifications = notificationsRes.docs;
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<BellIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Notifications</h1>
</div>
<p className="text-sm text-muted-foreground">
Updates and activity from across Polaris Task Force.
</p>
</div>
<NotificationsInbox notifications={notifications} />
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -79,9 +80,7 @@ export function NotificationsBell() {
async function handleItemClick(item: NotificationItem) {
if (!item.read) {
await markNotificationsRead([item.id]);
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)),
);
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
}
if (item.link) {
router.push(item.link);
@ -162,6 +161,12 @@ export function NotificationsBell() {
))
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/notifications" className="justify-center text-sm font-medium">
View all
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowUpRightIcon, BellIcon, CheckCheckIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { cn } from "@/lib/utils";
import { notificationLabel } from "@/lib/notifications";
import {
markAllNotificationsRead,
markNotificationsRead,
} from "@/app/(frontend)/notifications/actions";
interface NotificationItem {
readonly id: number;
readonly type?: string | null;
readonly title: string;
readonly message?: string | null;
readonly link?: string | null;
readonly read?: boolean | null;
readonly createdAt: string;
}
interface NotificationsInboxProps {
readonly notifications: readonly NotificationItem[];
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
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);
return `${days}d ago`;
}
export function NotificationsInbox({
notifications: initialNotifications,
}: NotificationsInboxProps) {
const router = useRouter();
const [notifications, setNotifications] = useState<NotificationItem[]>(() => [
...initialNotifications,
]);
const [markingId, setMarkingId] = useState<number | null>(null);
const [markingAll, setMarkingAll] = useState(false);
const [error, setError] = useState<string | null>(null);
const unreadCount = notifications.filter((notification) => !notification.read).length;
async function handleItemClick(item: NotificationItem) {
if (markingId !== null || markingAll) return;
setError(null);
if (!item.read) {
setMarkingId(item.id);
try {
const result = await markNotificationsRead([item.id]);
if (result.success) {
setNotifications((previous) =>
previous.map((notification) =>
notification.id === item.id ? { ...notification, read: true } : notification,
),
);
} else {
setError(result.error ?? "Failed to update notifications.");
}
} catch (error) {
setError(error instanceof Error ? error.message : "Failed to update notifications.");
} finally {
setMarkingId(null);
}
}
if (item.link) router.push(item.link);
}
async function handleMarkAll() {
if (unreadCount === 0 || markingId !== null || markingAll) return;
setError(null);
setMarkingAll(true);
try {
const result = await markAllNotificationsRead();
if (result.success) {
setNotifications((previous) =>
previous.map((notification) => ({ ...notification, read: true })),
);
} else {
setError(result.error ?? "Failed to update notifications.");
}
} catch (error) {
setError(error instanceof Error ? error.message : "Failed to update notifications.");
} finally {
setMarkingAll(false);
}
}
return (
<section aria-labelledby="notifications-inbox-heading" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2
id="notifications-inbox-heading"
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"
>
Inbox
</h2>
{unreadCount > 0 && <Badge variant="outline">{unreadCount} unread</Badge>}
</div>
{unreadCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => void handleMarkAll()}
disabled={markingId !== null || markingAll}
>
<CheckCheckIcon className="size-4" />
{markingAll ? "Marking..." : "Mark all read"}
</Button>
)}
</div>
{error && (
<p role="alert" className="text-sm text-red-400">
{error}
</p>
)}
{notifications.length === 0 ? (
<Item variant="outline">
<ItemMedia variant="icon" className="bg-muted">
<BellIcon className="text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>No notifications yet</ItemTitle>
<ItemDescription>You&apos;ll see important updates and activity here.</ItemDescription>
</ItemContent>
</Item>
) : (
<ItemGroup className="gap-2">
{notifications.map((item) => {
const isUnread = !item.read;
const isBusy = markingId !== null || markingAll;
return (
<Item
key={item.id}
asChild
variant="outline"
className={cn(
"group cursor-pointer",
isUnread && "border-blue-500/30 bg-accent/50",
)}
>
<button
type="button"
onClick={() => void handleItemClick(item)}
disabled={isBusy}
className="w-full text-left"
>
<span
aria-hidden="true"
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
isUnread ? "bg-blue-500" : "bg-muted-foreground/30",
)}
/>
<ItemContent className="min-w-0 gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">
{notificationLabel(item.type)}
</Badge>
<span className="text-xs tabular-nums text-muted-foreground">
{timeAgo(item.createdAt)}
</span>
</div>
<ItemTitle className="max-w-full text-foreground">{item.title}</ItemTitle>
{item.message && <ItemDescription>{item.message}</ItemDescription>}
</ItemContent>
{item.link && (
<ArrowUpRightIcon className="mt-1 size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</button>
</Item>
);
})}
</ItemGroup>
)}
</section>
);
}

View file

@ -11,6 +11,22 @@ export interface NotifyInput {
/** Create an in-app notification for a user. Fire-and-forget: failures are logged, not thrown. */
export async function notifyUser(payload: PayloadType, input: NotifyInput): Promise<void> {
try {
const target = await payload.findByID({
collection: "users",
id: input.userId,
depth: 0,
overrideAccess: true,
});
const prefs = (
target as {
preferences?: {
notifications?: { mutedAll?: boolean | null; mutedTypes?: (string | null)[] | null };
};
}
).preferences?.notifications;
if (prefs?.mutedAll) return;
if (input.type && prefs?.mutedTypes?.includes(input.type)) return;
await payload.create({
collection: "user-notifications",
data: {
@ -48,6 +64,8 @@ export function notificationLabel(type?: string | null): string {
return "Listing sold";
case "market:expired":
return "Listing expired";
case "account:discord-request":
return "Discord request";
default:
return type ?? "Notification";
}