Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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());
Expand Down Expand Up @@ -71,7 +75,15 @@ function App() {
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-gray-950 transition-colors">
<OfflineBanner />
<Header userData={userData} onAuth={handleAuth} authLoading={authLoading} />
<Header
userData={userData}
onAuth={handleAuth}
authLoading={authLoading}
notifications={notifications}
unreadCount={unreadCount}
onMarkNotificationsRead={markAllRead}
notificationsLoading={notificationsLoading}
/>

<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{userData ? (
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -64,6 +65,15 @@ export default function Header({ userData, onAuth, authLoading }) {
)}
</button>

{userData && (
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkRead={onMarkNotificationsRead}
loading={notificationsLoading}
/>
)}

{userData && (
<div className="hidden sm:flex flex-col items-end">
<span className="text-[10px] font-bold text-gray-300 uppercase tracking-tighter">Connected Wallet</span>
Expand Down
101 changes: 101 additions & 0 deletions frontend/src/components/NotificationBell.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleToggle}
className="relative p-2 rounded-lg text-gray-300 hover:text-white hover:bg-white/10 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-5 w-5 flex items-center justify-center text-[10px] font-bold text-white bg-red-500 rounded-full ring-2 ring-gray-900">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>

{open && (
<div className="absolute right-0 top-full mt-2 w-80 bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
<h3 className="font-bold text-sm text-gray-800 dark:text-gray-200">Notifications</h3>
{notifications.length > 0 && (
<button
onClick={onMarkRead}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
Mark all read
</button>
)}
</div>

<div className="max-h-80 overflow-y-auto">
{loading && notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-400 text-sm">
Loading...
</div>
) : notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-400 text-sm">
No tips received yet
</div>
) : (
notifications.slice(0, 20).map((tip, i) => (
<div
key={tip.txId || i}
className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors border-b border-gray-50 dark:border-gray-800/50 last:border-0"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
<span className="text-green-600 dark:text-green-400 font-bold">
+{formatSTX(tip.amount, 2)} STX
</span>
{' '}from{' '}
<span className="font-mono text-xs text-gray-500">
{truncateAddr(tip.sender)}
</span>
</p>
{tip.message && (
<p className="text-xs text-gray-400 mt-0.5 italic truncate">
&quot;{tip.message}&quot;
</p>
)}
</div>
<div className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0 mt-1.5" />
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
83 changes: 83 additions & 0 deletions frontend/src/hooks/useNotifications.js
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading