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