diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7a373bb9..f5275d05 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import Onboarding from './components/Onboarding'; import { AnimatedHero } from './components/ui/animated-hero'; import { ToastContainer, useToast } from './components/ui/toast'; import { analytics } from './lib/analytics'; +import { useNotifications } from './hooks/useNotifications'; const TipHistory = lazy(() => import('./components/TipHistory')); const PlatformStats = lazy(() => import('./components/PlatformStats')); @@ -24,6 +25,9 @@ function App() { const { toasts, addToast, removeToast } = useToast(); const location = useLocation(); + const userAddress = userData?.profile?.stxAddress?.mainnet || null; + const { notifications, unreadCount, markAllRead, loading: notificationsLoading } = useNotifications(userAddress); + useEffect(() => { if (userSession.isUserSignedIn()) { setUserData(userSession.loadUserData()); @@ -71,7 +75,15 @@ function App() { return (
-
+
{userData ? ( diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 7ac5cc7b..c2210aa4 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import CopyButton from './ui/copy-button'; +import NotificationBell from './NotificationBell'; import { useTheme } from '../context/ThemeContext'; import { NETWORK_NAME, STACKS_API_BASE } from '../config/contracts'; -export default function Header({ userData, onAuth, authLoading }) { +export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading }) { const { theme, toggleTheme } = useTheme(); const [apiReachable, setApiReachable] = useState(null); @@ -64,6 +65,15 @@ export default function Header({ userData, onAuth, authLoading }) { )} + {userData && ( + + )} + {userData && (
Connected Wallet diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx new file mode 100644 index 00000000..b0a6133b --- /dev/null +++ b/frontend/src/components/NotificationBell.jsx @@ -0,0 +1,101 @@ +import { useState, useRef, useEffect } from 'react'; +import { formatSTX } from '../lib/utils'; + +export default function NotificationBell({ notifications, unreadCount, onMarkRead, loading }) { + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleToggle = () => { + setOpen((prev) => !prev); + if (!open && unreadCount > 0) { + onMarkRead(); + } + }; + + const truncateAddr = (addr) => + addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : ''; + + return ( +
+ + + {open && ( +
+
+

Notifications

+ {notifications.length > 0 && ( + + )} +
+ +
+ {loading && notifications.length === 0 ? ( +
+ Loading... +
+ ) : notifications.length === 0 ? ( +
+ No tips received yet +
+ ) : ( + notifications.slice(0, 20).map((tip, i) => ( +
+
+
+

+ + +{formatSTX(tip.amount, 2)} STX + + {' '}from{' '} + + {truncateAddr(tip.sender)} + +

+ {tip.message && ( +

+ "{tip.message}" +

+ )} +
+
+
+
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js new file mode 100644 index 00000000..1e141215 --- /dev/null +++ b/frontend/src/hooks/useNotifications.js @@ -0,0 +1,83 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { CONTRACT_ADDRESS, CONTRACT_NAME, STACKS_API_BASE } from '../config/contracts'; + +const STORAGE_KEY = 'tipstream_last_seen_tip_ts'; +const POLL_INTERVAL = 30000; // 30 seconds + +function parseTipEvent(repr) { + try { + const eventMatch = repr.match(/event\s+u?"([^"]+)"/); + if (!eventMatch) return null; + const senderMatch = repr.match(/sender\s+'([A-Z0-9]+)/i); + const recipientMatch = repr.match(/recipient\s+'([A-Z0-9]+)/i); + const amountMatch = repr.match(/amount\s+u(\d+)/); + const messageMatch = repr.match(/message\s+u"([^"]*)"/); + const tipIdMatch = repr.match(/tip-id\s+u(\d+)/); + return { + event: eventMatch[1], + sender: senderMatch ? senderMatch[1] : '', + recipient: recipientMatch ? recipientMatch[1] : '', + amount: amountMatch ? amountMatch[1] : '0', + message: messageMatch ? messageMatch[1] : '', + tipId: tipIdMatch ? tipIdMatch[1] : '0', + }; + } catch { + return null; + } +} + +export function useNotifications(userAddress) { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(false); + const lastSeenRef = useRef( + parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10) + ); + + const fetchNotifications = useCallback(async () => { + if (!userAddress) return; + try { + setLoading(true); + const res = await fetch( + `${STACKS_API_BASE}/extended/v1/contract/${CONTRACT_ADDRESS}.${CONTRACT_NAME}/events?limit=50&offset=0` + ); + if (!res.ok) return; + const data = await res.json(); + + const receivedTips = data.results + .filter(e => e.contract_log?.value?.repr) + .map((e, idx) => ({ + ...parseTipEvent(e.contract_log.value.repr), + timestamp: e.block_time || Date.now() / 1000 - idx, + txId: e.tx_id, + })) + .filter(t => t && t.event === 'tip-sent' && t.recipient === userAddress); + + setNotifications(receivedTips); + + const unread = receivedTips.filter( + t => t.timestamp > lastSeenRef.current + ).length; + setUnreadCount(unread); + } catch (err) { + console.error('Failed to fetch notifications:', err.message || err); + } finally { + setLoading(false); + } + }, [userAddress]); + + const markAllRead = useCallback(() => { + const now = Math.floor(Date.now() / 1000); + lastSeenRef.current = now; + localStorage.setItem(STORAGE_KEY, String(now)); + setUnreadCount(0); + }, []); + + useEffect(() => { + fetchNotifications(); + const interval = setInterval(fetchNotifications, POLL_INTERVAL); + return () => clearInterval(interval); + }, [fetchNotifications]); + + return { notifications, unreadCount, loading, markAllRead, refetch: fetchNotifications }; +}