From 9c15117b9b488988e6b04f2f6e2e46c27bfb9ad7 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 10 Aug 2026 16:02:55 -0400 Subject: [PATCH] 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 --- src/app/(frontend)/notifications/actions.ts | 81 +++++++++ src/collections/users/UserNotifications.ts | 70 ++++++++ src/components/frontend/SiteHeader.tsx | 12 +- .../notifications/NotificationsBell.tsx | 168 ++++++++++++++++++ src/lib/notifications/index.ts | 54 ++++++ src/payload.config.ts | 2 + 6 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 src/app/(frontend)/notifications/actions.ts create mode 100644 src/collections/users/UserNotifications.ts create mode 100644 src/components/frontend/notifications/NotificationsBell.tsx create mode 100644 src/lib/notifications/index.ts diff --git a/src/app/(frontend)/notifications/actions.ts b/src/app/(frontend)/notifications/actions.ts new file mode 100644 index 0000000..2457835 --- /dev/null +++ b/src/app/(frontend)/notifications/actions.ts @@ -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 { + 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 { + 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." }; + } +} diff --git a/src/collections/users/UserNotifications.ts b/src/collections/users/UserNotifications.ts new file mode 100644 index 0000000..1d972c5 --- /dev/null +++ b/src/collections/users/UserNotifications.ts @@ -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, +}; diff --git a/src/components/frontend/SiteHeader.tsx b/src/components/frontend/SiteHeader.tsx index 158620a..2bc5567 100644 --- a/src/components/frontend/SiteHeader.tsx +++ b/src/components/frontend/SiteHeader.tsx @@ -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 = () => { - {/* - - - Home - - Link - - - - */} + ); }; diff --git a/src/components/frontend/notifications/NotificationsBell.tsx b/src/components/frontend/notifications/NotificationsBell.tsx new file mode 100644 index 0000000..e174303 --- /dev/null +++ b/src/components/frontend/notifications/NotificationsBell.tsx @@ -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([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const intervalRef = useRef | 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 ( + + + + + +
+ Notifications + {unreadCount > 0 && ( + + )} +
+ +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

+ Could not load notifications. +

+ ) : notifications.length === 0 ? ( +

+ No notifications yet. +

+ ) : ( + notifications.map((item) => ( + void handleItemClick(item)} + > + + + {!item.read && ( + + )} + {item.title} + + + {timeAgo(item.createdAt)} + + + {item.message && ( + {item.message} + )} + + {notificationLabel(item.type)} + + + )) + )} +
+
+
+ ); +} diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts new file mode 100644 index 0000000..6ae5c9c --- /dev/null +++ b/src/lib/notifications/index.ts @@ -0,0 +1,54 @@ +type PayloadType = Awaited>; + +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 { + 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"; + } +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 4923a4b..dc8f9b3 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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,