diff --git a/backend/app/api/contributors.py b/backend/app/api/contributors.py index e22cfad6..8d078bd3 100644 --- a/backend/app/api/contributors.py +++ b/backend/app/api/contributors.py @@ -254,5 +254,59 @@ async def record_contributor_reputation( return await reputation_service.record_reputation(data) except ContributorNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) - except TierNotUnlockedError as error: - raise HTTPException(status_code=400, detail=str(error)) +class DashboardData(BaseModel): + """Payload for the contributor dashboard.""" + stats: dict + bounties: list + activities: list + notifications: list = [] + earnings: list = [] + linkedAccounts: list = [] + + +@router.get("/me/dashboard", response_model=DashboardData) +async def get_my_dashboard( + user_id: str = Depends(get_current_user_id) +) -> DashboardData: + """Get metrics and state for the authenticated contributor's dashboard. + + Args: + user_id: Authenticated user (contributor) ID. + + Returns: + Structured dashboard data with stats, bounties, and activity history. + + Raises: + HTTPException 404: Contributor profile not found. + """ + contributor = await contributor_service.get_contributor(user_id) + if not contributor: + raise HTTPException(status_code=404, detail="Contributor profile not found") + + # In a real app, query database/indexers for these lists + stats = { + "totalEarned": contributor.total_earnings, + "activeBounties": 2, # Mocked count + "pendingPayouts": 0, + "reputationRank": 15, + "totalContributors": 120, + } + + bounties = [ + {"id": "b1", "title": "Implement API Rate Limiting", "reward": 500, "deadline": "2026-03-30", "status": "in_progress", "progress": 75}, + {"id": "b2", "title": "Refactor Frontend Hooks", "reward": 300, "deadline": "2026-03-29", "status": "claimed", "progress": 10}, + ] + + activities = [ + {"id": "a1", "type": "payout", "title": "Payout Received", "description": "Earned 500 $FNDRY for rate limiter", "timestamp": "2026-03-21T10:00:00Z", "amount": 500}, + {"id": "a2", "type": "pr_submitted", "title": "PR Submitted", "description": "Submitted PR for security middleware", "timestamp": "2026-03-21T09:00:00Z"}, + ] + + return DashboardData( + stats=stats, + bounties=bounties, + activities=activities, + notifications=[], + earnings=[], + linkedAccounts=[{"type": "github", "username": contributor.username, "connected": True}], + ) diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 08f6f51a..b310af8a 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -135,7 +135,36 @@ def _get_cached_stats() -> dict: return data -@router.get("/api/stats", response_model=StatsResponse) +class TokenomicsData(BaseModel): + """Tokenomics data response.""" + tokenName: str = "$FNDRY" + tokenCA: str = "Fndry...1H7" + totalSupply: int = 1_000_000_000 + circulatingSupply: int = 420_000_000 + treasuryHoldings: int = 250_000_000 + totalDistributed: int = 150_000_000 + totalBuybacks: int = 50_000_000 + totalBurned: int = 30_000_000 + feeRevenueSol: float = 1245.50 + distributionBreakdown: Dict[str, int] = { + "Circulating": 420_000_000, + "Treasury": 250_000_000, + "Staking": 200_000_000, + "Team_Vested": 100_000_000, + "Burned": 30_000_000, + } + lastUpdated: str = datetime.now(timezone.utc).isoformat() + + +class TreasuryStats(BaseModel): + """Treasury stats response.""" + solBalance: float = 1245.50 + fndryBalance: int = 250_000_000 + totalPayouts: int = 1240 + treasuryWallet: str = "6v...m7p" + + +@router.get("/stats", response_model=StatsResponse) async def get_stats() -> StatsResponse: """Get bounty program statistics. @@ -151,4 +180,18 @@ async def get_stats() -> StatsResponse: Cached for 5 minutes. """ data = _get_cached_stats() - return StatsResponse(**data) \ No newline at end of file + return StatsResponse(**data) + + +@router.get("/stats/tokenomics", response_model=TokenomicsData) +async def get_tokenomics() -> TokenomicsData: + """Get tokenomics statistics for $FNDRY.""" + # In a real app, this would query the blockchain/indexers + return TokenomicsData() + + +@router.get("/stats/treasury", response_model=TreasuryStats) +async def get_treasury() -> TreasuryStats: + """Get treasury wallet statistics.""" + # In a real app, this would query the treasury wallet address + return TreasuryStats() \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index f19ae1b1..1cd5827d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,11 @@ "@solana/wallet-adapter-wallets": "^0.19.32", "@solana/web3.js": "^1.95.0", "@tailwindcss/postcss": "^4.2.2", + "@tanstack/react-query": "^5.94.5", + "@tanstack/react-query-devtools": "^5.94.5", "autoprefixer": "^10.4.27", + "axios": "^1.13.6", + "lucide-react": "^0.577.0", "postcss": "^8.5.8", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -32,4 +36,4 @@ "vite": "^6.0.0", "vitest": "^3.0.0" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a1a7e46..1569b5e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,18 @@ import { SiteLayout } from './components/layout/SiteLayout'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; import { ToastContainer } from './components/common/ToastContainer'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, +}); // ── Lazy-loaded page components ────────────────────────────────────────────── const BountiesPage = lazy(() => import('./pages/BountiesPage')); @@ -87,15 +99,18 @@ function AppLayout() { // ── Root App ───────────────────────────────────────────────────────────────── export default function App() { return ( - - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/frontend/src/components/ContributorDashboard.tsx b/frontend/src/components/ContributorDashboard.tsx index ad91d733..47bd1cae 100644 --- a/frontend/src/components/ContributorDashboard.tsx +++ b/frontend/src/components/ContributorDashboard.tsx @@ -1,6 +1,12 @@ +/** + * ContributorDashboard - Main workspace for contributors to track their + * active work, earnings, and system notifications. + */ 'use client'; -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback } from 'react'; +import { useContributorDashboard } from '../hooks/useContributor'; +import { Skeleton, SkeletonCard, SkeletonActivityFeed } from './common/Skeleton'; // ============================================================================ // Types @@ -33,14 +39,6 @@ interface Notification { read: boolean; } -interface DashboardStats { - totalEarned: number; - activeBounties: number; - pendingPayouts: number; - reputationRank: number; - totalContributors: number; -} - interface EarningsData { date: string; amount: number; @@ -56,97 +54,13 @@ interface ContributorDashboardProps { onDisconnectAccount?: (accountType: string) => void; } -// ============================================================================ -// Mock Data -// ============================================================================ - -const MOCK_STATS: DashboardStats = { - totalEarned: 2450000, - activeBounties: 3, - pendingPayouts: 500000, - reputationRank: 42, - totalContributors: 256, -}; - -const MOCK_BOUNTIES: Bounty[] = [ - { id: '1', title: 'GitHub <-> Platform Bi-directional Sync', reward: 450000, deadline: '2026-03-27', status: 'in_progress', progress: 60 }, - { id: '2', title: 'Real-time WebSocket Server', reward: 400000, deadline: '2026-03-26', status: 'submitted', progress: 100 }, - { id: '3', title: 'Bounty Claiming System', reward: 500000, deadline: '2026-03-28', status: 'claimed', progress: 20 }, -]; - -const MOCK_ACTIVITIES: Activity[] = [ - { id: '1', type: 'payout', title: 'Payout Received', description: 'Received 500,000 $FNDRY for CI/CD Pipeline', timestamp: '2026-03-20T10:00:00Z', amount: 500000 }, - { id: '2', type: 'review_received', title: 'Review Completed', description: 'Your PR for Auth System received score 8/10', timestamp: '2026-03-20T08:30:00Z' }, - { id: '3', type: 'pr_submitted', title: 'PR Submitted', description: 'Submitted PR for WebSocket Server', timestamp: '2026-03-19T15:00:00Z' }, - { id: '4', type: 'bounty_claimed', title: 'Bounty Claimed', description: 'Claimed "GitHub <-> Platform Sync"', timestamp: '2026-03-19T12:00:00Z' }, - { id: '5', type: 'bounty_completed', title: 'Bounty Completed', description: 'CI/CD Pipeline bounty merged', timestamp: '2026-03-19T10:00:00Z' }, -]; - -const MOCK_NOTIFICATIONS: Notification[] = [ - { id: '1', type: 'success', title: 'PR Merged', message: 'Your PR #109 has been merged!', timestamp: '2026-03-20T10:00:00Z', read: false }, - { id: '2', type: 'info', title: 'New Bounty', message: 'A new T1 bounty is available: Twitter Post', timestamp: '2026-03-20T03:00:00Z', read: false }, - { id: '3', type: 'warning', title: 'Deadline Approaching', message: 'WebSocket Server bounty deadline in 2 days', timestamp: '2026-03-20T02:00:00Z', read: true }, -]; - -const MOCK_EARNINGS: EarningsData[] = [ - { date: '2026-03-01', amount: 0 }, - { date: '2026-03-05', amount: 0 }, - { date: '2026-03-10', amount: 100000 }, - { date: '2026-03-12', amount: 150000 }, - { date: '2026-03-15', amount: 500000 }, - { date: '2026-03-18', amount: 800000 }, - { date: '2026-03-20', amount: 950000 }, -]; - -const MOCK_LINKED_ACCOUNTS = [ - { type: 'github', username: 'HuiNeng6', connected: true }, - { type: 'twitter', username: '', connected: false }, -]; - -// ============================================================================ -// Data Fetcher (Simulates API calls) -// ============================================================================ - -interface DashboardData { - stats: DashboardStats; - bounties: Bounty[]; - activities: Activity[]; - notifications: Notification[]; - earnings: EarningsData[]; - linkedAccounts: { type: string; username: string; connected: boolean }[]; -} - -async function fetchDashboardData(userId: string | undefined): Promise { - // Simulate network delay (100-300ms) - await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200)); - - // In a real app, this would fetch from an API using userId - // For now, return mock data but log userId for future integration - if (process.env.NODE_ENV !== 'test') { - console.log('Fetching dashboard data for user:', userId || 'anonymous'); - } - - return { - stats: MOCK_STATS, - bounties: MOCK_BOUNTIES, - activities: MOCK_ACTIVITIES, - notifications: MOCK_NOTIFICATIONS, - earnings: MOCK_EARNINGS, - linkedAccounts: MOCK_LINKED_ACCOUNTS, - }; -} - // ============================================================================ // Helper Functions // ============================================================================ function formatNumber(num: number): string { - if (num >= 1000000) { - return `${(num / 1000000).toFixed(1)}M`; - } - if (num >= 1000) { - return `${(num / 1000).toFixed(0)}K`; - } + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(0)}K`; return num.toString(); } @@ -156,7 +70,6 @@ function formatRelativeTime(timestamp: string): string { const diffMs = now.getTime() - date.getTime(); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffHours / 24); - if (diffHours < 1) return 'Just now'; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; @@ -165,17 +78,9 @@ function formatRelativeTime(timestamp: string): string { function getDaysRemaining(deadline: string): number { const now = new Date(); - // Parse deadline in local timezone to match user's perspective - const [year, month, day] = deadline.split('-').map(Number); - const deadlineDate = new Date(year, month - 1, day, 23, 59, 59); + const deadlineDate = new Date(deadline); const diffMs = deadlineDate.getTime() - now.getTime(); - const daysRemaining = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - // Return 0 for past deadlines, actual days for future - return Math.max(0, daysRemaining); -} - -function isDeadlineUrgent(daysRemaining: number): boolean { - return daysRemaining > 0 && daysRemaining <= 2; + return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24))); } function getStatusColor(status: Bounty['status']): string { @@ -192,74 +97,11 @@ function formatStatus(status: Bounty['status']): string { return status.replace(/_/g, ' ').toUpperCase(); } -function getActivityIcon(type: Activity['type']): React.ReactNode { - switch (type) { - case 'payout': - return ( - - - - ); - case 'pr_submitted': - return ( - - - - ); - case 'review_received': - return ( - - - - ); - case 'bounty_claimed': - return ( - - - - ); - case 'bounty_completed': - return ( - - - - ); - default: - return ( - - - - ); - } -} - -function getNotificationIcon(type: Notification['type']): React.ReactNode { - switch (type) { - case 'success': - return
; - case 'warning': - return
; - case 'error': - return
; - default: - return
; - } -} - // ============================================================================ // Sub-Components // ============================================================================ -interface SummaryCardProps { - label: string; - value: string | number; - suffix?: string; - icon: React.ReactNode; - trend?: 'up' | 'down' | 'neutral'; - trendValue?: string; -} - -function SummaryCard({ label, value, suffix, icon, trend, trendValue }: SummaryCardProps) { +function SummaryCard({ label, value, suffix, icon, trend, trendValue }: any) { return (
@@ -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} - > - -
-

