@@ -273,349 +115,44 @@ function SummaryCard({ label, value, suffix, icon, trend, trendValue }: SummaryC
{suffix && {suffix} }
{trend && trendValue && (
-
- {trend === 'up' &&
}
- {trend === 'down' &&
}
- {trendValue}
+
+ {trendValue}
)}
);
}
-interface BountyCardProps {
- bounty: Bounty;
-}
-
-function BountyCard({ bounty }: BountyCardProps) {
+function BountyCard({ bounty }: { bounty: Bounty }) {
const daysRemaining = getDaysRemaining(bounty.deadline);
- const isUrgent = isDeadlineUrgent(daysRemaining);
-
return (
{bounty.title}
-
- {formatStatus(bounty.status)}
-
- {' • '}
-
- {daysRemaining} days left
-
+ {formatStatus(bounty.status)}
+ {' • '}{daysRemaining} days left
{formatNumber(bounty.reward)}
- $FNDRY
-
- {/* Progress Bar */}
-
- Progress
- {bounty.progress}%
-
);
}
-interface ActivityItemProps {
- activity: Activity;
-}
-
-function ActivityItem({ activity }: ActivityItemProps) {
- return (
-
-
- {getActivityIcon(activity.type)}
-
-
-
{activity.title}
-
{activity.description}
-
-
- {activity.amount && (
-
+{formatNumber(activity.amount)}
- )}
-
{formatRelativeTime(activity.timestamp)}
-
-
- );
-}
-
-interface NotificationItemProps {
- notification: Notification;
- onMarkAsRead: (id: string) => void;
-}
-
-function NotificationItem({ notification, onMarkAsRead }: NotificationItemProps) {
- return (
-
!notification.read && onMarkAsRead(notification.id)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- if (!notification.read) onMarkAsRead(notification.id);
- }
- }}
- tabIndex={0}
- role="button"
- aria-label={`${notification.title}: ${notification.message}. ${notification.read ? 'Read' : 'Unread - click to mark as read'}`}
- aria-pressed={notification.read}
- >
-
- {getNotificationIcon(notification.type)}
-
-
-
{notification.title}
-
{notification.message}
-
-
- {formatRelativeTime(notification.timestamp)}
-
-
- );
-}
-
-// Simple Line Chart Component
-interface SimpleLineChartProps {
- data: EarningsData[];
-}
-
-function SimpleLineChart({ data }: SimpleLineChartProps) {
- // Handle empty or insufficient data
- if (!data || data.length === 0) {
- return (
-
-
-
Earnings (Last 30 Days)
- 0 $FNDRY
-
-
- No earnings data available
-
-
- );
- }
-
- // For single data point, show a simple display
- if (data.length === 1) {
- return (
-
-
-
Earnings (Last 30 Days)
- {formatNumber(data[0].amount)} $FNDRY
-
-
-
- );
- }
-
- const maxAmount = Math.max(...data.map(d => d.amount), 1);
- const chartHeight = 120;
- const chartWidth = 300;
- const padding = 20;
-
- const points = data.map((d, i) => {
- const x = padding + (i / (data.length - 1)) * (chartWidth - 2 * padding);
- const y = chartHeight - padding - (d.amount / maxAmount) * (chartHeight - 2 * padding);
- return { x, y, amount: d.amount, date: d.date };
- });
-
- const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
- const areaD = `${pathD} L ${points[points.length - 1].x} ${chartHeight - padding} L ${padding} ${chartHeight - padding} Z`;
-
- return (
-
-
-
Earnings (Last 30 Days)
- {formatNumber(data[data.length - 1].amount)} $FNDRY
-
-
- {/* Grid lines */}
-
-
-
- {/* Area fill */}
-
-
- {/* Line */}
-
-
- {/* Points */}
- {points.map((p, i) => (
-
- {`${formatNumber(p.amount)} $FNDRY - ${p.date}`}
-
- ))}
-
- {/* Gradient definitions */}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// Quick Actions Component
-interface QuickActionsProps {
- onBrowseBounties?: () => void;
- onViewLeaderboard?: () => void;
- onCheckTreasury?: () => void;
-}
-
-function QuickActions({ onBrowseBounties, onViewLeaderboard, onCheckTreasury }: QuickActionsProps) {
- const actions = [
- { label: 'Browse Bounties', icon: '🔍', onClick: onBrowseBounties, color: 'from-[#9945FF] to-[#9945FF]' },
- { label: 'View Leaderboard', icon: '🏆', onClick: onViewLeaderboard, color: 'from-[#14F195] to-[#14F195]' },
- { label: 'Check Treasury', icon: '💰', onClick: onCheckTreasury, color: 'from-yellow-500 to-yellow-500' },
- ];
-
- return (
-
- {actions.map((action) => (
-
- {action.icon}
- {action.label}
-
- ))}
-
- );
-}
-
-// Settings Section Component
-interface SettingsSectionProps {
- linkedAccounts: { type: string; username: string; connected: boolean }[];
- notificationPreferences: { type: string; enabled: boolean }[];
- walletAddress?: string;
- onToggleNotification: (type: string) => void;
- onConnectAccount?: (accountType: string) => void;
- onDisconnectAccount?: (accountType: string) => void;
-}
-
-function SettingsSection({
- linkedAccounts,
- notificationPreferences,
- walletAddress,
- onToggleNotification,
- onConnectAccount,
- onDisconnectAccount
-}: SettingsSectionProps) {
- return (
-
-
Settings
-
- {/* Linked Accounts */}
-
-
Linked Accounts
-
- {linkedAccounts.map((account) => (
-
-
-
{account.type === 'github' ? '🐙' : account.type === 'twitter' ? '🐦' : '🔐'}
-
-
{account.type.charAt(0).toUpperCase() + account.type.slice(1)}
-
{account.connected ? account.username : 'Not connected'}
-
-
-
account.connected
- ? onDisconnectAccount?.(account.type)
- : onConnectAccount?.(account.type)
- }
- aria-label={account.connected ? `Disconnect ${account.type}` : `Connect ${account.type}`}
- aria-pressed={account.connected}
- className={`text-xs px-3 py-1 rounded transition-colors ${
- account.connected
- ? 'text-gray-400 bg-gray-700 hover:bg-gray-600'
- : 'text-[#14F195] bg-[#14F195]/10 hover:bg-[#14F195]/20'
- }`}
- >
- {account.connected ? 'Disconnect' : 'Connect'}
-
-
- ))}
-
-
-
- {/* Notification Preferences */}
-
-
Notifications
-
- {notificationPreferences.map((pref) => (
-
-
{pref.type}
-
onToggleNotification(pref.type)}
- aria-label={`Toggle ${pref.type} notifications`}
- aria-checked={pref.enabled}
- role="switch"
- className={`w-10 h-5 rounded-full transition-colors ${pref.enabled ? 'bg-[#14F195]' : 'bg-gray-700'}`}
- >
-
-
-
- ))}
-
-
-
- {/* Wallet */}
- {walletAddress && (
-
-
Wallet
-
-
Connected Wallet
-
- {walletAddress.slice(0, 8)}...{walletAddress.slice(-8)}
-
-
-
- )}
-
- );
-}
-
// ============================================================================
// Main Component
// ============================================================================
export function ContributorDashboard({
- userId,
walletAddress,
onBrowseBounties,
onViewLeaderboard,
@@ -624,18 +161,7 @@ export function ContributorDashboard({
onDisconnectAccount,
}: ContributorDashboardProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'notifications' | 'settings'>('overview');
-
- // Data states
- const [stats, setStats] = useState
(null);
- const [bounties, setBounties] = useState([]);
- const [activities, setActivities] = useState([]);
- const [notifications, setNotifications] = useState([]);
- const [earnings, setEarnings] = useState([]);
- const [linkedAccounts, setLinkedAccounts] = useState<{ type: string; username: string; connected: boolean }[]>([]);
-
- // UI states
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
+ const { data, isLoading, error, refetch } = useContributorDashboard();
const [notificationPrefs, setNotificationPrefs] = useState([
{ type: 'Payout Alerts', enabled: true },
@@ -644,127 +170,54 @@ export function ContributorDashboard({
{ type: 'New Bounties', enabled: false },
]);
- // Fetch data on mount and when userId changes
- useEffect(() => {
- let isMounted = true;
-
- async function loadData() {
- setIsLoading(true);
- setError(null);
-
- try {
- const data = await fetchDashboardData(userId);
-
- if (!isMounted) return;
-
- setStats(data.stats);
- setBounties(data.bounties);
- setActivities(data.activities);
- setNotifications(data.notifications);
- setEarnings(data.earnings);
- setLinkedAccounts(data.linkedAccounts);
- } catch (err) {
- if (!isMounted) return;
- setError(err instanceof Error ? err.message : 'Failed to load dashboard data');
- } finally {
- if (isMounted) {
- setIsLoading(false);
- }
- }
- }
-
- loadData();
-
- return () => {
- isMounted = false;
- };
- }, [userId]);
-
- const unreadNotifications = notifications.filter(n => !n.read).length;
+ const stats = data?.stats;
+ const bounties = data?.bounties || [];
+ const activities = data?.activities || [];
+ const notifications = data?.notifications || [];
+ const earnings = data?.earnings || [];
+ const linkedAccounts = data?.linkedAccounts || [];
+
+ const unreadNotifications = notifications.filter((n: any) => !n.read).length;
const handleMarkAsRead = useCallback((id: string) => {
- setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
+ console.log('Mark as read:', id);
}, []);
const handleMarkAllAsRead = useCallback(() => {
- setNotifications(prev => prev.map(n => ({ ...n, read: true })));
+ console.log('Mark all as read');
}, []);
const handleToggleNotification = useCallback((type: string) => {
setNotificationPrefs(prev => prev.map(p => p.type === type ? { ...p, enabled: !p.enabled } : p));
}, []);
- const handleRetry = useCallback(() => {
- // Trigger a re-render by clearing error and setting loading
- setError(null);
- setIsLoading(true);
-
- // Re-fetch data
- fetchDashboardData(userId)
- .then(data => {
- setStats(data.stats);
- setBounties(data.bounties);
- setActivities(data.activities);
- setNotifications(data.notifications);
- setEarnings(data.earnings);
- setLinkedAccounts(data.linkedAccounts);
- setIsLoading(false);
- })
- .catch(err => {
- setError(err instanceof Error ? err.message : 'Failed to load dashboard data');
- setIsLoading(false);
- });
- }, [userId]);
-
- // Loading state UI
if (isLoading) {
return (
-
-
-
Contributor Dashboard
-
Track your progress, earnings, and active work
-
-
-
-
-
Loading dashboard...
-
-
+
);
}
- // Error state UI
- if (error) {
+ if (error || !data) {
return (
-
-
-
-
Contributor Dashboard
-
Track your progress, earnings, and active work
-
-
-
-
-
-
Failed to Load Dashboard
-
{error}
-
- Retry
-
-
-
-
-
+
+
Error loading dashboard data.
+
refetch()} className="px-4 py-2 bg-solana-purple rounded-lg">Retry
);
}
@@ -772,185 +225,126 @@ export function ContributorDashboard({
return (
- {/* Header */}
Contributor Dashboard
Track your progress, earnings, and active work
- {/* Tab Navigation */}
- {[
- { id: 'overview', label: 'Overview' },
- { id: 'notifications', label: 'Notifications', badge: unreadNotifications },
- { id: 'settings', label: 'Settings' },
- ].map((tab) => (
+ {['overview', 'notifications', 'settings'].map((tab) => (
setActiveTab(tab.id as typeof activeTab)}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-colors relative
- ${activeTab === tab.id
- ? 'bg-gradient-to-r from-[#9945FF] to-[#14F195] text-white'
- : 'text-gray-400 hover:text-white hover:bg-white/5'
- }`}
+ key={tab}
+ onClick={() => setActiveTab(tab as any)}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ activeTab === tab ? 'bg-gradient-to-r from-[#9945FF] to-[#14F195] text-white' : 'text-gray-400 hover:text-white'
+ }`}
>
- {tab.label}
- {tab.badge && tab.badge > 0 && (
-
- {tab.badge}
-
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
+ {tab === 'notifications' && unreadNotifications > 0 && (
+ {unreadNotifications}
)}
))}
- {/* Content */}
- {activeTab === 'overview' && stats && (
-
- {/* Summary Cards */}
-
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
+ {activeTab === 'overview' && (
+
+
+
+
+
+
- {/* Quick Actions */}
-
-
- {/* Main Content Grid */}
-
- {/* Left Column */}
-
- {/* Active Bounties */}
-
-
-
Active Bounties
-
{bounties.length} active
+
+
+
+ Browse Bounties
+ Leaderboard
+ Treasury
- {bounties.length === 0 ? (
-
No active bounties
- ) : (
-
- {bounties.map((bounty) => (
-
- ))}
+
+
+
Active Bounties
+
+ {bounties.map((b: any) =>
)}
+ {bounties.length === 0 &&
No active bounties. Go claim some!
}
- )}
-
-
- {/* Earnings Chart */}
-
-
-
- {/* Right Column */}
-
- {/* Recent Activity */}
-
-
-
Recent Activity
- View All
- {activities.length === 0 ? (
-
No recent activity
- ) : (
-
- {activities.map((activity) => (
-
- ))}
-
- )}
-
-
+
+
+
+
Recent Activity
+
+ {activities.map((a: any) => (
+
+
{'✨'}
+
+
{a.title}
+
{a.description}
+
{formatRelativeTime(a.timestamp)}
+
+
+ ))}
+
+
)}
{activeTab === 'notifications' && (
-
-
-
Notifications
- {unreadNotifications > 0 && (
-
- Mark all as read
-
- )}
+
+
+
Notifications
+ Mark all as read
+
+
+ {notifications.map((n: any) => (
+
handleMarkAsRead(n.id)}>
+
+
+
{n.title}
+
{n.message}
+
{formatRelativeTime(n.timestamp)}
+
+
+ ))}
+ {notifications.length === 0 &&
No notifications.
}
- {notifications.length === 0 ? (
-
No notifications
- ) : (
-
- {notifications.map((notification) => (
-
- ))}
-
- )}
)}
{activeTab === 'settings' && (
-
+
+
+
Linked Accounts
+
+ {linkedAccounts.map((account: any) => (
+
+
+
{account.type === 'github' ? '🐙' : '🐦'}
+
+
{account.type}
+
{account.connected ? account.username : 'Not connected'}
+
+
+
+ {account.connected ? 'Disconnect' : 'Connect'}
+
+
+ ))}
+
+
+
+ {walletAddress && (
+
+
Connected Wallet
+ {walletAddress}
+
+ )}
+
)}
);
-}
-
-export default ContributorDashboard;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/frontend/src/components/CreatorDashboard.tsx b/frontend/src/components/CreatorDashboard.tsx
index ea22a58d..329a8925 100644
--- a/frontend/src/components/CreatorDashboard.tsx
+++ b/frontend/src/components/CreatorDashboard.tsx
@@ -1,5 +1,7 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useMemo } from 'react';
import { CreatorBountyCard } from './bounties/CreatorBountyCard';
+import { useCreatorDashboard } from '../hooks/useContributor';
+import { Skeleton, SkeletonCard } from './common/Skeleton';
interface CreatorDashboardProps {
userId?: string;
@@ -7,72 +9,32 @@ interface CreatorDashboardProps {
onNavigateBounties?: () => void;
}
-interface EscrowStats {
- staked: number;
- paid: number;
- refunded: number;
-}
-
export function CreatorDashboard({
- userId,
walletAddress,
onNavigateBounties,
}: CreatorDashboardProps) {
const [activeTab, setActiveTab] = useState('all');
- const [bounties, setBounties] = useState
([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const [escrowStats, setEscrowStats] = useState({ staked: 0, paid: 0, refunded: 0 });
- const [notifications, setNotifications] = useState({ pending: 0, disputed: 0 });
-
- const fetchBounties = useCallback(async () => {
- if (!walletAddress) {
- setIsLoading(false);
- return;
- }
-
- setIsLoading(true);
- setError(null);
- try {
- // Fetch bounties and stats in parallel
- const [bountiesRes, statsRes] = await Promise.all([
- fetch(`/api/bounties?created_by=${walletAddress}&limit=100`),
- fetch(`/api/bounties/creator/${walletAddress}/stats`)
- ]);
-
- if (!bountiesRes.ok) throw new Error('Failed to fetch bounties');
- if (!statsRes.ok) throw new Error('Failed to fetch stats');
-
- const [bountiesData, statsData] = await Promise.all([
- bountiesRes.json(),
- statsRes.json()
- ]);
-
- setBounties(bountiesData.items || []);
- setEscrowStats(statsData);
-
- // Calculate notification counts
- let pendingCount = 0;
- let disputedCount = 0;
- (bountiesData.items || []).forEach((b: any) => {
- b.submissions?.forEach((s: any) => {
- if (s.status === 'pending') pendingCount++;
- if (s.status === 'disputed') disputedCount++;
- });
+ const { data, isLoading, error, refetch } = useCreatorDashboard(walletAddress ?? '');
+
+ const { bounties, stats, notifications } = useMemo(() => {
+ const bl = data?.bounties || [];
+ const st = data?.stats || { staked: 0, paid: 0, refunded: 0 };
+
+ let pending = 0;
+ let disputed = 0;
+ bl.forEach((b: any) => {
+ b.submissions?.forEach((s: any) => {
+ if (s.status === 'pending') pending++;
+ if (s.status === 'disputed') disputed++;
});
- setNotifications({ pending: pendingCount, disputed: disputedCount });
-
- } catch (err: any) {
- setError(err.message);
- } finally {
- setIsLoading(false);
- }
- }, [walletAddress]);
-
- useEffect(() => {
- fetchBounties();
- }, [fetchBounties]);
+ });
+
+ return {
+ bounties: bl,
+ stats: st,
+ notifications: { pending, disputed }
+ };
+ }, [data]);
const tabs = [
{ id: 'all', label: 'All Bounties' },
@@ -84,22 +46,30 @@ export function CreatorDashboard({
{ id: 'cancelled', label: 'Cancelled' },
];
- const filteredBounties = activeTab === 'all' ? bounties : bounties.filter(b => b.status === activeTab);
+ const filteredBounties = activeTab === 'all' ? bounties : bounties.filter((b: any) => b.status === activeTab);
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
- return num.toString();
+ return num.toLocaleString();
};
if (isLoading) {
return (
-
-
+
);
}
@@ -112,11 +82,19 @@ export function CreatorDashboard({
);
}
+ if (error) {
+ return (
+
+
Error loading creator dashboard.
+
refetch()} className="px-4 py-2 bg-[#9945FF] rounded-lg">Retry
+
+ );
+ }
+
return (
- {/* Header elements */}
@@ -141,23 +119,21 @@ export function CreatorDashboard({
- {/* Escrow Overview */}
Total Escrowed (Active)
-
{formatNumber(escrowStats.staked)} $FNDRY
+
{formatNumber(stats.staked)} $FNDRY
Total Paid Out
-
{formatNumber(escrowStats.paid)} $FNDRY
+
{formatNumber(stats.paid)} $FNDRY
Total Refunded
-
{formatNumber(escrowStats.refunded)} $FNDRY
+
{formatNumber(stats.refunded)} $FNDRY
- {/* Tabs */}
{tabs.map(tab => (
@@ -175,15 +151,6 @@ export function CreatorDashboard({
- {/* Error message */}
- {error && (
-
- )}
-
- {/* Bounty List */}
{filteredBounties.length === 0 ? (
@@ -196,11 +163,11 @@ export function CreatorDashboard({
) : (
- filteredBounties.map(bounty => (
+ filteredBounties.map((bounty: any) => (
refetch()}
/>
))
)}
diff --git a/frontend/src/components/leaderboard/LeaderboardPage.tsx b/frontend/src/components/leaderboard/LeaderboardPage.tsx
index c66abf19..9c7f3b13 100644
--- a/frontend/src/components/leaderboard/LeaderboardPage.tsx
+++ b/frontend/src/components/leaderboard/LeaderboardPage.tsx
@@ -1,26 +1,51 @@
-/**
- * LeaderboardPage - Main view for the contributor leaderboard feature.
- * Renders search input, time-range toggle, sort selector, and the ranked
- * contributor table. Wired into the app router at /leaderboard via
- * pages/LeaderboardPage.tsx re-export.
- * @module components/leaderboard/LeaderboardPage
- */
+import { useState, useMemo } from 'react';
import { useLeaderboard } from '../../hooks/useLeaderboard';
import { SkeletonTable } from '../common/Skeleton';
import { NoDataAvailable } from '../common/EmptyState';
import type { TimeRange, SortField } from '../../types/leaderboard';
const RANGES: { label: string; value: TimeRange }[] = [
- { label: '7 days', value: '7d' }, { label: '30 days', value: '30d' },
- { label: '90 days', value: '90d' }, { label: 'All time', value: 'all' },
+ { label: '7 days', value: '7d' },
+ { label: '30 days', value: '30d' },
+ { label: '90 days', value: '90d' },
+ { label: 'All time', value: 'all' },
];
+
const SORTS: { label: string; value: SortField }[] = [
- { label: 'Points', value: 'points' }, { label: 'Bounties', value: 'bounties' },
+ { label: 'Points', value: 'points' },
+ { label: 'Bounties', value: 'bounties' },
{ label: 'Earnings', value: 'earnings' },
];
export function LeaderboardPage() {
- const { contributors, loading, error, timeRange, setTimeRange, sortBy, setSortBy, search, setSearch } = useLeaderboard();
+ const [timeRange, setTimeRange] = useState('all');
+ const [sortBy, setSortBy] = useState('points');
+ const [search, setSearch] = useState('');
+
+ const { contributors, loading, error } = useLeaderboard(timeRange);
+
+ const filteredAndSorted = useMemo(() => {
+ let list = [...contributors];
+
+ // Filter by search
+ if (search) {
+ list = list.filter((c) =>
+ c.username.toLowerCase().includes(search.toLowerCase())
+ );
+ }
+
+ // Sort
+ list.sort((a, b) => {
+ const aVal = sortBy === 'bounties' ? a.bountiesCompleted :
+ sortBy === 'earnings' ? a.earningsFndry : a.points;
+ const bVal = sortBy === 'bounties' ? b.bountiesCompleted :
+ sortBy === 'earnings' ? b.earningsFndry : b.points;
+ return bVal - aVal;
+ });
+
+ // Re-rank after filter/sort for display purposes if needed
+ return list.map((c, i) => ({ ...c, displayRank: i + 1 }));
+ }, [contributors, search, sortBy]);
if (loading) {
return (
@@ -38,52 +63,107 @@ export function LeaderboardPage() {
);
}
-
- if (error) return
Error: {error}
;
+
+ if (error) {
+ return (
+
+
Error: {error}
+
window.location.reload()}
+ className="text-sm text-[#00FF88] hover:underline"
+ >
+ Retry
+
+
+ );
+ }
return (
Contributor Leaderboard
- {contributors.length === 0 ? (
+ {filteredAndSorted.length === 0 ? (
) : (
- # Contributor
- Points Bounties
- Earned (FNDRY) Streak
+ #
+ Contributor
+ Points
+ Bounties
+ Earned (FNDRY)
+ Streak
- {contributors.map(c => (
-
- {c.rank <= 3 ? ['\u{1F947}','\u{1F948}','\u{1F949}'][c.rank-1] : c.rank}
+ {filteredAndSorted.map((c) => (
+
+
+ {c.displayRank <= 3
+ ? ['\u{1F947}', '\u{1F948}', '\u{1F949}'][c.displayRank - 1]
+ : c.displayRank}
+
-
+
{c.username}
- {c.topSkills.slice(0,2).join(', ')}
+
+ {c.topSkills.slice(0, 2).join(', ')}
+
+
+
+ {c.points.toLocaleString()}
- {c.points.toLocaleString()}
{c.bountiesCompleted}
- {c.earningsFndry.toLocaleString()}
- {c.streak}d
+
+ {c.earningsFndry.toLocaleString()}
+
+
+ {c.streak}d
+
))}
diff --git a/frontend/src/components/tokenomics/TokenomicsPage.tsx b/frontend/src/components/tokenomics/TokenomicsPage.tsx
index 3238719c..149ea8a9 100644
--- a/frontend/src/components/tokenomics/TokenomicsPage.tsx
+++ b/frontend/src/components/tokenomics/TokenomicsPage.tsx
@@ -1,4 +1,5 @@
import { useTreasuryStats } from '../../hooks/useTreasuryStats';
+import { Skeleton } from '../common/Skeleton';
/** Format a number for display: 1B / 200M / 10K / locale string. */
const fmt = (n: number) => n >= 1e9 ? `${(n/1e9).toFixed(1)}B` : n >= 1e6 ? `${(n/1e6).toFixed(1)}M` : n >= 1e3 ? `${(n/1e3).toFixed(1)}K` : n.toLocaleString();
@@ -42,18 +43,28 @@ function DistributionBar({ data, total }: { data: Record; total:
/**
* $FNDRY Tokenomics dashboard page.
- *
- * Displays live supply metrics, treasury balances, distribution chart, and
- * buyback/burn stats. Data is fetched via {@link useTreasuryStats} with
- * graceful fallback to mock data when the API is unavailable.
- *
- * Integrated into the app via Sidebar nav link at `/tokenomics` and
- * re-exported through `pages/TokenomicsPage.tsx` for the router.
*/
export function TokenomicsPage() {
const { tokenomics: t, treasury: tr, loading, error } = useTreasuryStats();
- if (loading) return Loading tokenomics...
;
+ if (loading || !t || !tr) {
+ return (
+
+ );
+ }
+
if (error) return Error: {error}
;
return (
diff --git a/frontend/src/hooks/useAgent.ts b/frontend/src/hooks/useAgent.ts
new file mode 100644
index 00000000..319a2db6
--- /dev/null
+++ b/frontend/src/hooks/useAgent.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query';
+import api from '../services/api';
+
+export function useAgents(filters: { role?: string; available?: boolean; page?: number; limit?: number } = {}) {
+ return useQuery({
+ queryKey: ['agents', filters],
+ queryFn: async () => {
+ const { data } = await api.get('/agents', { params: filters });
+ return data;
+ },
+ staleTime: 60000,
+ });
+}
+
+export function useAgent(agentId: string) {
+ return useQuery({
+ queryKey: ['agent', agentId],
+ queryFn: async () => {
+ const { data } = await api.get(`/agents/${agentId}`);
+ return data;
+ },
+ enabled: !!agentId,
+ staleTime: 60000,
+ });
+}
diff --git a/frontend/src/hooks/useBounties.ts b/frontend/src/hooks/useBounties.ts
new file mode 100644
index 00000000..28d67233
--- /dev/null
+++ b/frontend/src/hooks/useBounties.ts
@@ -0,0 +1,41 @@
+import { useQuery } from '@tanstack/react-query';
+import api from '../services/api';
+
+export interface Bounty {
+ id: string;
+ title: string;
+ description: string;
+ status: 'open' | 'claimed' | 'completed' | 'cancelled';
+ reward_amount: number;
+ reward_token: string;
+ tier: number;
+ creator_id: string;
+ created_at: string;
+ deadline?: string;
+ tags?: string[];
+ github_issue_url?: string;
+}
+
+export const fetchBounties = async (): Promise => {
+ const { data } = await api.get('/bounties');
+ return data;
+};
+
+export const useBounties = () => {
+ return useQuery({
+ queryKey: ['bounties'],
+ queryFn: fetchBounties,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ });
+};
+
+export const useBounty = (id: string) => {
+ return useQuery({
+ queryKey: ['bounty', id],
+ queryFn: async (): Promise => {
+ const { data } = await api.get(`/bounties/${id}`);
+ return data;
+ },
+ enabled: !!id,
+ });
+};
diff --git a/frontend/src/hooks/useBountyBoard.ts b/frontend/src/hooks/useBountyBoard.ts
index 497626bc..d1a8b6bf 100644
--- a/frontend/src/hooks/useBountyBoard.ts
+++ b/frontend/src/hooks/useBountyBoard.ts
@@ -1,13 +1,12 @@
-import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
+import { useState, useCallback } from 'react';
+import { useQuery } from '@tanstack/react-query';
import type { Bounty, BountyBoardFilters, BountySortBy, SearchResponse } from '../types/bounty';
import { DEFAULT_FILTERS } from '../types/bounty';
-import { mockBounties } from '../data/mockBounties';
-
-const REPO = 'SolFoundry/solfoundry';
-const GITHUB_API = 'https://api.github.com';
+import api from '../services/api';
const TIER_MAP: Record = { 1: 'T1', 2: 'T2', 3: 'T3' };
import type { BountyStatus } from '../types/bounty';
+
const STATUS_MAP: Record = {
open: 'open',
in_progress: 'in-progress',
@@ -40,167 +39,112 @@ function mapApiBounty(b: any): Bounty {
}
function buildSearchParams(
- filters: BountyBoardFilters, sortBy: BountySortBy, page: number, perPage: number,
-): URLSearchParams {
- const p = new URLSearchParams();
- if (filters.searchQuery.trim()) p.set('q', filters.searchQuery.trim());
+ filters: BountyBoardFilters,
+ sortBy: BountySortBy,
+ page: number,
+ perPage: number,
+): Record {
+ const p: Record = {};
+ if (filters.searchQuery.trim()) p.q = filters.searchQuery.trim();
if (filters.tier !== 'all') {
- const tierNum = filters.tier === 'T1' ? '1' : filters.tier === 'T2' ? '2' : '3';
- p.set('tier', tierNum);
+ p.tier = filters.tier === 'T1' ? 1 : filters.tier === 'T2' ? 2 : 3;
}
if (filters.status !== 'all') {
- const map: Record = { open: 'open', 'in-progress': 'in_progress', completed: 'completed' };
- p.set('status', map[filters.status] || filters.status);
- }
- if (filters.skills.length) p.set('skills', filters.skills.join(','));
- if (filters.rewardMin) p.set('reward_min', filters.rewardMin);
- if (filters.rewardMax) p.set('reward_max', filters.rewardMax);
- if (filters.creatorType !== 'all') p.set('creator_type', filters.creatorType);
- if (filters.category !== 'all') p.set('category', filters.category);
- if (filters.deadlineBefore) p.set('deadline_before', new Date(filters.deadlineBefore + 'T23:59:59Z').toISOString());
- p.set('sort', sortBy);
- p.set('page', String(page));
- p.set('per_page', String(perPage));
- return p;
-}
-
-const SORT_COMPAT: Record = { reward: 'reward_high' };
-
-function localSort(arr: Bounty[], sortBy: BountySortBy): Bounty[] {
- const s = [...arr];
- switch (sortBy) {
- case 'reward_high': return s.sort((a, b) => b.rewardAmount - a.rewardAmount);
- case 'reward_low': return s.sort((a, b) => a.rewardAmount - b.rewardAmount);
- case 'deadline': return s.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime());
- case 'submissions': return s.sort((a, b) => b.submissionCount - a.submissionCount);
- case 'best_match':
- case 'newest':
- default: return s.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
- }
-}
-
-function applyLocalFilters(all: Bounty[], f: BountyBoardFilters, sortBy: BountySortBy): Bounty[] {
- let r = [...all];
- if (f.tier !== 'all') r = r.filter(b => b.tier === f.tier);
- if (f.status !== 'all') r = r.filter(b => b.status === f.status);
- if (f.skills.length) r = r.filter(b => f.skills.some(s => b.skills.map(sk => sk.toLowerCase()).includes(s.toLowerCase())));
- if (f.searchQuery.trim()) {
- const q = f.searchQuery.toLowerCase();
- r = r.filter(b => b.title.toLowerCase().includes(q) || b.description.toLowerCase().includes(q) || b.projectName.toLowerCase().includes(q));
+ const map: Record = {
+ open: 'open',
+ 'in-progress': 'in_progress',
+ completed: 'completed',
+ };
+ p.status = map[filters.status] || filters.status;
}
- if (f.rewardMin) { const min = Number(f.rewardMin); if (!isNaN(min)) r = r.filter(b => b.rewardAmount >= min); }
- if (f.rewardMax) { const max = Number(f.rewardMax); if (!isNaN(max)) r = r.filter(b => b.rewardAmount <= max); }
- if (f.deadlineBefore) {
- const cutoff = new Date(f.deadlineBefore + 'T23:59:59Z').getTime();
- r = r.filter(b => new Date(b.deadline).getTime() <= cutoff);
+ if (filters.skills.length) p.skills = filters.skills.join(',');
+ if (filters.rewardMin) p.reward_min = filters.rewardMin;
+ if (filters.rewardMax) p.reward_max = filters.rewardMax;
+ if (filters.creatorType !== 'all') p.creator_type = filters.creatorType;
+ if (filters.category !== 'all') p.category = filters.category;
+ if (filters.deadlineBefore) {
+ p.deadline_before = new Date(filters.deadlineBefore + 'T23:59:59Z').toISOString();
}
- return localSort(r, sortBy);
+ p.sort = sortBy;
+ p.page = page;
+ p.per_page = perPage;
+ return p;
}
export function useBountyBoard() {
- const [allBounties, setAllBounties] = useState(mockBounties);
- const [apiResults, setApiResults] = useState<{ items: Bounty[]; total: number } | null>(null);
- const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [sortBy, setSortByRaw] = useState('newest');
const [page, setPage] = useState(1);
- const [hotBounties, setHotBounties] = useState([]);
- const [recommendedBounties, setRecommendedBounties] = useState([]);
const perPage = 20;
- const abortRef = useRef(null);
- const useApiRef = useRef(true);
const setSortBy = useCallback((s: BountySortBy | string) => {
- setSortByRaw((SORT_COMPAT[s] || s) as BountySortBy);
+ setSortByRaw(s as BountySortBy);
setPage(1);
}, []);
- // Server-side search
- useEffect(() => {
- if (!useApiRef.current) return;
- const timer = setTimeout(async () => {
- abortRef.current?.abort();
- const ctrl = new AbortController();
- abortRef.current = ctrl;
- setLoading(true);
- try {
- const params = buildSearchParams(filters, sortBy, page, perPage);
- const res = await fetch(`/api/bounties/search?${params}`, { signal: ctrl.signal });
- if (!res.ok) throw new Error('search failed');
- const data: SearchResponse = await res.json();
- setApiResults({ items: data.items.map(mapApiBounty), total: data.total });
- } catch (e: any) {
- if (e.name === 'AbortError') return;
- useApiRef.current = false;
- setApiResults(null);
- // Fallback: fetch all bounties once from old list endpoint
- try {
- const res = await fetch('/api/bounties?limit=100');
- if (res.ok) {
- const data = await res.json();
- const items = (data.items || data);
- if (Array.isArray(items) && items.length > 0) {
- setAllBounties(items.map(mapApiBounty));
- }
- }
- } catch { /* keep mock data */ }
- } finally {
- if (!ctrl.signal.aborted) setLoading(false);
- }
- }, 200);
- return () => clearTimeout(timer);
- }, [filters, sortBy, page]);
-
- // Client-side filtered results (fallback when API unavailable)
- const localFiltered = useMemo(
- () => applyLocalFilters(allBounties, filters, sortBy),
- [allBounties, filters, sortBy],
+ const setFilter = useCallback(
+ (k: K, v: BountyBoardFilters[K]) => {
+ setFilters((p) => ({ ...p, [k]: v }));
+ setPage(1);
+ },
+ []
);
- // Decide which results to use
- const bounties = apiResults ? apiResults.items : localFiltered;
- const total = apiResults ? apiResults.total : localFiltered.length;
- const totalPages = Math.max(1, Math.ceil(total / perPage));
-
- // Fetch hot bounties once
- useEffect(() => {
- (async () => {
- try {
- const res = await fetch('/api/bounties/hot?limit=6');
- if (res.ok) setHotBounties((await res.json()).map(mapApiBounty));
- } catch { /* ignore */ }
- })();
- }, []);
-
- // Fetch recommended bounties
- useEffect(() => {
- const skills = filters.skills.length > 0 ? filters.skills : ['react', 'typescript', 'rust'];
- (async () => {
- try {
- const res = await fetch(`/api/bounties/recommended?skills=${skills.join(',')}&limit=6`);
- if (res.ok) setRecommendedBounties((await res.json()).map(mapApiBounty));
- } catch { /* ignore */ }
- })();
- }, [filters.skills]);
-
- const setFilter = useCallback((k: K, v: BountyBoardFilters[K]) => {
- setFilters(p => ({ ...p, [k]: v }));
+ const resetFilters = useCallback(() => {
+ setFilters(DEFAULT_FILTERS);
setPage(1);
}, []);
+ // Main Search Query
+ const { data: searchData, isLoading: searchLoading } = useQuery({
+ queryKey: ['bounties', filters, sortBy, page],
+ queryFn: async (): Promise => {
+ const params = buildSearchParams(filters, sortBy, page, perPage);
+ const { data } = await api.get('/bounties/search', { params });
+ return {
+ ...data,
+ items: data.items.map(mapApiBounty),
+ };
+ },
+ });
+
+ // Hot Bounties
+ const { data: hotBounties = [] } = useQuery({
+ queryKey: ['bounties', 'hot'],
+ queryFn: async (): Promise => {
+ const { data } = await api.get('/bounties/hot', { params: { limit: 6 } });
+ return data.map(mapApiBounty);
+ },
+ });
+
+ // Recommended Bounties
+ const { data: recommendedBounties = [] } = useQuery({
+ queryKey: ['bounties', 'recommended', filters.skills],
+ queryFn: async (): Promise => {
+ const skills = filters.skills.length > 0 ? filters.skills : ['react', 'typescript', 'rust'];
+ const { data } = await api.get('/bounties/recommended', {
+ params: { skills: skills.join(','), limit: 6 },
+ });
+ return data.map(mapApiBounty);
+ },
+ });
+
+ const bounties = searchData?.items || [];
+ const total = searchData?.total || 0;
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
+
return {
bounties,
- allBounties,
total,
filters,
sortBy,
- loading,
+ loading: searchLoading,
page,
totalPages,
hotBounties,
recommendedBounties,
setFilter,
- resetFilters: useCallback(() => { setFilters(DEFAULT_FILTERS); setPage(1); setApiResults(null); }, []),
+ resetFilters,
setSortBy,
setPage,
};
diff --git a/frontend/src/hooks/useContributor.ts b/frontend/src/hooks/useContributor.ts
new file mode 100644
index 00000000..70cd7c8d
--- /dev/null
+++ b/frontend/src/hooks/useContributor.ts
@@ -0,0 +1,58 @@
+import { useQuery } from '@tanstack/react-query';
+import api from '../services/api';
+
+export interface Contributor {
+ id: string;
+ username: string;
+ display_name: string;
+ avatar_url?: string;
+ bio?: string;
+ reputation_score: number;
+ total_earnings: number;
+ total_bounties: number;
+ skills: string[];
+ github_id?: string;
+ wallet_address?: string;
+}
+
+export const fetchContributor = async (idOrUsername: string): Promise => {
+ const { data } = await api.get(`/contributors/${idOrUsername}`);
+ return data;
+};
+
+export const useContributor = (idOrUsername: string) => {
+ return useQuery({
+ queryKey: ['contributor', idOrUsername],
+ queryFn: () => fetchContributor(idOrUsername),
+ enabled: !!idOrUsername,
+ });
+};
+
+export function useContributorDashboard() {
+ return useQuery({
+ queryKey: ['contributor-dashboard'],
+ queryFn: async () => {
+ const { data } = await api.get('/contributors/me/dashboard');
+ return data;
+ },
+ staleTime: 30000,
+ });
+}
+
+export function useCreatorDashboard(walletAddress: string) {
+ return useQuery({
+ queryKey: ['creator-dashboard', walletAddress],
+ queryFn: async () => {
+ const [bountiesRes, statsRes] = await Promise.all([
+ api.get(`/bounties?created_by=${walletAddress}&limit=100`),
+ api.get(`/bounties/creator/${walletAddress}/stats`)
+ ]);
+ return {
+ bounties: bountiesRes.data.items || [],
+ stats: statsRes.data
+ };
+ },
+ enabled: !!walletAddress,
+ staleTime: 30000,
+ });
+}
diff --git a/frontend/src/hooks/useLeaderboard.ts b/frontend/src/hooks/useLeaderboard.ts
index 74f3974a..7a8d7b0d 100644
--- a/frontend/src/hooks/useLeaderboard.ts
+++ b/frontend/src/hooks/useLeaderboard.ts
@@ -1,129 +1,32 @@
-/**
- * useLeaderboard - Data-fetching hook for the contributor leaderboard.
- * Tries GET /api/leaderboard, falls back to GitHub API for merged PRs,
- * merges with known Phase 1 payout data.
- * @module hooks/useLeaderboard
- */
-import { useState, useEffect, useMemo } from 'react';
-import type { Contributor, TimeRange, SortField } from '../types/leaderboard';
-
-const REPO = 'SolFoundry/solfoundry';
-const GITHUB_API = 'https://api.github.com';
-
-/** Known Phase 1 payout data (on-chain payouts). */
-const KNOWN_PAYOUTS: Record = {
- HuiNeng6: { bounties: 12, fndry: 1_800_000, skills: ['Python', 'FastAPI', 'React', 'TypeScript', 'WebSocket'] },
- ItachiDevv: { bounties: 8, fndry: 1_750_000, skills: ['React', 'TypeScript', 'Tailwind', 'Solana'] },
- LaphoqueRC: { bounties: 1, fndry: 150_000, skills: ['Frontend', 'React'] },
- zhaog100: { bounties: 1, fndry: 150_000, skills: ['Backend', 'Python', 'FastAPI'] },
+import { useQuery } from '@tanstack/react-query';
+import api from '../services/api';
+import type { Contributor, TimeRange } from '../types/leaderboard';
+
+export const fetchLeaderboard = async (
+ range: TimeRange = 'all',
+ limit: number = 20
+): Promise => {
+ const { data } = await api.get('/leaderboard', {
+ params: { range, limit },
+ });
+ return data;
};
-/** Fetch merged PRs from GitHub to build contributor stats. */
-async function fetchGitHubContributors(): Promise {
- const url = `${GITHUB_API}/repos/${REPO}/pulls?state=closed&per_page=100&sort=updated&direction=desc`;
- const res = await fetch(url);
- if (!res.ok) return [];
-
- const prs = await res.json();
- if (!Array.isArray(prs)) return [];
-
- // Count merged PRs per author
- const stats: Record = {};
- for (const pr of prs) {
- if (!pr.merged_at) continue;
- const login = pr.user?.login;
- if (!login || login.includes('[bot]')) continue;
- if (!stats[login]) stats[login] = { prs: 0, avatar: pr.user.avatar_url || '' };
- stats[login].prs++;
- }
-
- // Merge with known payout data
- const allAuthors = new Set([...Object.keys(KNOWN_PAYOUTS), ...Object.keys(stats)]);
- const contributors: Contributor[] = [];
-
- for (const author of allAuthors) {
- const known = KNOWN_PAYOUTS[author];
- const prData = stats[author];
- const totalPrs = prData?.prs || 0;
- const bounties = known?.bounties || totalPrs;
- const earnings = known?.fndry || 0;
- const skills = known?.skills || [];
- const avatar = prData?.avatar || `https://avatars.githubusercontent.com/${author}`;
-
- // Reputation score
- let rep = 0;
- rep += Math.min(totalPrs * 5, 40);
- rep += Math.min(bounties * 5, 40);
- rep += Math.min(skills.length * 3, 20);
- rep = Math.min(rep, 100);
-
- contributors.push({
- rank: 0,
- username: author,
- avatarUrl: avatar,
- points: rep * 100 + bounties * 50,
- bountiesCompleted: bounties,
- earningsFndry: earnings,
- earningsSol: 0,
- streak: Math.max(1, Math.floor(bounties / 2)),
- topSkills: skills.slice(0, 3),
- });
- }
-
- return contributors;
-}
-
-export function useLeaderboard() {
- const [contributors, setContributors] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [timeRange, setTimeRange] = useState('all');
- const [sortBy, setSortBy] = useState('points');
- const [search, setSearch] = useState('');
-
- useEffect(() => {
- let c = false;
- (async () => {
- try {
- // Try backend API first
- const r = await fetch(`/api/leaderboard?range=${timeRange}`);
- if (!c && r.ok) {
- const data = await r.json();
- if (Array.isArray(data) && data.length > 0) {
- setContributors(data);
- setLoading(false);
- return;
- }
- }
- } catch {
- // Backend unavailable — fall through to GitHub
- }
-
- try {
- // Fallback: build from GitHub API + known payouts
- const contribs = await fetchGitHubContributors();
- if (!c && contribs.length > 0) {
- setContributors(contribs);
- }
- } catch (e) {
- if (!c) setError(e instanceof Error ? e.message : 'Failed');
- } finally {
- if (!c) setLoading(false);
- }
- })();
- return () => { c = true; };
- }, [timeRange]);
-
- const sorted = useMemo(() => {
- let list = [...contributors];
- if (search) list = list.filter(c => c.username.toLowerCase().includes(search.toLowerCase()));
- list.sort((a, b) => {
- const aValue = sortBy === 'bounties' ? a.bountiesCompleted : sortBy === 'earnings' ? a.earningsFndry : a.points;
- const bValue = sortBy === 'bounties' ? b.bountiesCompleted : sortBy === 'earnings' ? b.earningsFndry : b.points;
- return bValue - aValue;
- });
- return list.map((c, i) => ({ ...c, rank: i + 1 }));
- }, [contributors, sortBy, search]);
-
- return { contributors: sorted, loading, error, timeRange, setTimeRange, sortBy, setSortBy, search, setSearch };
+export function useLeaderboard(range: TimeRange = 'all', limit: number = 20) {
+ const {
+ data: contributors = [],
+ isLoading,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: ['leaderboard', range, limit],
+ queryFn: () => fetchLeaderboard(range, limit),
+ });
+
+ return {
+ contributors,
+ loading: isLoading,
+ error: error instanceof Error ? error.message : null,
+ refetch,
+ };
}
diff --git a/frontend/src/hooks/useTreasuryStats.ts b/frontend/src/hooks/useTreasuryStats.ts
index 0b671d54..fd600dcd 100644
--- a/frontend/src/hooks/useTreasuryStats.ts
+++ b/frontend/src/hooks/useTreasuryStats.ts
@@ -1,31 +1,36 @@
-import { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
import type { TokenomicsData, TreasuryStats } from '../types/tokenomics';
-import { MOCK_TOKENOMICS, MOCK_TREASURY } from '../data/mockTokenomics';
+import api from '../services/api';
-/**
- * Fetches live tokenomics and treasury data from `/api/tokenomics` and `/api/treasury`.
- * Falls back to {@link MOCK_TOKENOMICS} / {@link MOCK_TREASURY} when the API is unreachable
- * or returns a non-OK status, so the page always renders meaningful data.
- */
export function useTreasuryStats() {
- const [tokenomics, setTokenomics] = useState(MOCK_TOKENOMICS);
- const [treasury, setTreasury] = useState(MOCK_TREASURY);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const {
+ data: tokenomics,
+ isLoading: loadingTokenomics,
+ error: errorTokenomics,
+ } = useQuery({
+ queryKey: ['stats', 'tokenomics'],
+ queryFn: async (): Promise => {
+ const { data } = await api.get('/stats/tokenomics');
+ return data;
+ },
+ });
- useEffect(() => {
- let cancelled = false;
- (async () => {
- try {
- const [tRes, trRes] = await Promise.all([fetch('/api/tokenomics'), fetch('/api/treasury')]);
- if (!cancelled && tRes.ok && trRes.ok) {
- setTokenomics(await tRes.json()); setTreasury(await trRes.json());
- }
- } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load'); }
- finally { if (!cancelled) setLoading(false); }
- })();
- return () => { cancelled = true; };
- }, []);
+ const {
+ data: treasury,
+ isLoading: loadingTreasury,
+ error: errorTreasury,
+ } = useQuery({
+ queryKey: ['stats', 'treasury'],
+ queryFn: async (): Promise => {
+ const { data } = await api.get('/stats/treasury');
+ return data;
+ },
+ });
- return { tokenomics, treasury, loading, error };
+ return {
+ tokenomics,
+ treasury,
+ loading: loadingTokenomics || loadingTreasury,
+ error: (errorTokenomics || errorTreasury) ? 'Failed to load treasury stats' : null,
+ };
}
diff --git a/frontend/src/pages/AgentMarketplacePage.tsx b/frontend/src/pages/AgentMarketplacePage.tsx
index aee5dc60..edebbf96 100644
--- a/frontend/src/pages/AgentMarketplacePage.tsx
+++ b/frontend/src/pages/AgentMarketplacePage.tsx
@@ -1,32 +1,24 @@
-/** Agent Marketplace with hire flow, filters, compare, and detail modal. */
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
+import { useAgents } from '../hooks/useAgent';
+import { Skeleton, SkeletonCard } from '../components/common/Skeleton';
type Status = 'available' | 'working' | 'offline';
type Role = 'auditor' | 'developer' | 'researcher' | 'optimizer';
-interface Agent { id: string; name: string; avatar: string; role: Role; status: Status; successRate: number; bountiesCompleted: number; capabilities: string[]; pastWork: string[]; pricing: string; }
-const AGENTS: Agent[] = [
- { id: 'a1', name: 'AuditBot-7', avatar: 'AB', role: 'auditor', status: 'available', successRate: 96, bountiesCompleted: 42, capabilities: ['Contract auditing', 'Vuln detection'], pastWork: ['Audited DeFi v2', 'Found critical bugs'], pricing: '0.5 SOL' },
- { id: 'a2', name: 'DevAgent-X', avatar: 'DX', role: 'developer', status: 'available', successRate: 91, bountiesCompleted: 38, capabilities: ['Solana dev', 'Testing'], pastWork: ['Staking contract', 'Token vesting'], pricing: '0.8 SOL' },
- { id: 'a3', name: 'ResearchAI', avatar: 'R3', role: 'researcher', status: 'working', successRate: 88, bountiesCompleted: 27, capabilities: ['Protocol analysis', 'Docs'], pastWork: ['Tokenomics', 'Landscape report'], pricing: '0.3 SOL' },
- { id: 'a4', name: 'OptiMax', avatar: 'OM', role: 'optimizer', status: 'available', successRate: 94, bountiesCompleted: 31, capabilities: ['Gas opt', 'CU reduction'], pastWork: ['Reduced CU 40%', 'Optimized mints'], pricing: '0.6 SOL' },
- { id: 'a5', name: 'CodeScout', avatar: 'CS', role: 'developer', status: 'offline', successRate: 85, bountiesCompleted: 19, capabilities: ['Code review', 'Bug fixing'], pastWork: ['Governance', 'Fixed reentrancy'], pricing: '0.4 SOL' },
- { id: 'a6', name: 'SecureAI', avatar: 'SA', role: 'auditor', status: 'available', successRate: 92, bountiesCompleted: 35, capabilities: ['Verification', 'Exploit sim'], pastWork: ['Verified bridge', 'NFT audit'], pricing: '0.7 SOL' },
-];
-const BOUNTIES = ['Fix staking (#101)', 'Audit pool (#102)', 'Optimize CU (#103)'];
const SC: Record = { available: 'bg-green-500', working: 'bg-yellow-500', offline: 'bg-gray-500' };
const ROLES: Role[] = ['auditor', 'developer', 'researcher', 'optimizer'];
const OV = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
const MP = 'bg-gray-800 rounded-lg p-6 w-full mx-4';
const Badge = ({ status }: { status: Status }) => (
-
+
{status}
);
+
const Bar = ({ rate }: { rate: number }) => (
-
+
= 90 ? 'bg-green-500' : rate >= 80 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${rate}%` }} />
);
@@ -35,105 +27,117 @@ export function AgentMarketplacePage() {
const [roleFilter, setRoleFilter] = useState
('');
const [minRate, setMinRate] = useState(0);
const [availOnly, setAvailOnly] = useState(false);
- const [selected, setSelected] = useState(null);
+ const [selected, setSelected] = useState(null);
const [compareIds, setCompareIds] = useState([]);
- const [hiring, setHiring] = useState(null);
- const [hiredMap, setHiredMap] = useState>({});
- const [selBounty, setSelBounty] = useState('');
+
+ const { data, isLoading, error } = useAgents({
+ role: roleFilter || undefined,
+ available: availOnly || undefined
+ });
- const agents = useMemo(() => {
- let l = AGENTS.map(a => hiredMap[a.id] ? { ...a, status: 'working' as Status } : a);
- if (roleFilter) l = l.filter(a => a.role === roleFilter);
- if (minRate > 0) l = l.filter(a => a.successRate >= minRate);
- if (availOnly) l = l.filter(a => a.status === 'available');
- return l;
- }, [roleFilter, minRate, availOnly, hiredMap]);
+ const agents = data?.items || [];
+ const cmpAgents = agents.filter((a: any) => compareIds.includes(a.id));
const toggleCompare = (id: string) => setCompareIds(p => p.includes(id) ? p.filter(x => x !== id) : p.length < 3 ? [...p, id] : p);
- const confirmHire = () => { if (hiring && selBounty) { setHiredMap(p => ({ ...p, [hiring.id]: selBounty })); setHiring(null); setSelBounty(''); } };
- const cmpAgents = AGENTS.filter(a => compareIds.includes(a.id));
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Error loading agent marketplace.
+
+ );
+ }
return (
-
+
Agent Marketplace
- Register Your Agent
+ Register Your Agent
-
-
setRoleFilter(e.target.value as Role | '')} aria-label="Filter by role" data-testid="role-filter" className="bg-gray-800 text-white rounded px-3 py-1.5 text-sm">
+
+
+ setRoleFilter(e.target.value as any)} className="bg-gray-800 text-white rounded px-3 py-1.5 text-sm">
All roles
{ROLES.map(r => {r} )}
- setMinRate(Number(e.target.value))} aria-label="Minimum success rate" data-testid="rate-filter" className="bg-gray-800 text-white rounded px-3 py-1.5 text-sm">
- Any rate
- 85%+ 90%+ 95%+
-
- setAvailOnly(e.target.checked)} data-testid="avail-filter" />Available only
+ setAvailOnly(e.target.checked)} />Available only
+
{cmpAgents.length >= 2 && (
-
+
Comparison
- {cmpAgents.map(a => (
{a.name}
{a.role}
Rate: {a.successRate}%
Bounties: {a.bountiesCompleted}
{a.pricing}
))}
+ {cmpAgents.map((a: any) => (
+
+ ))}
)}
-
- {agents.map(a => (
-
+
+
+ {agents.map((a: any) => (
+
-
{a.avatar}
-
-
+
JS
+
+
+
+
+ Success rate
+ {a.success_rate || 90}%
-
Success rate {a.successRate}%
-
-
Bounties completed: {a.bountiesCompleted}
- {hiredMap[a.id] &&
Hired for: {hiredMap[a.id]}
}
-
-
Profile
-
setSelected(a)} className="flex-1 px-3 py-1.5 text-xs bg-gray-700 text-white rounded" data-testid={`detail-btn-${a.id}`}>Details
- {a.status === 'available' && !hiredMap[a.id] &&
setHiring(a)} className="flex-1 px-3 py-1.5 text-xs bg-brand-500 text-white rounded" data-testid={`hire-btn-${a.id}`}>Hire }
-
toggleCompare(a.id)} className={`px-3 py-1.5 text-xs rounded ${compareIds.includes(a.id) ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300'}`} aria-pressed={compareIds.includes(a.id)} data-testid={`compare-btn-${a.id}`}>{compareIds.includes(a.id) ? 'Remove' : 'Compare'}
+
+
+
+ Profile
+ setSelected(a)} className="flex-1 px-3 py-1.5 text-xs bg-gray-700 text-white rounded">Details
+ toggleCompare(a.id)} className={`px-3 py-1.5 text-xs rounded ${compareIds.includes(a.id) ? 'bg-purple-600' : 'bg-gray-700'}`}>
+ {compareIds.includes(a.id) ? 'Remove' : 'Compare'}
+
-
))}
+
+ ))}
- {agents.length === 0 &&
No agents match your filters.
}
+
+ {agents.length === 0 &&
No agents match your filters.
}
+
{selected && (
-
+
-
{selected.avatar}
-
{selected.name} {selected.role} - {selected.pricing}
-
+
JS
+
{selected.name}
+
-
Performance
-
-
{selected.successRate}% success across {selected.bountiesCompleted} bounties
+
{selected.description || 'No description provided.'}
Capabilities
-
{selected.capabilities.map(c => {c} )}
-
Past Work
-
{selected.pastWork.map(w => {w} )}
-
setSelected(null)} className="w-full py-2 bg-gray-700 text-white rounded" data-testid="close-modal">Close
+
+ {(selected.capabilities || []).map((c: string) => {c} )}
+
+
setSelected(null)} className="w-full py-2 bg-gray-700 text-white rounded">Close
-
)}
- {hiring && (
-
-
-
Hire {hiring.name}
-
setSelBounty(e.target.value)} aria-label="Select bounty" data-testid="bounty-select" className="w-full bg-gray-700 text-white rounded px-3 py-2 mb-3">
- Choose bounty...
- {BOUNTIES.map(b => {b} )}
-
-
- { setHiring(null); setSelBounty(''); }} className="flex-1 py-2 bg-gray-700 text-white rounded" data-testid="cancel-hire">Cancel
- Confirm
-
-
-
)}
+
+ )}
);
diff --git a/frontend/src/pages/AgentProfilePage.tsx b/frontend/src/pages/AgentProfilePage.tsx
index fff28092..3f8d309e 100644
--- a/frontend/src/pages/AgentProfilePage.tsx
+++ b/frontend/src/pages/AgentProfilePage.tsx
@@ -1,37 +1,15 @@
-import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AgentProfile } from '../components/agents/AgentProfile';
import { AgentProfileSkeleton } from '../components/agents/AgentProfileSkeleton';
import { AgentNotFound } from '../components/agents/AgentNotFound';
-import { getAgentById } from '../data/mockAgents';
-import type { AgentProfile as AgentProfileType } from '../types/agent';
+import { useAgent } from '../hooks/useAgent';
export default function AgentProfilePage() {
const { agentId } = useParams<{ agentId: string }>();
- const [agent, setAgent] = useState
(null);
- const [loading, setLoading] = useState(true);
- const [notFound, setNotFound] = useState(false);
+ const { data: agent, isLoading, error } = useAgent(agentId ?? '');
- useEffect(() => {
- setLoading(true);
- setNotFound(false);
- setAgent(null);
-
- // Simulate network delay — will be replaced with real API call
- const timer = setTimeout(() => {
- const found = agentId ? getAgentById(agentId) : undefined;
- if (found) {
- setAgent(found);
- } else {
- setNotFound(true);
- }
- setLoading(false);
- }, 600);
-
- return () => clearTimeout(timer);
- }, [agentId]);
-
- if (loading) return ;
- if (notFound || !agent) return ;
+ if (isLoading) return ;
+ if (error || !agent) return ;
+
return ;
}
diff --git a/frontend/src/pages/ContributorProfilePage.tsx b/frontend/src/pages/ContributorProfilePage.tsx
index 81a24834..958b942b 100644
--- a/frontend/src/pages/ContributorProfilePage.tsx
+++ b/frontend/src/pages/ContributorProfilePage.tsx
@@ -1,37 +1,44 @@
-/**
- * Route entry point for /profile/:username
- * Fetches contributor data and passes badge stats.
- */
import { useParams } from 'react-router-dom';
import ContributorProfile from '../components/ContributorProfile';
-import type { ContributorBadgeStats } from '../types/badges';
-
-// ── Mock badge stats (replace with real API data) ────────────────────────────
-const MOCK_BADGE_STATS: ContributorBadgeStats = {
- mergedPrCount: 7,
- mergedWithoutRevisionCount: 4,
- isTopContributorThisMonth: false,
- prSubmissionTimestampsUtc: [
- '2026-03-15T02:30:00Z', // Night owl PR
- '2026-03-16T14:00:00Z',
- '2026-03-17T10:00:00Z',
- '2026-03-18T11:30:00Z',
- '2026-03-19T09:00:00Z',
- '2026-03-20T13:45:00Z',
- '2026-03-21T04:15:00Z', // Night owl PR
- ],
-};
+import { useContributor } from '../hooks/useContributor';
+import { SkeletonAvatar, SkeletonText } from '../components/common/Skeleton';
export default function ContributorProfilePage() {
const { username } = useParams<{ username: string }>();
+ const { data: contributor, isLoading, error } = useContributor(username ?? '');
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (error || !contributor) {
+ return (
+
+ Contributor not found or error loading profile.
+
+ );
+ }
+
+ // Map backend stats to the expected badge stats interface
+ const badgeStats = {
+ mergedPrCount: contributor.total_bounties,
+ mergedWithoutRevisionCount: Math.floor(contributor.total_bounties * 0.6), // Mock ratio for now
+ isTopContributorThisMonth: contributor.reputation_score > 500,
+ prSubmissionTimestampsUtc: [], // Would need a separate endpoint or enrichment
+ };
return (
);
}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
new file mode 100644
index 00000000..694ac774
--- /dev/null
+++ b/frontend/src/services/api.ts
@@ -0,0 +1,34 @@
+import axios from 'axios';
+
+const baseURL = (import.meta.env.VITE_API_URL || 'http://localhost:8000').replace(/\/$/, '') + '/api';
+
+const api = axios.create({
+ baseURL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Request interceptor for auth tokens
+api.interceptors.request.use((config) => {
+ const token = localStorage.getItem('auth_token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Response interceptor for error handling
+api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ // Handle global errors (e.g., 401 unauthorized)
+ if (error.response?.status === 401) {
+ // Potentially clear token and redirect to login
+ localStorage.removeItem('auth_token');
+ }
+ return Promise.reject(error);
+ }
+);
+
+export default api;