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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import { BellIcon } from "lucide-react";
|
import { BellIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -79,9 +80,7 @@ export function NotificationsBell() {
|
||||||
async function handleItemClick(item: NotificationItem) {
|
async function handleItemClick(item: NotificationItem) {
|
||||||
if (!item.read) {
|
if (!item.read) {
|
||||||
await markNotificationsRead([item.id]);
|
await markNotificationsRead([item.id]);
|
||||||
setNotifications((prev) =>
|
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
|
||||||
prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (item.link) {
|
if (item.link) {
|
||||||
router.push(item.link);
|
router.push(item.link);
|
||||||
|
|
@ -162,6 +161,12 @@ export function NotificationsBell() {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/notifications" className="justify-center text-sm font-medium">
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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. */
|
/** 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> {
|
export async function notifyUser(payload: PayloadType, input: NotifyInput): Promise<void> {
|
||||||
try {
|
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({
|
await payload.create({
|
||||||
collection: "user-notifications",
|
collection: "user-notifications",
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -48,6 +64,8 @@ export function notificationLabel(type?: string | null): string {
|
||||||
return "Listing sold";
|
return "Listing sold";
|
||||||
case "market:expired":
|
case "market:expired":
|
||||||
return "Listing expired";
|
return "Listing expired";
|
||||||
|
case "account:discord-request":
|
||||||
|
return "Discord request";
|
||||||
default:
|
default:
|
||||||
return type ?? "Notification";
|
return type ?? "Notification";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue