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 { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell";
|
||||||
|
|
||||||
export const SiteHeader = () => {
|
export const SiteHeader = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -8,16 +9,7 @@ export const SiteHeader = () => {
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
</div>
|
</div>
|
||||||
{/*<NavigationMenu>
|
<NotificationsBell />
|
||||||
<NavigationMenuList>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger>Home</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent>
|
|
||||||
<NavigationMenuLink>Link</NavigationMenuLink>
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>*/}
|
|
||||||
</div>
|
</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 { LedgerEntries } from "@/collections/banking/LedgerEntries";
|
||||||
import { LockerStorages } from "@/collections/locker/LockerStorages";
|
import { LockerStorages } from "@/collections/locker/LockerStorages";
|
||||||
import { Loadouts } from "@/collections/locker/Loadouts";
|
import { Loadouts } from "@/collections/locker/Loadouts";
|
||||||
|
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
|
@ -87,6 +88,7 @@ export default buildConfig({
|
||||||
Qualifications,
|
Qualifications,
|
||||||
Assignments,
|
Assignments,
|
||||||
Experience,
|
Experience,
|
||||||
|
UserNotifications,
|
||||||
|
|
||||||
// Intel
|
// Intel
|
||||||
Missions,
|
Missions,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue