diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 98f8bf5..1cc8361 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; import { connectWallet, getPublicKey, isFreighterInstalled } from "@/lib/wallet"; +import { NotificationProvider } from "@/components/NotificationProvider"; interface WalletContextType { address: string | null; @@ -45,16 +46,18 @@ export function Providers({ children }: { children: React.ReactNode }) { }, []); return ( - - {children} - + + + {children} + + ); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2d673aa..b0d6635 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { ConnectWallet } from "./ConnectWallet"; +import { NotificationBell } from "./NotificationBell"; export function Navbar() { return ( @@ -27,7 +28,10 @@ export function Navbar() { - +
+ + +
diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx new file mode 100644 index 0000000..dda0c87 --- /dev/null +++ b/src/components/NotificationBell.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useNotifications, type Notification } from "./NotificationProvider"; + +// --------------------------------------------------------------------------- +// Icon components +// --------------------------------------------------------------------------- + +function BellIcon({ className }: { className?: string }) { + return ( + + ); +} + +const typeIcons: Record = { + contribution_received: "Contribution", + payout_distributed: "Payout", + dispute_raised: "Dispute", + member_joined: "Member", + round_started: "Round", +}; + +const typeColors: Record = { + contribution_received: "bg-green-100 text-green-800", + payout_distributed: "bg-blue-100 text-blue-800", + dispute_raised: "bg-red-100 text-red-800", + member_joined: "bg-purple-100 text-purple-800", + round_started: "bg-yellow-100 text-yellow-800", +}; + +function relativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + 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`; +} + +// --------------------------------------------------------------------------- +// NotificationItem +// --------------------------------------------------------------------------- + +function NotificationItem({ + notification, + onRead, +}: { + notification: Notification; + onRead: (id: string) => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// NotificationBell (main export) +// --------------------------------------------------------------------------- + +export function NotificationBell() { + const { + notifications, + unreadCount, + isConnected, + markAsRead, + markAllAsRead, + clearAll, + } = useNotifications(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ {/* Bell button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+
+

+ Notifications +

+ +
+
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ + {/* List */} +
+ {notifications.length === 0 ? ( +
+ No notifications yet +
+ ) : ( + notifications.map((n) => ( + + )) + )} +
+
+ )} +
+ ); +} diff --git a/src/components/NotificationProvider.tsx b/src/components/NotificationProvider.tsx new file mode 100644 index 0000000..7f39865 --- /dev/null +++ b/src/components/NotificationProvider.tsx @@ -0,0 +1,223 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, +} from "react"; + +/** The types of events SoroSave can notify about. */ +export type NotificationType = + | "contribution_received" + | "payout_distributed" + | "dispute_raised" + | "member_joined" + | "round_started"; + +export interface Notification { + id: string; + type: NotificationType; + title: string; + message: string; + timestamp: number; + read: boolean; + /** Optional group id the notification relates to. */ + groupId?: number; +} + +interface NotificationContextType { + notifications: Notification[]; + unreadCount: number; + /** Whether the WebSocket / SSE connection is active. */ + isConnected: boolean; + markAsRead: (id: string) => void; + markAllAsRead: () => void; + clearAll: () => void; +} + +const NotificationContext = createContext({ + notifications: [], + unreadCount: 0, + isConnected: false, + markAsRead: () => {}, + markAllAsRead: () => {}, + clearAll: () => {}, +}); + +export function useNotifications() { + return useContext(NotificationContext); +} + +const STORAGE_KEY = "sorosave_notifications"; +const WS_URL = + process.env.NEXT_PUBLIC_WS_URL || "wss://api.sorosave.io/notifications"; +const MAX_NOTIFICATIONS = 100; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function loadNotifications(): Notification[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as Notification[]) : []; + } catch { + return []; + } +} + +function saveNotifications(notifications: Notification[]): void { + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications)); + } +} + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function labelForType(type: NotificationType): string { + switch (type) { + case "contribution_received": + return "Contribution Received"; + case "payout_distributed": + return "Payout Distributed"; + case "dispute_raised": + return "Dispute Raised"; + case "member_joined": + return "Member Joined"; + case "round_started": + return "Round Started"; + default: + return "Notification"; + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function NotificationProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [notifications, setNotifications] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const wsRef = useRef(null); + const reconnectTimer = useRef>(); + + // Hydrate from localStorage on mount + useEffect(() => { + setNotifications(loadNotifications()); + }, []); + + // Persist whenever notifications change (skip initial empty render) + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + saveNotifications(notifications); + }, [notifications]); + + // ------- WebSocket / SSE connection ------- + const connectWs = useCallback(() => { + // If running without a real WS server, EventSource (SSE) can be used instead. + // This implementation uses WebSocket but falls back gracefully. + try { + const ws = new WebSocket(WS_URL); + + ws.onopen = () => { + setIsConnected(true); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as { + type: NotificationType; + message: string; + groupId?: number; + }; + + const notification: Notification = { + id: generateId(), + type: data.type, + title: labelForType(data.type), + message: data.message, + timestamp: Date.now(), + read: false, + groupId: data.groupId, + }; + + setNotifications((prev) => + [notification, ...prev].slice(0, MAX_NOTIFICATIONS), + ); + } catch { + // Ignore malformed messages + } + }; + + ws.onclose = () => { + setIsConnected(false); + // Auto-reconnect after 5 seconds + reconnectTimer.current = setTimeout(connectWs, 5000); + }; + + ws.onerror = () => { + ws.close(); + }; + + wsRef.current = ws; + } catch { + // WebSocket not available — degrade gracefully + setIsConnected(false); + } + }, []); + + useEffect(() => { + connectWs(); + + return () => { + clearTimeout(reconnectTimer.current); + wsRef.current?.close(); + }; + }, [connectWs]); + + // ------- Actions ------- + const markAsRead = useCallback((id: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)), + ); + }, []); + + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + }, []); + + const clearAll = useCallback(() => { + setNotifications([]); + }, []); + + const unreadCount = notifications.filter((n) => !n.read).length; + + return ( + + {children} + + ); +}