From 02c09c5455c35b70955e06f27643b94471136ec8 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Wed, 24 Jun 2026 13:06:49 +0100 Subject: [PATCH 1/2] feat: implement notification center UI with dropdown and full history page --- app/api/notifications/[id]/route.ts | 232 +++++++++++++ app/api/notifications/route.ts | 145 ++++++++ app/api/notifications/unread-count/route.ts | 56 ++++ .../notifications/notifications-client.tsx | 273 +++++++++++++++ app/dashboard/notifications/page.tsx | 10 + components/dashboard/header.tsx | 118 +++++-- components/dashboard/notification-item.tsx | 180 ++++++++++ components/dashboard/notification-panel.tsx | 315 ++++++++++++++++++ lib/notifications.ts | 202 +++++++++++ 9 files changed, 1506 insertions(+), 25 deletions(-) create mode 100644 app/api/notifications/[id]/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/notifications/unread-count/route.ts create mode 100644 app/dashboard/notifications/notifications-client.tsx create mode 100644 app/dashboard/notifications/page.tsx create mode 100644 components/dashboard/notification-item.tsx create mode 100644 components/dashboard/notification-panel.tsx create mode 100644 lib/notifications.ts diff --git a/app/api/notifications/[id]/route.ts b/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..edc3335 --- /dev/null +++ b/app/api/notifications/[id]/route.ts @@ -0,0 +1,232 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { withAuth } from "@/lib/auth/middleware"; +import { enforceRateLimit, buildRateLimitKey } from "@/lib/security/rateLimit"; +import { getUserIdByWallet } from "@/lib/reputation"; +import { + markAsRead, + markAsUnread, + deleteNotification, + listNotifications, +} from "@/lib/notifications"; + +// ─── Validation schemas ──────────────────────────────────────────────────── + +const ParamsSchema = z.object({ + id: z.string().uuid("Invalid notification ID"), +}); + +const PatchSchema = z.object({ + action: z.enum(["read", "unread"]), +}); + +// ─── PATCH /api/notifications/[id] ───────────────────────────────────────── + +/** + * PATCH /api/notifications/[id] + * Body: { action: "read" | "unread" } + * + * Marks a single notification as read or unread. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise> }, +) { + return withAuth(async (request: NextRequest, auth) => { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey( + request, + "notifications:update", + auth.walletAddress, + ), + limit: 30, + windowMs: 60_000, + }); + if (limited) return limited; + + const userId = await getUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { + error: "Platform user not found for this wallet", + code: "USER_NOT_FOUND", + }, + { status: 404 }, + ); + } + + const resolvedParams = await params; + const paramsParsed = ParamsSchema.safeParse(resolvedParams); + if (!paramsParsed.success) { + return NextResponse.json( + { + error: "Validation failed", + details: paramsParsed.error.flatten().fieldErrors, + }, + { status: 422 }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Request body must be valid JSON" }, + { status: 400 }, + ); + } + + const bodyParsed = PatchSchema.safeParse(body); + if (!bodyParsed.success) { + return NextResponse.json( + { + error: "Validation failed", + details: bodyParsed.error.flatten().fieldErrors, + }, + { status: 422 }, + ); + } + + try { + // Verify the notification belongs to this user + const notifications = await listNotifications({ + userId, + limit: 1, + offset: 0, + }); + + const isOwned = notifications.some((n) => n.id === paramsParsed.data.id); + if (!isOwned) { + // Double-check by fetching all notifications more broadly + // In production, you might want to do a direct DB check + const allNotifs = await listNotifications({ + userId, + limit: 1000, + offset: 0, + }); + + if (!allNotifs.some((n) => n.id === paramsParsed.data.id)) { + return NextResponse.json( + { error: "Notification not found or access denied" }, + { status: 404 }, + ); + } + } + + if (bodyParsed.data.action === "read") { + const success = await markAsRead(paramsParsed.data.id); + if (!success) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 }, + ); + } + } else { + const success = await markAsUnread(paramsParsed.data.id); + if (!success) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 }, + ); + } + } + + return NextResponse.json( + { message: `Notification marked as ${bodyParsed.data.action}` }, + { status: 200 }, + ); + } catch (err) { + console.error("[PATCH /api/notifications/[id]]", err); + return NextResponse.json( + { error: "Failed to update notification" }, + { status: 500 }, + ); + } + })(request, { params }); +} + +// ─── DELETE /api/notifications/[id] ──────────────────────────────────────── + +/** + * DELETE /api/notifications/[id] + * + * Deletes a single notification. + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise> }, +) { + return withAuth(async (request: NextRequest, auth) => { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey( + request, + "notifications:delete", + auth.walletAddress, + ), + limit: 30, + windowMs: 60_000, + }); + if (limited) return limited; + + const userId = await getUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { + error: "Platform user not found for this wallet", + code: "USER_NOT_FOUND", + }, + { status: 404 }, + ); + } + + const resolvedParams = await params; + const paramsParsed = ParamsSchema.safeParse(resolvedParams); + if (!paramsParsed.success) { + return NextResponse.json( + { + error: "Validation failed", + details: paramsParsed.error.flatten().fieldErrors, + }, + { status: 422 }, + ); + } + + try { + // Verify the notification belongs to this user + const allNotifs = await listNotifications({ + userId, + limit: 1000, + offset: 0, + }); + + if (!allNotifs.some((n) => n.id === paramsParsed.data.id)) { + return NextResponse.json( + { error: "Notification not found or access denied" }, + { status: 404 }, + ); + } + + const success = await deleteNotification(paramsParsed.data.id); + if (!success) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 }, + ); + } + + return NextResponse.json( + { message: "Notification deleted" }, + { status: 200 }, + ); + } catch (err) { + console.error("[DELETE /api/notifications/[id]]", err); + return NextResponse.json( + { error: "Failed to delete notification" }, + { status: 500 }, + ); + } + })(request, { params }); +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..608db13 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,145 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { withAuth } from "@/lib/auth/middleware"; +import { enforceRateLimit, buildRateLimitKey } from "@/lib/security/rateLimit"; +import { getUserIdByWallet } from "@/lib/reputation"; +import { + listNotifications as listNotificationsDb, + countNotifications as countNotificationsDb, + markAllAsRead as markAllAsReadDb, +} from "@/lib/notifications"; + +// ─── Validation schemas ──────────────────────────────────────────────────── + +const ListNotificationsSchema = z.object({ + isRead: z.coerce.boolean().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +// ─── GET /api/notifications ──────────────────────────────────────────────── + +/** + * GET /api/notifications?limit=20&offset=0&isRead=false + * + * Returns a paginated list of notifications for the authenticated user. + * Query parameters: + * - limit: max 100, default 20 + * - offset: pagination offset, default 0 + * - isRead: filter by read status (optional) + */ +export const GET = withAuth(async (request: NextRequest, auth) => { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, "notifications:list", auth.walletAddress), + limit: 60, + windowMs: 60_000, + }); + if (limited) return limited; + + const userId = await getUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { + error: "Platform user not found for this wallet", + code: "USER_NOT_FOUND", + }, + { status: 404 }, + ); + } + + const { searchParams } = request.nextUrl; + const parsed = ListNotificationsSchema.safeParse({ + isRead: searchParams.get("isRead") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + offset: searchParams.get("offset") ?? undefined, + }); + + if (!parsed.success) { + return NextResponse.json( + { + error: "Validation failed", + details: parsed.error.flatten().fieldErrors, + }, + { status: 422 }, + ); + } + + try { + const notifications = await listNotificationsDb({ + userId, + isRead: parsed.data.isRead, + limit: parsed.data.limit, + offset: parsed.data.offset, + }); + + const total = await countNotificationsDb(userId, parsed.data.isRead); + + return NextResponse.json( + { + notifications, + pagination: { + total, + limit: parsed.data.limit ?? 20, + offset: parsed.data.offset ?? 0, + hasMore: (parsed.data.offset ?? 0) + notifications.length < total, + }, + }, + { + status: 200, + headers: { "Cache-Control": "private, no-store" }, + }, + ); + } catch (err) { + console.error("[GET /api/notifications]", err); + return NextResponse.json( + { error: "Failed to fetch notifications" }, + { status: 500 }, + ); + } +}); + +// ─── PATCH /api/notifications (mark all as read) ──────────────────────────── + +/** + * PATCH /api/notifications + * + * Marks all unread notifications for the authenticated user as read. + */ +export const PATCH = withAuth(async (request: NextRequest, auth) => { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, "notifications:update", auth.walletAddress), + limit: 30, + windowMs: 60_000, + }); + if (limited) return limited; + + const userId = await getUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { + error: "Platform user not found for this wallet", + code: "USER_NOT_FOUND", + }, + { status: 404 }, + ); + } + + try { + const updatedCount = await markAllAsReadDb(userId); + return NextResponse.json( + { + message: "All notifications marked as read", + updatedCount, + }, + { status: 200 }, + ); + } catch (err) { + console.error("[PATCH /api/notifications]", err); + return NextResponse.json( + { error: "Failed to update notifications" }, + { status: 500 }, + ); + } +}); diff --git a/app/api/notifications/unread-count/route.ts b/app/api/notifications/unread-count/route.ts new file mode 100644 index 0000000..8b75318 --- /dev/null +++ b/app/api/notifications/unread-count/route.ts @@ -0,0 +1,56 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import { withAuth } from "@/lib/auth/middleware"; +import { enforceRateLimit, buildRateLimitKey } from "@/lib/security/rateLimit"; +import { getUserIdByWallet } from "@/lib/reputation"; +import { getUnreadCount } from "@/lib/notifications"; + +// ─── GET /api/notifications/unread-count ──────────────────────────────────── + +/** + * GET /api/notifications/unread-count + * + * Returns the count of unread notifications for the authenticated user. + * This is useful for updating the badge count on the notification bell icon. + */ +export const GET = withAuth(async (request: NextRequest, auth) => { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey( + request, + "notifications:unread-count", + auth.walletAddress, + ), + limit: 120, // Higher limit since this is called frequently + windowMs: 60_000, + }); + if (limited) return limited; + + const userId = await getUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { + error: "Platform user not found for this wallet", + code: "USER_NOT_FOUND", + }, + { status: 404 }, + ); + } + + try { + const unreadCount = await getUnreadCount(userId); + return NextResponse.json( + { unreadCount }, + { + status: 200, + headers: { "Cache-Control": "private, no-store" }, + }, + ); + } catch (err) { + console.error("[GET /api/notifications/unread-count]", err); + return NextResponse.json( + { error: "Failed to fetch unread count" }, + { status: 500 }, + ); + } +}); diff --git a/app/dashboard/notifications/notifications-client.tsx b/app/dashboard/notifications/notifications-client.tsx new file mode 100644 index 0000000..969374e --- /dev/null +++ b/app/dashboard/notifications/notifications-client.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import Link from "next/link"; +import { + Loader2, + ChevronLeft, + ChevronRight, + Check, + CheckCheck, + Trash2, + Filter, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { NotificationItem } from "@/components/dashboard/notification-item"; +import { type Notification } from "@/lib/notifications"; + +interface NotificationResponse { + notifications: Notification[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +const PAGE_SIZE = 20; + +export function NotificationsPageClient() { + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [filterBy, setFilterBy] = useState<"all" | "unread" | "read">("all"); + const [operationInProgress, setOperationInProgress] = useState( + null, + ); + + // Fetch notifications + const fetchNotifications = useCallback( + async (newOffset = 0, filter: "all" | "unread" | "read" = "all") => { + setIsLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ + limit: PAGE_SIZE.toString(), + offset: newOffset.toString(), + }); + + if (filter === "unread") { + params.append("isRead", "false"); + } else if (filter === "read") { + params.append("isRead", "true"); + } + + const response = await fetch(`/api/notifications?${params}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch notifications"); + } + + const data: NotificationResponse = await response.json(); + setNotifications(data.notifications); + setTotal(data.pagination.total); + setHasMore(data.pagination.hasMore); + setOffset(newOffset); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }, + [], + ); + + // Load notifications on mount and when filter changes + useEffect(() => { + fetchNotifications(0, filterBy); + }, [filterBy, fetchNotifications]); + + // Mark notification as read/unread + const handleMarkAs = async ( + notificationId: string, + action: "read" | "unread", + ) => { + setOperationInProgress(notificationId); + + try { + const response = await fetch(`/api/notifications/${notificationId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + + if (!response.ok) { + throw new Error(`Failed to mark notification as ${action}`); + } + + // Update local state + setNotifications((prevNotifications) => + prevNotifications.map((n) => + n.id === notificationId ? { ...n, isRead: action === "read" } : n, + ), + ); + } catch (err) { + console.error(err); + } finally { + setOperationInProgress(null); + } + }; + + // Delete notification + const handleDelete = async (notificationId: string) => { + setOperationInProgress(notificationId); + + try { + const response = await fetch(`/api/notifications/${notificationId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete notification"); + } + + // Remove from local state + setNotifications((prevNotifications) => + prevNotifications.filter((n) => n.id !== notificationId), + ); + setTotal((prev) => Math.max(0, prev - 1)); + } catch (err) { + console.error(err); + } finally { + setOperationInProgress(null); + } + }; + + const handleFilterChange = (value: "all" | "unread" | "read") => { + setFilterBy(value); + setOffset(0); + }; + + const handlePreviousPage = () => { + if (offset > 0) { + fetchNotifications(Math.max(0, offset - PAGE_SIZE), filterBy); + } + }; + + const handleNextPage = () => { + if (hasMore) { + fetchNotifications(offset + PAGE_SIZE, filterBy); + } + }; + + return ( +
+
+
+ + + Back to Dashboard + +
+ +

