1
0
Fork 0

feat(notifications): add in-app player notification center

- user-notifications collection (per-user inbox, owner-scoped read)
- notifyUser() fire-and-forget helper + notification label mapping
- NotificationsBell in the site header with 30s polling, unread badge,
  mark-read on click, and "Mark all read"
- markNotificationsRead / markAllNotificationsRead server actions
This commit is contained in:
Jason Fraley 2026-08-10 16:02:55 -04:00
parent 4a76b54426
commit 9c15117b9b
6 changed files with 377 additions and 10 deletions

View file

@ -0,0 +1,81 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
export interface NotificationActionResult {
success: boolean;
error?: string;
}
export async function markNotificationsRead(ids: number[]): Promise<NotificationActionResult> {
try {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) return { success: false, error: "You must be logged in." };
const cleanIds = [...new Set(ids.filter((id) => Number.isFinite(id) && id > 0))];
if (cleanIds.length === 0) return { success: true };
// Query with access control so users can only touch their own notifications.
const found = await payload.find({
collection: "user-notifications",
where: { id: { in: cleanIds } },
limit: Math.min(cleanIds.length, 100),
depth: 0,
});
for (const doc of found.docs) {
if (!doc.read) {
await payload.update({
collection: "user-notifications",
id: doc.id,
data: { read: true },
overrideAccess: true,
depth: 0,
});
}
}
return { success: true };
} catch {
return { success: false, error: "Failed to update notifications." };
}
}
export async function markAllNotificationsRead(): Promise<NotificationActionResult> {
try {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) return { success: false, error: "You must be logged in." };
const unread = await payload.find({
collection: "user-notifications",
where: { read: { equals: false } },
limit: 100,
depth: 0,
});
for (const doc of unread.docs) {
await payload.update({
collection: "user-notifications",
id: doc.id,
data: { read: true },
overrideAccess: true,
depth: 0,
});
}
return { success: true };
} catch {
return { success: false, error: "Failed to update notifications." };
}
}

View file

@ -0,0 +1,70 @@
import { CollectionConfig } from "payload";
import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
export const UserNotifications: CollectionConfig = {
slug: "user-notifications",
admin: {
group: "Users",
useAsTitle: "title",
defaultColumns: ["title", "user", "read", "createdAt"],
listSearchableFields: ["title", "message"],
pagination: { defaultLimit: 25 },
},
access: {
read: ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
return { user: { equals: user.id } };
},
create: isDeveloper,
update: ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
return { user: { equals: user.id } };
},
delete: isDeveloper,
},
fields: [
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
index: true,
},
{
name: "type",
type: "text",
admin: {
description: "Machine-readable notification type, e.g. `market:offer`.",
},
},
{
name: "title",
type: "text",
required: true,
},
{
name: "message",
type: "textarea",
},
{
name: "link",
type: "text",
admin: {
description: "Optional internal path to navigate to when clicked.",
},
},
{
name: "read",
type: "checkbox",
defaultValue: false,
index: true,
},
],
timestamps: true,
};

View file

@ -1,5 +1,6 @@
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell";
export const SiteHeader = () => {
return (
@ -8,16 +9,7 @@ export const SiteHeader = () => {
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
</div>
{/*<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Home</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink>Link</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>*/}
<NotificationsBell />
</div>
);
};

View file

@ -0,0 +1,168 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { notificationLabel } from "@/lib/notifications";
import {
markAllNotificationsRead,
markNotificationsRead,
} from "@/app/(frontend)/notifications/actions";
interface NotificationItem {
id: number;
type?: string | null;
title: string;
message?: string | null;
link?: string | null;
read: boolean;
createdAt: string;
}
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`;
}
const POLL_MS = 30_000;
export function NotificationsBell() {
const router = useRouter();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
const fetchNotifications = useCallback(async () => {
try {
const res = await fetch("/api/user-notifications?limit=12&sort=-createdAt", {
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to fetch");
const data = (await res.json()) as { docs?: NotificationItem[] };
setNotifications(data.docs ?? []);
setError(false);
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchNotifications();
intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNotifications]);
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)),
);
}
if (item.link) {
router.push(item.link);
setOpen(false);
}
}
async function handleMarkAll() {
if (unreadCount === 0) return;
await markAllNotificationsRead();
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
<BellIcon className="size-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-semibold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 sm:w-96">
<div className="flex items-center justify-between pr-1">
<DropdownMenuLabel className="pb-1">Notifications</DropdownMenuLabel>
{unreadCount > 0 && (
<button
onClick={() => void handleMarkAll()}
className="text-xs text-muted-foreground hover:text-foreground"
>
Mark all read
</button>
)}
</div>
<DropdownMenuSeparator />
<div className="max-h-96 overflow-y-auto">
{loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="px-3 py-6 text-center text-sm text-red-400">
Could not load notifications.
</p>
) : notifications.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
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}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{timeAgo(item.createdAt)}
</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>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,54 @@
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export interface NotifyInput {
userId: number;
type?: string;
title: string;
message?: string;
link?: string;
}
/** 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 {
await payload.create({
collection: "user-notifications",
data: {
user: input.userId,
type: input.type,
title: input.title,
message: input.message,
link: input.link,
read: false,
},
overrideAccess: true,
depth: 0,
});
} catch (err) {
console.error("[Notifications] Failed to create notification:", err);
}
}
/** Short display label for a notification type, or the raw type when unknown. */
export function notificationLabel(type?: string | null): string {
switch (type) {
case "market:offer":
return "New offer";
case "market:counter":
return "Counteroffer";
case "market:accept":
return "Offer accepted";
case "market:reject":
return "Offer rejected";
case "market:withdrawn":
return "Offer withdrawn";
case "market:closed":
return "Negotiations closed";
case "market:sold":
return "Listing sold";
case "market:expired":
return "Listing expired";
default:
return type ?? "Notification";
}
}

View file

@ -37,6 +37,7 @@ import { BankTransactions } from "@/collections/banking/BankTransactions";
import { LedgerEntries } from "@/collections/banking/LedgerEntries";
import { LockerStorages } from "@/collections/locker/LockerStorages";
import { Loadouts } from "@/collections/locker/Loadouts";
import { UserNotifications } from "@/collections/users/UserNotifications";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer";
@ -87,6 +88,7 @@ export default buildConfig({
Qualifications,
Assignments,
Experience,
UserNotifications,
// Intel
Missions,