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:
parent
5ee98d61da
commit
b8cbb85dc8
4 changed files with 271 additions and 3 deletions
43
src/app/(frontend)/notifications/page.tsx
Normal file
43
src/app/(frontend)/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
202
src/components/frontend/notifications/NotificationsInbox.tsx
Normal file
202
src/components/frontend/notifications/NotificationsInbox.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue