From 5e66d8e99fb78e6bcfcc1b4876c394538db66e5a Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Tue, 3 Mar 2026 09:49:15 +0100 Subject: [PATCH] feat(notifications): implement notifications page with grouped feed, detail sheet, and live sidebar badge (#396) Replace the coming-soon redirect with a full notifications center at /me/notifications. Notifications are grouped into New/Earlier/Archived sections, clickable items open a BoundlessSheet with full details, Mark All as Read uses motion animations, and the sidebar badge reflects the live unread count via a Zustand store. Made-with: Cursor --- .../components/NotificationDetailSheet.tsx | 148 ++++++++++ .../components/NotificationFeedItem.tsx | 95 ++++++ .../components/NotificationSection.tsx | 79 +++++ app/me/notifications/page.tsx | 277 +++++++++++++++++- components/app-sidebar.tsx | 17 +- docs/project-detail-redesign.md | 241 ++++++++------- lib/stores/notification-store.ts | 16 + 7 files changed, 759 insertions(+), 114 deletions(-) create mode 100644 app/me/notifications/components/NotificationDetailSheet.tsx create mode 100644 app/me/notifications/components/NotificationFeedItem.tsx create mode 100644 app/me/notifications/components/NotificationSection.tsx create mode 100644 lib/stores/notification-store.ts 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 bdc0ce57c..556d5c83b 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -27,8 +27,12 @@ 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 }) => ({ +const getNavigationData = (counts?: { + participating?: number; + unreadNotifications?: number; +}) => ({ main: [ { title: 'Overview', @@ -97,7 +101,10 @@ const getNavigationData = (counts?: { participating?: number }) => ({ title: 'Notifications', url: '/me/notifications', icon: IconBell, - badge: '5', + badge: + counts?.unreadNotifications && counts.unreadNotifications > 0 + ? counts.unreadNotifications.toString() + : undefined, }, ], }); @@ -116,9 +123,11 @@ export function AppSidebar({ user: UserData; counts?: { participating?: 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/docs/project-detail-redesign.md b/docs/project-detail-redesign.md index 6957bd63c..5e5c01ffb 100644 --- a/docs/project-detail-redesign.md +++ b/docs/project-detail-redesign.md @@ -8,12 +8,13 @@ The objective is to create a simpler, clearer, and more professional experience --- Design Principles -* Clear primary action above the fold -* Strong visual hierarchy -* Reduced visual noise -* Consistent spacing and typography -* Intentional use of the green accent -* Professional and trustworthy product feel + +- Clear primary action above the fold +- Strong visual hierarchy +- Reduced visual noise +- Consistent spacing and typography +- Intentional use of the green accent +- Professional and trustworthy product feel --- @@ -21,65 +22,72 @@ Layout Structure Desktop Left: Sticky summary sidebar -* Project logo -* Title -* Status and short description -* Progress (votes/funding where applicable) -* Primary CTA (Vote or Back) -* Secondary actions (Share) -* Creator info and external links + +- Project logo +- Title +- Status and short description +- Progress (votes/funding where applicable) +- Primary CTA (Vote or Back) +- Secondary actions (Share) +- Creator info and external links Right: Main content area -* Horizontal tab navigation -* Dynamic tab content -* Optimized reading width for long-form content + +- Horizontal tab navigation +- Dynamic tab content +- Optimized reading width for long-form content > The sidebar remains visible while scrolling to maintain action visibility and improve engagement. Mobile -* Sidebar collapses into a condensed header block -* Horizontal scrollable tab navigation -* Single-column content layout -* Primary CTA positioned prominently without overwhelming the interface + +- Sidebar collapses into a condensed header block +- Horizontal scrollable tab navigation +- Single-column content layout +- Primary CTA positioned prominently without overwhelming the interface > No feature loss between desktop and mobile. --- Component Redesign + 1. Loading State -A simplified skeleton layout that mirrors final structure: -* Sidebar placeholder block -* Tab row skeleton -* Content block placeholders -* Reduced visual motion for a clean, professional feel + A simplified skeleton layout that mirrors final structure: + +- Sidebar placeholder block +- Tab row skeleton +- Content block placeholders +- Reduced visual motion for a clean, professional feel > The loading state communicates layout structure without clutter. --- 2. Sidebar (Desktop) / Header Block (Mobile) -Improvements: -* Strong title hierarchy -* Short description positioned clearly under title -* Simplified progress visualization -* One clearly emphasized primary action -* Secondary actions styled with lower visual weight -* Creator avatar and name placed below primary CTA -* External links grouped and visually subtle + Improvements: + +- Strong title hierarchy +- Short description positioned clearly under title +- Simplified progress visualization +- One clearly emphasized primary action +- Secondary actions styled with lower visual weight +- Creator avatar and name placed below primary CTA +- External links grouped and visually subtle > The goal is clarity and action focus. --- 3. Tab Bar -Tabs: Details, Team, Milestones, Voters (optional), Backers (optional), Comments -Improvements: -* Clear selected state -* Subtle hover state -* Consistent spacing and typography -* Scrollable on mobile -* Optional tabs hidden gracefully when not applicable + Tabs: Details, Team, Milestones, Voters (optional), Backers (optional), Comments + Improvements: + +- Clear selected state +- Subtle hover state +- Consistent spacing and typography +- Scrollable on mobile +- Optional tabs hidden gracefully when not applicable > The tab system is visually lightweight but structurally strong. @@ -87,66 +95,78 @@ Improvements: Tab-Level Redesign Details Tab -* Controlled reading width for markdown content -* Clear heading hierarchy -* Improved paragraph spacing -* Links styled consistently -* Optional media block positioned after introduction -* Improved vertical rhythm + +- Controlled reading width for markdown content +- Clear heading hierarchy +- Improved paragraph spacing +- Links styled consistently +- Optional media block positioned after introduction +- Improved vertical rhythm > Focus: readability and professional presentation. Team Tab -* Grid layout on desktop -* Vertical list on mobile -* Avatar, name, and role hierarchy clearly defined -* Clean spacing between members + +- Grid layout on desktop +- Vertical list on mobile +- Avatar, name, and role hierarchy clearly defined +- Clean spacing between members Empty State Example: + > No team members yet. This project is currently solo. Milestones Tab -* Vertical timeline layout -* Status indicators (Upcoming, Active, Completed) -* Clean separation between stages -* Optional filter alignment + +- Vertical timeline layout +- Status indicators (Upcoming, Active, Completed) +- Clean separation between stages +- Optional filter alignment Empty State Example: + > No milestones have been added yet. Voters Tab -* Clean list layout -* Vote indicators clearly distinguished -* Sorting control aligned with header + +- Clean list layout +- Vote indicators clearly distinguished +- Sorting control aligned with header Empty State Example: + > No votes yet. Be the first to support this project. Backers Tab -* Supporter card or structured list layout -* Clear contribution display -* Encouraging but subtle empty state + +- Supporter card or structured list layout +- Clear contribution display +- Encouraging but subtle empty state Empty State Example: + > Be the first to back this project. Comments Tab -* Structured threaded layout -* Controlled indentation depth -* Clear reply visibility -* Sorting dropdown aligned to section header -* Clean comment input field with clear submit action + +- Structured threaded layout +- Controlled indentation depth +- Clear reply visibility +- Sorting dropdown aligned to section header +- Clean comment input field with clear submit action Empty State Example: + > No comments yet. Start the discussion. --- Empty State Strategy Each tab includes a purposeful empty state to avoid dead screens: -* Encourage action -* Maintain tone consistency -* Support engagement goals + +- Encourage action +- Maintain tone consistency +- Support engagement goals --- @@ -154,70 +174,79 @@ Error State Strategy Each tab and interactive component must handle failures gracefully to maintain clarity, trust, and engagement. General Principles -* Display a clear, concise error message in context -* Offer a retry action when appropriate -* Maintain the same layout structure as loading and empty states -* Keep messaging professional and consistent with tone + +- Display a clear, concise error message in context +- Offer a retry action when appropriate +- Maintain the same layout structure as loading and empty states +- Keep messaging professional and consistent with tone Tab-Level Examples Details / Team / Milestones / Voters / Backers / Comments Tabs - * Error message: “Something went wrong while loading this content.” - * Retry CTA: “Try again” button positioned centrally within the tab content area - * Maintain spacing and typography consistency with other states + +- Error message: “Something went wrong while loading this content.” +- Retry CTA: “Try again” button positioned centrally within the tab content area +- Maintain spacing and typography consistency with other states Comment Submission Failure - * Inline error: “Your comment could not be submitted.” - * Preserve typed input so users don’t lose their content - * Include a retry button adjacent to the input field - Vote / Back Action Failure - * Inline or toast notification: “Your vote could not be recorded.” - * Provide immediate retry option - * Ensure visual distinction from primary actions to avoid confusion +- Inline error: “Your comment could not be submitted.” +- Preserve typed input so users don’t lose their content +- Include a retry button adjacent to the input field + +Vote / Back Action Failure + +- Inline or toast notification: “Your vote could not be recorded.” +- Provide immediate retry option +- Ensure visual distinction from primary actions to avoid confusion Deliverables for Implementation -* Error-state mockups for all tabs and key actions -* Retry interaction designs and user flow diagrams -* Copy for all error messages and notifications + +- Error-state mockups for all tabs and key actions +- Retry interaction designs and user flow diagrams +- Copy for all error messages and notifications --- UX Improvements -* Single primary CTA above the fold -* Sticky action area improves conversion -* Stronger reading experience -* Consistent spacing system -* Clear separation of primary vs secondary actions -* Reduced cognitive load + +- Single primary CTA above the fold +- Sticky action area improves conversion +- Stronger reading experience +- Consistent spacing system +- Clear separation of primary vs secondary actions +- Reduced cognitive load --- Visual System Adjustments -* Reduced shadow usage -* Controlled border radius for consistency -* Green accent reserved primarily for key actions and highlights -* Improved typography hierarchy -* Balanced whitespace for clarity + +- Reduced shadow usage +- Controlled border radius for consistency +- Green accent reserved primarily for key actions and highlights +- Improved typography hierarchy +- Balanced whitespace for clarity > The page now feels lighter, more modern, and more trustworthy. --- Responsiveness Strategy -* Sidebar collapses into header on smaller screens -* Horizontal tab scroll -* Content reflows naturally -* No feature disparity between device sizes + +- Sidebar collapses into header on smaller screens +- Horizontal tab scroll +- Content reflows naturally +- No feature disparity between device sizes --- Deliverables -* Desktop full-page mockups (all tabs) -* Mobile full-page mockups (all tabs) -* Component-level breakdown -* Loading, empty, and error state designs -* Retry interaction and UX flow documentation -* UX recommendations + +- Desktop full-page mockups (all tabs) +- Mobile full-page mockups (all tabs) +- Component-level breakdown +- Loading, empty, and error state designs +- Retry interaction and UX flow documentation +- UX recommendations Figma link: https://www.figma.com/design/EMNGAQl1SGObXcsoa24krt/Boundless_Project-Details?node-id=0-1&t=TAP62qLgaqjB5B1K-1 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 }), +}));