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:
parent
4a76b54426
commit
9c15117b9b
6 changed files with 377 additions and 10 deletions
81
src/app/(frontend)/notifications/actions.ts
Normal file
81
src/app/(frontend)/notifications/actions.ts
Normal 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." };
|
||||
}
|
||||
}
|
||||
70
src/collections/users/UserNotifications.ts
Normal file
70
src/collections/users/UserNotifications.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
168
src/components/frontend/notifications/NotificationsBell.tsx
Normal file
168
src/components/frontend/notifications/NotificationsBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/lib/notifications/index.ts
Normal file
54
src/lib/notifications/index.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue