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}
+
+ );
+}