diff --git a/app/me/notifications/components/NotificationDetailSheet.tsx b/app/me/notifications/components/NotificationDetailSheet.tsx new file mode 100644 index 000000000..d99340285 --- /dev/null +++ b/app/me/notifications/components/NotificationDetailSheet.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { Notification } from '@/types/notifications'; +import BoundlessSheet from '@/components/sheet/boundless-sheet'; +import { getNotificationIcon } from '@/components/notifications/NotificationIcon'; +import { format, formatDistanceToNow } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; + +interface NotificationDetailSheetProps { + notification: Notification | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const metadataLabels: Record = { + organizationName: 'Organization', + hackathonName: 'Hackathon', + projectName: 'Project', + memberEmail: 'Member', + role: 'Role', + oldRole: 'Previous Role', + newRole: 'New Role', + oldStatus: 'Previous Status', + newStatus: 'New Status', + submissionStatus: 'Submission Status', + deadlineType: 'Deadline', + amount: 'Amount', + transactionHash: 'Transaction', +}; + +export const NotificationDetailSheet = ({ + notification, + open, + onOpenChange, +}: NotificationDetailSheetProps) => { + if (!notification) return null; + + const Icon = getNotificationIcon(notification.type); + const createdAt = new Date(notification.createdAt); + + const metadata = Object.entries(notification.data).filter( + ([key, value]) => + value !== undefined && + value !== null && + key in metadataLabels && + key !== 'organizationId' && + key !== 'hackathonId' && + key !== 'projectId' && + key !== 'commentId' && + key !== 'milestoneId' && + key !== 'teamInvitationId' && + key !== 'hackathonSlug' + ); + + return ( + +
+ {/* Notification header */} +
+
+ +
+
+

+ {notification.title} +

+
+ + · + {formatDistanceToNow(createdAt, { addSuffix: true })} +
+
+ + {notification.read ? 'Read' : 'Unread'} + +
+ + {/* Divider */} +
+ + {/* Message body */} +
+

+ {notification.message} +

+
+ + {/* Metadata */} + {metadata.length > 0 && ( +
+

+ Details +

+
+ {metadata.map(([key, value]) => ( +
+ + {metadataLabels[key] || key} + + + {key === 'amount' + ? `$${(value as number).toLocaleString()}` + : key === 'transactionHash' + ? `${String(value).slice(0, 8)}...${String(value).slice(-6)}` + : String(value)} + +
+ ))} +
+
+ )} + + {/* Type tag */} +
+ + {notification.type.replace(/_/g, ' ')} + +
+
+ + ); +}; diff --git a/app/me/notifications/components/NotificationFeedItem.tsx b/app/me/notifications/components/NotificationFeedItem.tsx new file mode 100644 index 000000000..42620eba8 --- /dev/null +++ b/app/me/notifications/components/NotificationFeedItem.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { formatDistanceToNow } from 'date-fns'; +import { Notification } from '@/types/notifications'; +import { getNotificationIcon } from '@/components/notifications/NotificationIcon'; +import { cn } from '@/lib/utils'; +import { motion, AnimatePresence } from 'motion/react'; + +interface NotificationFeedItemProps { + notification: Notification; + onClick: () => void; + isMarkingAll: boolean; +} + +export const NotificationFeedItem = ({ + notification, + onClick, + isMarkingAll, +}: NotificationFeedItemProps) => { + const Icon = getNotificationIcon(notification.type); + const isUnread = !notification.read; + + return ( + + ); +}; diff --git a/app/me/notifications/components/NotificationSection.tsx b/app/me/notifications/components/NotificationSection.tsx new file mode 100644 index 000000000..d1d575ba2 --- /dev/null +++ b/app/me/notifications/components/NotificationSection.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Notification } from '@/types/notifications'; +import { NotificationFeedItem } from './NotificationFeedItem'; +import { motion, AnimatePresence } from 'motion/react'; +import { Archive, Bell, Inbox } from 'lucide-react'; + +interface NotificationSectionProps { + title: string; + notifications: Notification[]; + onNotificationClick: (notification: Notification) => void; + isMarkingAll: boolean; + emptyMessage: string; + emptySubMessage: string; +} + +const sectionIcons: Record = { + New: Bell, + Earlier: Inbox, + Archived: Archive, +}; + +export const NotificationSection = ({ + title, + notifications, + onNotificationClick, + isMarkingAll, + emptyMessage, + emptySubMessage, +}: NotificationSectionProps) => { + const Icon = sectionIcons[title] ?? Bell; + + return ( +
+
+ +

+ {title} +

+ {notifications.length > 0 && ( + + {notifications.length} + + )} +
+ +
+ {notifications.length === 0 ? ( +
+

{emptyMessage}

+

{emptySubMessage}

+
+ ) : ( + +
+ {notifications.map((notification, index) => ( + + onNotificationClick(notification)} + isMarkingAll={isMarkingAll} + /> + + ))} +
+
+ )} +
+
+ ); +}; diff --git a/app/me/notifications/page.tsx b/app/me/notifications/page.tsx index c1ff1b6d9..8b00477b4 100644 --- a/app/me/notifications/page.tsx +++ b/app/me/notifications/page.tsx @@ -1,7 +1,276 @@ -import { redirect } from 'next/navigation'; +'use client'; -const page = () => { - redirect('/coming-soon'); +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useNotifications } from '@/hooks/useNotifications'; +import { useNotificationPolling } from '@/hooks/use-notification-polling'; +import { useNotificationStore } from '@/lib/stores/notification-store'; +import { Notification } from '@/types/notifications'; +import { NotificationDetailSheet } from './components/NotificationDetailSheet'; +import { NotificationSection } from './components/NotificationSection'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AuthGuard } from '@/components/auth'; +import Loading from '@/components/Loading'; +import { toast } from 'sonner'; +import { motion, AnimatePresence } from 'motion/react'; +import { Bell, CheckCheck } from 'lucide-react'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +type NotificationGroups = { + new: Notification[]; + earlier: Notification[]; + archived: Notification[]; +}; + +const groupNotifications = ( + notifications: Notification[] +): NotificationGroups => { + const now = Date.now(); + const sevenDaysAgo = now - SEVEN_DAYS_MS; + + const groups: NotificationGroups = { new: [], earlier: [], archived: [] }; + + for (const notification of notifications) { + const createdAt = new Date(notification.createdAt).getTime(); + + if (!notification.read) { + groups.new.push(notification); + } else if (createdAt >= sevenDaysAgo) { + groups.earlier.push(notification); + } else { + groups.archived.push(notification); + } + } + + return groups; }; -export default page; +export default function NotificationsPage() { + const [page, setPage] = useState(1); + const [selectedNotification, setSelectedNotification] = + useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [isMarkingAll, setIsMarkingAll] = useState(false); + + const limit = 20; + + const notificationsHook = useNotifications({ page, limit, autoFetch: true }); + const { + notifications, + loading, + error, + total, + unreadCount, + markAllAsRead, + markNotificationAsRead, + setCurrentPage, + } = notificationsHook; + + const { setUnreadCount, clearUnreadCount } = useNotificationStore(); + + useEffect(() => { + setUnreadCount(unreadCount); + }, [unreadCount, setUnreadCount]); + + useNotificationPolling(notificationsHook, { + interval: 30000, + enabled: true, + }); + + const groups = useMemo( + () => groupNotifications(notifications), + [notifications] + ); + + const totalPages = Math.ceil(total / limit); + + const handleNotificationClick = useCallback( + (notification: Notification) => { + setSelectedNotification(notification); + setSheetOpen(true); + + if (!notification.read) { + markNotificationAsRead([notification.id]).catch(() => {}); + } + }, + [markNotificationAsRead] + ); + + const handleMarkAllAsRead = useCallback(async () => { + setIsMarkingAll(true); + try { + await markAllAsRead(); + clearUnreadCount(); + toast.success('All notifications marked as read'); + } catch { + toast.error('Failed to mark all as read'); + } finally { + setTimeout(() => setIsMarkingAll(false), 600); + } + }, [markAllAsRead, clearUnreadCount]); + + const handlePageChange = useCallback( + (newPage: number) => { + setCurrentPage(newPage); + setPage(newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [setCurrentPage] + ); + + if (error) { + return ( +
+
+

+ Error loading notifications +

+

{error.message}

+ +
+
+ ); + } + + return ( + }> +
+ {/* Header */} +
+
+
+ +
+
+

Notifications

+
+ {loading ? ( + + ) : ( + <> + {unreadCount > 0 + ? `${unreadCount} unread of ${total} total` + : `${total} notifications`} + + )} +
+
+
+ + + {unreadCount > 0 && !loading && ( + + + + )} + +
+ + {/* Feed */} + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : notifications.length === 0 ? ( +
+
+ +
+

+ No notifications yet +

+

+ When you receive notifications about your projects, hackathons, or + account activity, they will appear here. +

+
+ ) : ( +
+ + + + + +
+ )} + + {/* Pagination */} + {totalPages > 1 && !loading && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + {/* Detail Sheet */} + +
+
+ ); +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 63b9e3383..a4382cff9 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -27,9 +27,11 @@ import { } from '@/components/ui/sidebar'; import Image from 'next/image'; import Link from 'next/link'; +import { useNotificationStore } from '@/lib/stores/notification-store'; const getNavigationData = (counts?: { participating?: number; + unreadNotifications?: number; submissions?: number; }) => ({ main: [ @@ -104,7 +106,10 @@ const getNavigationData = (counts?: { title: 'Notifications', url: '/me/notifications', icon: IconBell, - badge: '5', + badge: + counts?.unreadNotifications && counts.unreadNotifications > 0 + ? counts.unreadNotifications.toString() + : undefined, }, ], }); @@ -123,9 +128,11 @@ export function AppSidebar({ user: UserData; counts?: { participating?: number; submissions?: number }; } & React.ComponentProps) { + const unreadNotifications = useNotificationStore(state => state.unreadCount); + const navigationData = React.useMemo( - () => getNavigationData(counts), - [counts] + () => getNavigationData({ ...counts, unreadNotifications }), + [counts, unreadNotifications] ); return ( diff --git a/lib/stores/notification-store.ts b/lib/stores/notification-store.ts new file mode 100644 index 000000000..95a25831d --- /dev/null +++ b/lib/stores/notification-store.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand'; + +interface NotificationStore { + unreadCount: number; + setUnreadCount: (count: number) => void; + decrementUnreadCount: (by?: number) => void; + clearUnreadCount: () => void; +} + +export const useNotificationStore = create(set => ({ + unreadCount: 0, + setUnreadCount: count => set({ unreadCount: count }), + decrementUnreadCount: (by = 1) => + set(state => ({ unreadCount: Math.max(0, state.unreadCount - by) })), + clearUnreadCount: () => set({ unreadCount: 0 }), +}));