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
84 changes: 79 additions & 5 deletions frontend/src/components/Leaderboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
];
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.
*
Expand All @@ -27,27 +30,50 @@
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;
try {
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) {
Expand All @@ -56,8 +82,44 @@
setError(isNet ? 'Unable to reach the Stacks API. Check your connection.' : `Failed to load leaderboard: ${err.message}`);
setLoading(false);
}
}, []);

Check warning on line 85 in frontend/src/components/Leaderboard.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

React Hook useCallback has missing dependencies: 'leaders.length' and 'loading'. Either include them or remove the dependency array

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]);

Expand Down Expand Up @@ -131,12 +193,24 @@
)}
{sorted.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800 flex justify-between items-center px-3">
<span className="text-xs text-gray-400">Showing top {sorted.length} users</span>
<span className="text-xs text-gray-400">Showing top {sorted.length} users{totalApiEvents !== null ? ` (from ${allTipEvents.length} events of ${totalApiEvents} total)` : ''}</span>
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400">
Total: {formatSTX(sorted.reduce((sum, u) => sum + (tab === 'sent' ? u.totalSent : u.totalReceived), 0), 2)} STX
</span>
</div>
)}

{hasMore && (
<div className="mt-3 text-center">
<button
onClick={loadMoreEvents}
disabled={loadingMore}
className="px-4 py-2 text-xs font-semibold bg-amber-500 hover:bg-amber-600 disabled:bg-gray-300 dark:disabled:bg-gray-700 text-white rounded-lg transition-colors"
>
{loadingMore ? 'Loading more events…' : 'Load More Events for Accurate Rankings'}
</button>
</div>
)}
</div>
</div>
);
Expand Down
60 changes: 58 additions & 2 deletions frontend/src/components/RecentTips.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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());

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -186,7 +231,8 @@ export default function RecentTips({ addToast }) {
</div>
</div>
)}
{hasActiveFilters && <p className="text-xs text-gray-500 dark:text-gray-400">Showing {filteredTips.length} of {tips.length} tips</p>}
{hasActiveFilters && <p className="text-xs text-gray-500 dark:text-gray-400">Showing {filteredTips.length} of {tips.length} tips{totalApiEvents !== null && totalApiEvents > tips.length ? ` (${totalApiEvents} total on-chain)` : ''}</p>}
{!hasActiveFilters && totalApiEvents !== null && <p className="text-xs text-gray-500 dark:text-gray-400">Loaded {tips.length} of {totalApiEvents} on-chain events</p>}
</div>

{/* Tip cards */}
Expand Down Expand Up @@ -244,6 +290,16 @@ export default function RecentTips({ addToast }) {
</div>
)}

{/* Load More from API */}
{hasMore && (
<div className="flex justify-center mt-4">
<button onClick={loadMoreTips} disabled={loadingMore}
className="px-6 py-2.5 text-sm font-semibold bg-gray-900 dark:bg-amber-500 text-white dark:text-black rounded-xl hover:opacity-90 transition-opacity disabled:opacity-50">
{loadingMore ? 'Loading...' : 'Load More Tips'}
</button>
</div>
)}

{/* Tip-back modal */}
{tipBackTarget && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
Expand Down
69 changes: 68 additions & 1 deletion frontend/src/components/TipHistory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(); })
]);

Expand All @@ -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());

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -191,6 +245,19 @@ export default function TipHistory({ userAddress }) {
</div>
)}
</div>

{/* Load More from API */}
{hasMore && (
<div className="flex flex-col items-center gap-2 mt-4">
<button onClick={loadMoreTips} disabled={loadingMore}
className="px-6 py-2.5 text-sm font-semibold bg-gray-900 dark:bg-amber-500 text-white dark:text-black rounded-xl hover:opacity-90 transition-opacity disabled:opacity-50">
{loadingMore ? 'Loading...' : 'Load More Activity'}
</button>
{totalApiEvents !== null && (
<span className="text-xs text-gray-400">Showing {tips.length} of {totalApiEvents} on-chain events</span>
)}
</div>
)}
</div>
);
}
Loading