diff --git a/frontend/src/components/Leaderboard.jsx b/frontend/src/components/Leaderboard.jsx
index 025f73e0..ac4d91d6 100644
--- a/frontend/src/components/Leaderboard.jsx
+++ b/frontend/src/components/Leaderboard.jsx
@@ -14,6 +14,9 @@ const MEDALS = [
];
const DEFAULT_MEDAL = 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
+const API_LIMIT = 50;
+const MAX_AUTO_PAGES = 10; // Auto-fetch up to 500 events on initial load
+
/**
* Leaderboard component that ranks users by total STX sent or received.
*
@@ -27,9 +30,14 @@ export default function Leaderboard() {
const { refreshCounter } = useTipContext();
const [leaders, setLeaders] = useState([]);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(null);
const [tab, setTab] = useState('sent');
const [lastRefresh, setLastRefresh] = useState(null);
+ const [allTipEvents, setAllTipEvents] = useState([]);
+ const [apiOffset, setApiOffset] = useState(0);
+ const [hasMore, setHasMore] = useState(false);
+ const [totalApiEvents, setTotalApiEvents] = useState(null);
const fetchLeaderboard = useCallback(async () => {
if (loading && leaders.length > 0) return;
@@ -37,17 +45,35 @@ export default function Leaderboard() {
setLoading(true);
setError(null);
const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
- const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=50&offset=0`);
- if (!response.ok) throw new Error(`API returned ${response.status}`);
- const data = await response.json();
+ // Auto-paginate through multiple pages for accurate rankings
+ let accumulated = [];
+ let currentOffset = 0;
+ let totalEvents = null;
+
+ for (let page = 0; page < MAX_AUTO_PAGES; page++) {
+ const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=${API_LIMIT}&offset=${currentOffset}`);
+ if (!response.ok) throw new Error(`API returned ${response.status}`);
+
+ const data = await response.json();
+ totalEvents = data.total;
+ accumulated = accumulated.concat(data.results);
+ currentOffset += data.results.length;
- const tipEvents = data.results
+ // Stop if we've fetched all events
+ if (currentOffset >= data.total || data.results.length < API_LIMIT) break;
+ }
+
+ const tipEvents = accumulated
.filter(e => e.contract_log?.value?.repr)
.map(e => parseTipEvent(e.contract_log.value.repr))
.filter(t => t !== null && t.event === 'tip-sent' && t.sender && t.recipient && t.amount !== '0');
+ setAllTipEvents(tipEvents);
setLeaders(buildLeaderboardStats(tipEvents));
+ setApiOffset(currentOffset);
+ setHasMore(currentOffset < totalEvents);
+ setTotalApiEvents(totalEvents);
setLoading(false);
setLastRefresh(new Date());
} catch (err) {
@@ -58,6 +84,42 @@ export default function Leaderboard() {
}
}, []);
+ const loadMoreEvents = useCallback(async () => {
+ try {
+ setLoadingMore(true);
+ const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
+
+ let accumulated = [];
+ let currentOffset = apiOffset;
+
+ for (let page = 0; page < MAX_AUTO_PAGES; page++) {
+ const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=${API_LIMIT}&offset=${currentOffset}`);
+ if (!response.ok) throw new Error(`API returned ${response.status}`);
+
+ const data = await response.json();
+ accumulated = accumulated.concat(data.results);
+ currentOffset += data.results.length;
+
+ if (currentOffset >= data.total || data.results.length < API_LIMIT) break;
+ }
+
+ const newTipEvents = accumulated
+ .filter(e => e.contract_log?.value?.repr)
+ .map(e => parseTipEvent(e.contract_log.value.repr))
+ .filter(t => t !== null && t.event === 'tip-sent' && t.sender && t.recipient && t.amount !== '0');
+
+ const combinedEvents = [...allTipEvents, ...newTipEvents];
+ setAllTipEvents(combinedEvents);
+ setLeaders(buildLeaderboardStats(combinedEvents));
+ setApiOffset(currentOffset);
+ setHasMore(currentOffset < (totalApiEvents || Infinity));
+ } catch (err) {
+ console.error('Failed to load more leaderboard data:', err.message || err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [apiOffset, allTipEvents, totalApiEvents]);
+
useEffect(() => { fetchLeaderboard(); }, [fetchLeaderboard, refreshCounter]);
useEffect(() => { const i = setInterval(fetchLeaderboard, 60000); return () => clearInterval(i); }, [fetchLeaderboard]);
@@ -131,12 +193,24 @@ export default function Leaderboard() {
)}
{sorted.length > 0 && (
- Showing top {sorted.length} users
+ Showing top {sorted.length} users{totalApiEvents !== null ? ` (from ${allTipEvents.length} events of ${totalApiEvents} total)` : ''}
Total: {formatSTX(sorted.reduce((sum, u) => sum + (tab === 'sent' ? u.totalSent : u.totalReceived), 0), 2)} STX
)}
+
+ {hasMore && (
+
+
+
+ )}
);
diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx
index a6683efc..152b8fc3 100644
--- a/frontend/src/components/RecentTips.jsx
+++ b/frontend/src/components/RecentTips.jsx
@@ -11,11 +11,13 @@ import { Zap, Search } from 'lucide-react';
import CopyButton from './ui/copy-button';
const PAGE_SIZE = 10;
+const API_LIMIT = 50;
export default function RecentTips({ addToast }) {
const { refreshCounter } = useTipContext();
const [tips, setTips] = useState([]);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState(null);
const [tipBackTarget, setTipBackTarget] = useState(null);
@@ -29,13 +31,16 @@ export default function RecentTips({ addToast }) {
const [sortBy, setSortBy] = useState('newest');
const [showFilters, setShowFilters] = useState(false);
const [offset, setOffset] = useState(0);
+ const [apiOffset, setApiOffset] = useState(0);
+ const [hasMore, setHasMore] = useState(false);
+ const [totalApiEvents, setTotalApiEvents] = useState(null);
const fetchRecentTips = useCallback(async () => {
try {
setError(null);
clearTipCache();
const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
- const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=50&offset=0`);
+ const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=${API_LIMIT}&offset=0`);
if (!response.ok) throw new Error(`API returned ${response.status}`);
const data = await response.json();
@@ -45,6 +50,9 @@ export default function RecentTips({ addToast }) {
.filter(t => t !== null && t.event === 'tip-sent');
setTips(tipEvents);
+ setApiOffset(data.results.length);
+ setHasMore(data.offset + data.results.length < data.total);
+ setTotalApiEvents(data.total);
setLoading(false);
setLastRefresh(new Date());
@@ -72,6 +80,43 @@ export default function RecentTips({ addToast }) {
}
}, []);
+ const loadMoreTips = useCallback(async () => {
+ try {
+ setLoadingMore(true);
+ const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
+ const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${contractId}/events?limit=${API_LIMIT}&offset=${apiOffset}`);
+ if (!response.ok) throw new Error(`API returned ${response.status}`);
+
+ const data = await response.json();
+ const newTipEvents = data.results
+ .filter(e => e.contract_log?.value?.repr)
+ .map(e => parseTipEvent(e.contract_log.value.repr))
+ .filter(t => t !== null && t.event === 'tip-sent');
+
+ setTips(prev => [...prev, ...newTipEvents]);
+ setApiOffset(prev => prev + data.results.length);
+ setHasMore(data.offset + data.results.length < data.total);
+
+ // Fetch messages for new tips
+ const tipIds = newTipEvents.map(t => t.tipId).filter(id => id && id !== '0');
+ if (tipIds.length > 0) {
+ try {
+ const messageMap = await fetchTipMessages(tipIds);
+ setTips(prev => prev.map(t => {
+ const msg = messageMap.get(String(t.tipId));
+ return msg ? { ...t, message: msg } : t;
+ }));
+ } catch (msgErr) {
+ console.warn('Failed to fetch tip messages:', msgErr.message || msgErr);
+ }
+ }
+ } catch (err) {
+ console.error('Failed to load more tips:', err.message || err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [apiOffset]);
+
useEffect(() => { fetchRecentTips(); }, [fetchRecentTips, refreshCounter]);
useEffect(() => { const i = setInterval(fetchRecentTips, 60000); return () => clearInterval(i); }, [fetchRecentTips]);
@@ -186,7 +231,8 @@ export default function RecentTips({ addToast }) {
)}
- {hasActiveFilters && Showing {filteredTips.length} of {tips.length} tips
}
+ {hasActiveFilters && Showing {filteredTips.length} of {tips.length} tips{totalApiEvents !== null && totalApiEvents > tips.length ? ` (${totalApiEvents} total on-chain)` : ''}
}
+ {!hasActiveFilters && totalApiEvents !== null && Loaded {tips.length} of {totalApiEvents} on-chain events
}
{/* Tip cards */}
@@ -244,6 +290,16 @@ export default function RecentTips({ addToast }) {
)}
+ {/* Load More from API */}
+ {hasMore && (
+
+
+
+ )}
+
{/* Tip-back modal */}
{tipBackTarget && (
diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx
index 99e8e8c5..51fdcc04 100644
--- a/frontend/src/components/TipHistory.jsx
+++ b/frontend/src/components/TipHistory.jsx
@@ -14,16 +14,22 @@ const CATEGORY_LABELS = {
3: 'Community Help', 4: 'Appreciation', 5: 'Education', 6: 'Bug Bounty',
};
+const API_LIMIT = 50;
+
export default function TipHistory({ userAddress }) {
const { refreshCounter } = useTipContext();
const [stats, setStats] = useState(null);
const [tips, setTips] = useState([]);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState(null);
const [tab, setTab] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [lastRefresh, setLastRefresh] = useState(null);
+ const [apiOffset, setApiOffset] = useState(0);
+ const [hasMore, setHasMore] = useState(false);
+ const [totalApiEvents, setTotalApiEvents] = useState(null);
const fetchData = useCallback(async () => {
if (!userAddress) return;
@@ -35,7 +41,7 @@ export default function TipHistory({ userAddress }) {
network, contractAddress: CONTRACT_ADDRESS, contractName: CONTRACT_NAME,
functionName: 'get-user-stats', functionArgs: [principalCV(userAddress)], senderAddress: userAddress,
}),
- fetch(`${STACKS_API_BASE}/extended/v1/contract/${CONTRACT_ADDRESS}.${CONTRACT_NAME}/events?limit=50&offset=0`)
+ fetch(`${STACKS_API_BASE}/extended/v1/contract/${CONTRACT_ADDRESS}.${CONTRACT_NAME}/events?limit=${API_LIMIT}&offset=0`)
.then(r => { if (!r.ok) throw new Error(`API returned ${r.status}`); return r.json(); })
]);
@@ -55,6 +61,9 @@ export default function TipHistory({ userAddress }) {
.map(t => ({ ...t, direction: t.sender === userAddress ? 'sent' : 'received', category: categoryMap[t.tipId] ?? null }));
setTips(userTips);
+ setApiOffset(tipsResult.results.length);
+ setHasMore(tipsResult.offset + tipsResult.results.length < tipsResult.total);
+ setTotalApiEvents(tipsResult.total);
setLoading(false);
setLastRefresh(new Date());
@@ -82,6 +91,51 @@ export default function TipHistory({ userAddress }) {
}
}, [userAddress]);
+ const loadMoreTips = useCallback(async () => {
+ if (!userAddress) return;
+ try {
+ setLoadingMore(true);
+ const response = await fetch(`${STACKS_API_BASE}/extended/v1/contract/${CONTRACT_ADDRESS}.${CONTRACT_NAME}/events?limit=${API_LIMIT}&offset=${apiOffset}`);
+ if (!response.ok) throw new Error(`API returned ${response.status}`);
+
+ const data = await response.json();
+ const allEvents = data.results
+ .filter(e => e.contract_log?.value?.repr)
+ .map(e => parseTipEvent(e.contract_log.value.repr))
+ .filter(Boolean);
+
+ const categoryMap = {};
+ allEvents.filter(e => e.event === 'tip-categorized').forEach(e => { categoryMap[e.tipId] = Number(e.category || 0); });
+
+ const newUserTips = allEvents
+ .filter(t => t.event === 'tip-sent')
+ .filter(t => t.sender === userAddress || t.recipient === userAddress)
+ .map(t => ({ ...t, direction: t.sender === userAddress ? 'sent' : 'received', category: categoryMap[t.tipId] ?? null }));
+
+ setTips(prev => [...prev, ...newUserTips]);
+ setApiOffset(prev => prev + data.results.length);
+ setHasMore(data.offset + data.results.length < data.total);
+
+ // Fetch messages for new tips
+ const tipIds = newUserTips.map(t => t.tipId).filter(id => id && id !== '0');
+ if (tipIds.length > 0) {
+ try {
+ const messageMap = await fetchTipMessages(tipIds);
+ setTips(prev => prev.map(t => {
+ const msg = messageMap.get(String(t.tipId));
+ return msg ? { ...t, message: msg } : t;
+ }));
+ } catch (msgErr) {
+ console.warn('Failed to fetch tip messages:', msgErr.message || msgErr);
+ }
+ }
+ } catch (err) {
+ console.error('Failed to load more tips:', err.message || err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [userAddress, apiOffset]);
+
useEffect(() => { fetchData(); }, [fetchData, refreshCounter]);
useEffect(() => { const i = setInterval(fetchData, 60000); return () => clearInterval(i); }, [fetchData]);
@@ -191,6 +245,19 @@ export default function TipHistory({ userAddress }) {
)}
+
+ {/* Load More from API */}
+ {hasMore && (
+
+
+ {totalApiEvents !== null && (
+ Showing {tips.length} of {totalApiEvents} on-chain events
+ )}
+
+ )}
);
}