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