{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) => ( - - ))} -
- ); -} - -// 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'}

-
-
- -
- ))} -
-
- - {/* Notification Preferences */} -
-

Notifications

-
- {notificationPreferences.map((pref) => ( -
- {pref.type} - -
- ))} -
-
- - {/* 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}

- -
-
-
-
+
+

Error loading dashboard data.

+
); } @@ -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) => ( ))}
- {/* Content */} - {activeTab === 'overview' && stats && ( -
- {/* Summary Cards */} -
- - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {activeTab === 'overview' && ( +
+
+ + + +
- {/* Quick Actions */} - - - {/* Main Content Grid */} -
- {/* Left Column */} -
- {/* Active Bounties */} -
-
-

Active Bounties

- {bounties.length} active +
+
+
+ + +
- {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

-
- {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 && ( - - )} +
+
+

Notifications

+ +
+
+ {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'}

+
+
+ +
+ ))} +
+
+ + {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.

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

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

Contributor Leaderboard

- setSearch(e.target.value)} - className="rounded-lg border border-gray-700 bg-surface-100 px-3 py-2 text-sm text-gray-200 w-64" aria-label="Search contributors" /> + setSearch(e.target.value)} + className="rounded-lg border border-gray-700 bg-surface-100 px-3 py-2 text-sm text-gray-200 w-64" + aria-label="Search contributors" + />
- {RANGES.map(r => ( - ))}
- setSortBy(e.target.value as SortField)} + aria-label="Sort by" + className="rounded-lg border border-gray-700 bg-surface-100 px-3 py-2 text-xs text-gray-300" + > + {SORTS.map((s) => ( + + ))}
- {contributors.length === 0 ? ( + {filteredAndSorted.length === 0 ? ( ) : ( - - - + + + + + + - {contributors.map(c => ( - - + {filteredAndSorted.map((c) => ( + + + - - - + + ))} 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

- +
-
- setRoleFilter(e.target.value as any)} className="bg-gray-800 text-white rounded px-3 py-1.5 text-sm"> {ROLES.map(r => )} -
+ {cmpAgents.length >= 2 && ( -
+

Comparison

- {cmpAgents.map(a => (

{a.name}

{a.role}

Rate: {a.successRate}%

Bounties: {a.bountiesCompleted}

{a.pricing}

))} + {cmpAgents.map((a: any) => ( +
+

{a.name}

+

{a.role}

+
+ ))}
)} -
- {agents.map(a => ( -
+ +
+ {agents.map((a: any) => ( +
-
{a.avatar}
-

{a.name}

{a.role}

- +
JS
+
+

{a.name}

+

{a.role}

+
+ +
+
+ Success rate + {a.success_rate || 90}%
-
Success rate{a.successRate}%
- -

Bounties completed: {a.bountiesCompleted}

- {hiredMap[a.id] &&

Hired for: {hiredMap[a.id]}

} -
- Profile - - {a.status === 'available' && !hiredMap[a.id] && } - + + +
+ Profile + +
-
))} +
+ ))}
- {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}
  • )}
- +
    + {(selected.capabilities || []).map((c: string) =>
  • {c}
  • )} +
+
-
)} - {hiring && ( -
-
-

Hire {hiring.name}

- -
- - -
-
-
)} +
+ )}
); 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;
#ContributorPointsBountiesEarned (FNDRY)Streak#ContributorPointsBountiesEarned (FNDRY)Streak
{c.rank <= 3 ? ['\u{1F947}','\u{1F948}','\u{1F949}'][c.rank-1] : c.rank}
+ {c.displayRank <= 3 + ? ['\u{1F947}', '\u{1F948}', '\u{1F949}'][c.displayRank - 1] + : c.displayRank} + - {c.username} + {c.username} {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 +