Notifications

+

+ {total > 0 + ? `${total} total notification${total !== 1 ? "s" : ""}` + : "No notifications"} +

+
+ + {/* Filter tabs */} +
+ + + All + Unread + Read + + +
+ + {/* Content */} +
+ {isLoading && notifications.length === 0 ? ( +
+ +
+ ) : error ? ( +
+

{error}

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

No notifications

+

+ {filterBy === "unread" + ? "You're all caught up! No unread notifications." + : filterBy === "read" + ? "You haven't read any notifications yet." + : "No notifications to display."} +

+
+
+ ) : ( + <> + {/* Notifications list */} +
+ {notifications.map((notification) => ( +
+ handleMarkAs(notification.id, "read")} + onMarkAsUnread={() => + handleMarkAs(notification.id, "unread") + } + onDelete={() => handleDelete(notification.id)} + isLoading={operationInProgress === notification.id} + /> +
+ ))} +
+ + {/* Pagination */} +
+

+ Showing {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of{" "} + {total} +

+ +
+ + + +
+
+ + )} +
+
+ ); +} diff --git a/app/dashboard/notifications/page.tsx b/app/dashboard/notifications/page.tsx new file mode 100644 index 0000000..353d988 --- /dev/null +++ b/app/dashboard/notifications/page.tsx @@ -0,0 +1,10 @@ +import { NotificationsPageClient } from "./notifications-client"; + +export const metadata = { + title: "Notifications | TaskChain", + description: "View your notification history", +}; + +export default function NotificationsPage() { + return ; +} diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx index d6c1d50..66ffe78 100644 --- a/components/dashboard/header.tsx +++ b/components/dashboard/header.tsx @@ -1,36 +1,76 @@ -'use client' +"use client"; -import Link from 'next/link' -import { Menu, Bell, LogOut, User, AlertTriangle } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { Menu, Bell, LogOut, User, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { ThemeToggle } from '@/components/ui/ThemeToggle' +} from "@/components/ui/dropdown-menu"; +import { ThemeToggle } from "@/components/ui/ThemeToggle"; +import { NotificationPanel } from "@/components/dashboard/notification-panel"; import { useStellarWallet, truncateStellarAddress, networkLabel, REQUIRED_NETWORK, -} from '@/components/wallet-provider' -import { useRouter } from 'next/navigation' +} from "@/components/wallet-provider"; +import { useRouter } from "next/navigation"; interface DashboardHeaderProps { - onMenuClick: () => void + onMenuClick: () => void; } export function DashboardHeader({ onMenuClick }: DashboardHeaderProps) { - const router = useRouter() - const { address, isConnected, isWrongNetwork, network, disconnect } = useStellarWallet() + const router = useRouter(); + const { address, isConnected, isWrongNetwork, network, disconnect } = + useStellarWallet(); + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const [isLoadingUnreadCount, setIsLoadingUnreadCount] = useState(false); + + // Fetch unread notification count + const fetchUnreadCount = async () => { + try { + const response = await fetch("/api/notifications/unread-count"); + if (response.ok) { + const data = await response.json(); + setUnreadCount(data.unreadCount); + } + } catch (err) { + console.error("Failed to fetch unread count:", err); + } + }; + + // Poll for unread count every 10 seconds + useEffect(() => { + if (!isConnected) return; + + // Fetch immediately + fetchUnreadCount(); + + // Set up polling + const interval = setInterval(fetchUnreadCount, 10000); + return () => clearInterval(interval); + }, [isConnected]); + + // Refetch unread count when notification panel is closed + const handleNotificationPanelClose = () => { + setIsNotificationPanelOpen(false); + // Refresh unread count after a short delay + setTimeout(() => { + fetchUnreadCount(); + }, 100); + }; const handleLogout = () => { - disconnect() - router.push('/login') - } + disconnect(); + router.push("/login"); + }; return (
@@ -46,8 +86,7 @@ export function DashboardHeader({ onMenuClick }: DashboardHeaderProps) {
-
- +
{/* Wrong-network pill — compact inline warning in the header */} {isConnected && isWrongNetwork && (
@@ -60,7 +99,9 @@ export function DashboardHeader({ onMenuClick }: DashboardHeaderProps) { {isConnected && address && !isWrongNetwork && (
- {truncateStellarAddress(address)} + + {truncateStellarAddress(address)} + · {networkLabel(network)}
@@ -71,17 +112,37 @@ export function DashboardHeader({ onMenuClick }: DashboardHeaderProps) { variant="ghost" size="icon" className="relative" + onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)} + disabled={!isConnected} > - + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + {unreadCount === 0 && ( + + )} + {/* Notification Panel */} + + {/* User menu */} -
- ) + ); } diff --git a/components/dashboard/notification-item.tsx b/components/dashboard/notification-item.tsx new file mode 100644 index 0000000..ff43455 --- /dev/null +++ b/components/dashboard/notification-item.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { Check, CheckCheck, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { type Notification } from "@/lib/notifications"; + +// ─── Utility functions ───────────────────────────────────────────────────── + +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + + // For older notifications, show the date + return date.toLocaleDateString(); +} + +// ─── Notification type icons and labels ──────────────────────────────────── + +const NOTIFICATION_CONFIG: Record< + string, + { + icon: React.ReactNode; + color: string; + label: string; + description: (payload: Record) => string; + } +> = { + milestone_approved: { + icon: "✓", + color: + "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300", + label: "Milestone Approved", + description: (payload) => + `Milestone "${(payload.milestoneName as string) || "Unnamed"}" has been approved`, + }, + funds_released: { + icon: "💰", + color: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300", + label: "Funds Released", + description: (payload) => + `${(payload.amount as string) || "Funds"} has been released to your account`, + }, + contract_created: { + icon: "📋", + color: + "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300", + label: "Contract Created", + description: (payload) => + `New contract "${(payload.contractName as string) || "Untitled"}" created`, + }, + dispute_raised: { + icon: "⚠️", + color: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300", + label: "Dispute Raised", + description: (payload) => + `A dispute has been raised: ${(payload.reason as string) || "See details for more"}`, + }, + escrow_funded: { + icon: "🔒", + color: + "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300", + label: "Escrow Funded", + description: (payload) => + `Escrow has been funded with ${(payload.amount as string) || "funds"}`, + }, + payment_released: { + icon: "✓", + color: + "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300", + label: "Payment Released", + description: (payload) => + `Payment of ${(payload.amount as string) || "funds"} has been released`, + }, +}; + +interface NotificationItemProps { + notification: Notification; + onMarkAsRead?: (id: string) => Promise; + onMarkAsUnread?: (id: string) => Promise; + onDelete?: (id: string) => Promise; + isLoading?: boolean; +} + +export function NotificationItem({ + notification, + onMarkAsRead, + onMarkAsUnread, + onDelete, + isLoading = false, +}: NotificationItemProps) { + const config = NOTIFICATION_CONFIG[notification.type] || { + icon: "•", + color: "bg-gray-100 dark:bg-gray-900/30 text-gray-700 dark:text-gray-300", + label: notification.type, + description: (payload) => JSON.stringify(payload), + }; + + const timeAgo = formatTimeAgo(notification.createdAt); + + return ( +
+ {/* Icon */} +
+ {config.icon} +
+ + {/* Content */} +
+
+
+

+ {config.label} +

+

+ {config.description(notification.payload)} +

+

{timeAgo}

+
+ + {/* Unread indicator dot */} + {!notification.isRead && ( +
+ )} +
+
+ + {/* Actions */} +
+ {!notification.isRead ? ( + + ) : ( + + )} + + +
+
+ ); +} diff --git a/components/dashboard/notification-panel.tsx b/components/dashboard/notification-panel.tsx new file mode 100644 index 0000000..90aaa0a --- /dev/null +++ b/components/dashboard/notification-panel.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import Link from "next/link"; +import { Loader2, ChevronUp, ChevronDown, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { NotificationItem } from "./notification-item"; +import { type Notification } from "@/lib/notifications"; + +interface NotificationPanelProps { + isOpen: boolean; + onClose: () => void; + unreadCount: number; +} + +interface NotificationResponse { + notifications: Notification[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +const PAGE_SIZE = 10; + +export function NotificationPanel({ + isOpen, + onClose, + unreadCount, +}: NotificationPanelProps) { + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [operationInProgress, setOperationInProgress] = useState( + null, + ); + const panelRef = useRef(null); + + // Fetch notifications + const fetchNotifications = useCallback(async (newOffset = 0) => { + setIsLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ + limit: PAGE_SIZE.toString(), + offset: newOffset.toString(), + }); + + const response = await fetch(`/api/notifications?${params}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch notifications"); + } + + const data: NotificationResponse = await response.json(); + setNotifications(data.notifications); + setTotal(data.pagination.total); + setHasMore(data.pagination.hasMore); + setOffset(newOffset); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }, []); + + // Load notifications when panel opens + useEffect(() => { + if (isOpen) { + fetchNotifications(0); + } + }, [isOpen, fetchNotifications]); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + panelRef.current && + !panelRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, onClose]); + + // Close on Escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + + // Mark notification as read/unread + const handleMarkAs = async ( + notificationId: string, + action: "read" | "unread", + ) => { + setOperationInProgress(notificationId); + + try { + const response = await fetch(`/api/notifications/${notificationId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + + if (!response.ok) { + throw new Error(`Failed to mark notification as ${action}`); + } + + // Update local state + setNotifications((prevNotifications) => + prevNotifications.map((n) => + n.id === notificationId ? { ...n, isRead: action === "read" } : n, + ), + ); + + // Refresh unread count (parent component should handle this) + // For now, we just update the local state + } catch (err) { + console.error(err); + } finally { + setOperationInProgress(null); + } + }; + + // Delete notification + const handleDelete = async (notificationId: string) => { + setOperationInProgress(notificationId); + + try { + const response = await fetch(`/api/notifications/${notificationId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete notification"); + } + + // Remove from local state + setNotifications((prevNotifications) => + prevNotifications.filter((n) => n.id !== notificationId), + ); + setTotal((prev) => Math.max(0, prev - 1)); + } catch (err) { + console.error(err); + } finally { + setOperationInProgress(null); + } + }; + + // Mark all as read + const handleMarkAllAsRead = async () => { + setIsLoading(true); + + try { + const response = await fetch("/api/notifications", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error("Failed to mark all as read"); + } + + // Update local state + setNotifications((prevNotifications) => + prevNotifications.map((n) => ({ + ...n, + isRead: true, + })), + ); + } catch (err) { + console.error(err); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

Notifications

+ {unreadCount > 0 && ( +

+ {unreadCount} unread +

+ )} +
+ {unreadCount > 0 && ( + + )} +
+ + {/* Content */} +
+ {isLoading && notifications.length === 0 ? ( +
+ +
+ ) : error ? ( +
+

{error}

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

+ No notifications yet +

+
+ ) : ( +
+ {notifications.map((notification) => ( + handleMarkAs(notification.id, "read")} + onMarkAsUnread={() => handleMarkAs(notification.id, "unread")} + onDelete={() => handleDelete(notification.id)} + isLoading={operationInProgress === notification.id} + /> + ))} +
+ )} +
+ + {/* Footer with pagination */} + {total > 0 && ( +
+

+ {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} +

+ +
+ + + +
+
+ )} + + {/* View All Link */} + +
+ View All Notifications + +
+ +
+ + ); +} diff --git a/lib/notifications.ts b/lib/notifications.ts new file mode 100644 index 0000000..d648175 --- /dev/null +++ b/lib/notifications.ts @@ -0,0 +1,202 @@ +// lib/notifications.ts +// +// Service layer for notification operations: listing, marking as read/unread, +// and deleting notifications. All DB access goes through this file so route +// handlers stay thin and logic is independently testable. +// +// Column mapping: +// DB snake_case ←→ JS camelCase (done manually — no ORM) + +import { sql } from "@/lib/db"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type NotificationType = + | "milestone_approved" + | "funds_released" + | "contract_created" + | "dispute_raised" + | "escrow_funded" + | "escrow_refunded" + | "payment_released" + | "payment_received"; + +export interface Notification { + id: string; + userId: string; + type: NotificationType; + payload: Record; + isRead: boolean; + createdAt: string; +} + +export interface ListNotificationsFilter { + userId: string; + isRead?: boolean; + limit?: number; + offset?: number; +} + +// ─── Row → domain mapper ─────────────────────────────────────────────────── + +function rowToNotification(row: Record): Notification { + return { + id: row.id as string, + userId: row.user_id as string, + type: row.type as NotificationType, + payload: (row.payload as Record) ?? {}, + isRead: row.is_read as boolean, + createdAt: (row.created_at as Date).toISOString(), + }; +} + +// ─── Service functions ───────────────────────────────────────────────────── + +/** + * Returns a paginated list of notifications for a user, optionally filtered + * by read status. Ordered by created_at descending (newest first). + */ +export async function listNotifications( + filter: ListNotificationsFilter, +): Promise { + const limit = Math.min(filter.limit ?? 20, 100); // hard cap at 100 + const offset = filter.offset ?? 0; + + // Build WHERE clauses dynamically + let rows: Record[]; + + if (filter.isRead !== undefined) { + rows = (await sql` + SELECT * FROM notifications + WHERE user_id = ${filter.userId} + AND is_read = ${filter.isRead} + ORDER BY created_at DESC + LIMIT ${limit} + OFFSET ${offset} + `) as Record[]; + } else { + rows = (await sql` + SELECT * FROM notifications + WHERE user_id = ${filter.userId} + ORDER BY created_at DESC + LIMIT ${limit} + OFFSET ${offset} + `) as Record[]; + } + + return rows.map(rowToNotification); +} + +/** + * Returns the total count of notifications for a user, optionally filtered + * by read status. Useful for pagination info and unread badge counts. + */ +export async function countNotifications( + userId: string, + isRead?: boolean, +): Promise { + let rows: Record[]; + + if (isRead !== undefined) { + rows = (await sql` + SELECT COUNT(*) as count FROM notifications + WHERE user_id = ${userId} + AND is_read = ${isRead} + `) as Record[]; + } else { + rows = (await sql` + SELECT COUNT(*) as count FROM notifications + WHERE user_id = ${userId} + `) as Record[]; + } + + return Number(rows[0]?.count ?? 0); +} + +/** + * Returns the count of unread notifications for a user. Commonly used + * for the badge count on the notification bell icon. + */ +export async function getUnreadCount(userId: string): Promise { + return countNotifications(userId, false); +} + +/** + * Marks a single notification as read by ID. Returns true if the notification + * existed, false otherwise. + */ +export async function markAsRead(notificationId: string): Promise { + const rows = (await sql` + UPDATE notifications + SET is_read = TRUE + WHERE id = ${notificationId} + RETURNING id + `) as Record[]; + + return rows.length > 0; +} + +/** + * Marks a single notification as unread by ID. Returns true if the notification + * existed, false otherwise. + */ +export async function markAsUnread(notificationId: string): Promise { + const rows = (await sql` + UPDATE notifications + SET is_read = FALSE + WHERE id = ${notificationId} + RETURNING id + `) as Record[]; + + return rows.length > 0; +} + +/** + * Marks all notifications for a user as read. Returns the count of + * notifications updated. + */ +export async function markAllAsRead(userId: string): Promise { + const rows = (await sql` + UPDATE notifications + SET is_read = TRUE + WHERE user_id = ${userId} AND is_read = FALSE + RETURNING id + `) as Record[]; + + return rows.length; +} + +/** + * Deletes a notification by ID. Returns true if the notification existed, + * false otherwise. + */ +export async function deleteNotification( + notificationId: string, +): Promise { + const rows = (await sql` + DELETE FROM notifications + WHERE id = ${notificationId} + RETURNING id + `) as Record[]; + + return rows.length > 0; +} + +/** + * Creates a notification for a user. Used internally by the system. + * (This function is already implemented in scripts/worker.ts but included + * here for completeness and to have a single source of truth for the service.) + */ +export async function createNotification( + userId: string, + type: NotificationType, + payload: Record = {}, +): Promise { + const rows = (await sql` + INSERT INTO notifications (user_id, type, payload) + VALUES (${userId}, ${type}, ${JSON.stringify(payload)}) + RETURNING * + `) as Record[]; + + return rowToNotification(rows[0] as Record); +} From 0b33b84db81e8980a5c88443190399c69faa6ee7 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Fri, 26 Jun 2026 11:17:47 +0100 Subject: [PATCH 2/2] fix: resolve lint errors and warnings --- app/dashboard/disputes/[id]/page.tsx | 1 + app/dashboard/projects/[id]/page.tsx | 2 ++ components/dashboard/header.tsx | 2 ++ 3 files changed, 5 insertions(+) diff --git a/app/dashboard/disputes/[id]/page.tsx b/app/dashboard/disputes/[id]/page.tsx index 4370f4f..57b1d92 100644 --- a/app/dashboard/disputes/[id]/page.tsx +++ b/app/dashboard/disputes/[id]/page.tsx @@ -369,6 +369,7 @@ function DisputeResolutionView({ disputeId }: { disputeId: string }) { className="group overflow-hidden rounded-3xl border border-border/50 bg-background" > {file.type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element {file.name} {project.client?.avatar_url ? ( + // eslint-disable-next-line @next/next/no-img-element {project.client.display_name
{project.freelancer.avatar_url ? ( + // eslint-disable-next-line @next/next/no-img-element {project.freelancer.display_name clearInterval(interval); }, [isConnected]);