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"; }