diff --git a/src/app/(frontend)/notifications/page.tsx b/src/app/(frontend)/notifications/page.tsx
new file mode 100644
index 0000000..bf932ee
--- /dev/null
+++ b/src/app/(frontend)/notifications/page.tsx
@@ -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 (
+
+
+
+
+
Notifications
+
+
+ Updates and activity from across Polaris Task Force.
+
+
+
+
+
+ );
+}
diff --git a/src/components/frontend/notifications/NotificationsBell.tsx b/src/components/frontend/notifications/NotificationsBell.tsx
index e174303..353bb62 100644
--- a/src/components/frontend/notifications/NotificationsBell.tsx
+++ b/src/components/frontend/notifications/NotificationsBell.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
+import Link from "next/link";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -79,9 +80,7 @@ export function NotificationsBell() {
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)),
- );
+ setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
}
if (item.link) {
router.push(item.link);
@@ -162,6 +161,12 @@ export function NotificationsBell() {
))
)}
+
+
+
+ View all
+
+
);
diff --git a/src/components/frontend/notifications/NotificationsInbox.tsx b/src/components/frontend/notifications/NotificationsInbox.tsx
new file mode 100644
index 0000000..39a0aa4
--- /dev/null
+++ b/src/components/frontend/notifications/NotificationsInbox.tsx
@@ -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(() => [
+ ...initialNotifications,
+ ]);
+ const [markingId, setMarkingId] = useState(null);
+ const [markingAll, setMarkingAll] = useState(false);
+ const [error, setError] = useState(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 (
+
+
+
+
+ Inbox
+
+ {unreadCount > 0 && {unreadCount} unread}
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {notifications.length === 0 ? (
+ -
+
+
+
+
+ No notifications yet
+ You'll see important updates and activity here.
+
+
+ ) : (
+
+ {notifications.map((item) => {
+ const isUnread = !item.read;
+ const isBusy = markingId !== null || markingAll;
+
+ return (
+ -
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts
index 6ae5c9c..f0c45b6 100644
--- a/src/lib/notifications/index.ts
+++ b/src/lib/notifications/index.ts
@@ -11,6 +11,22 @@ export interface NotifyInput {
/** 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 {
+ 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({
collection: "user-notifications",
data: {
@@ -48,6 +64,8 @@ export function notificationLabel(type?: string | null): string {
return "Listing sold";
case "market:expired":
return "Listing expired";
+ case "account:discord-request":
+ return "Discord request";
default:
return type ?? "Notification";
}