diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9fce85c0..0f37731f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,12 +3,20 @@ * All pages wrapped in WalletProvider + SiteLayout. * @module App */ -import { lazy, Suspense } from 'react'; +import React, { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { useWallet } from '@solana/wallet-adapter-react'; import { WalletProvider } from './components/wallet/WalletProvider'; import { SiteLayout } from './components/layout/SiteLayout'; +/** Catches render errors with retry. */ +class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { + state = { error: null as Error | null }; + static getDerivedStateFromError(e: Error) { return { error: e }; } + componentDidCatch(e: Error) { console.error('[ErrorBoundary]', e); } + render() { const err = this.state.error; if (!err) return this.props.children; return (

Something went wrong

{err.message}

); } +} + // ── Lazy-loaded page components ────────────────────────────────────────────── const BountiesPage = lazy(() => import('./pages/BountiesPage')); const BountyDetailPage = lazy(() => import('./pages/BountyDetailPage')); @@ -46,6 +54,7 @@ function AppLayout() { onConnectWallet={() => connect().catch(console.error)} onDisconnectWallet={() => disconnect().catch(console.error)} > + }> {/* Bounties */} @@ -73,6 +82,7 @@ function AppLayout() { } /> + ); } diff --git a/frontend/src/components/ContributorDashboard.tsx b/frontend/src/components/ContributorDashboard.tsx index ad91d733..0b8a6c0d 100644 --- a/frontend/src/components/ContributorDashboard.tsx +++ b/frontend/src/components/ContributorDashboard.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useCallback, useEffect } from 'react'; +import { apiClient } from '../services/apiClient'; // ============================================================================ // Types @@ -57,83 +58,20 @@ interface ContributorDashboardProps { } // ============================================================================ -// Mock Data +// Data Fetcher — Real API with empty-state fallback // ============================================================================ -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 }[]; -} - +interface DashboardData { stats: DashboardStats; bounties: Bounty[]; activities: Activity[]; notifications: Notification[]; earnings: EarningsData[]; linkedAccounts: { type: string; username: string; connected: boolean }[]; } +const ES: DashboardStats = { totalEarned: 0, activeBounties: 0, pendingPayouts: 0, reputationRank: 0, totalContributors: 0 }; +async function safe(ep: string, p?: Record): Promise { try { return await apiClient(ep, { params: p, retries: 0 }); } catch { return null; } } 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, - }; + const d: DashboardData = { stats: { ...ES }, bounties: [], activities: [], notifications: [], earnings: [], linkedAccounts: [] }; + const [bRaw, nRaw, lRaw] = await Promise.all([safe<{ items?: unknown[] }>('/api/bounties', { claimed_by: userId ?? '', limit: 10 }), safe<{ items?: unknown[] }>('/api/notifications', { limit: 10 }), safe('/api/leaderboard', { range: 'all', limit: 50 })]); + if (bRaw) { const items = (Array.isArray(bRaw) ? bRaw : (bRaw.items ?? [])) as Record[]; d.bounties = items.map((b) => ({ id: String(b.id ?? ''), title: String(b.title ?? ''), reward: Number(b.reward_amount ?? b.reward ?? 0), deadline: String(b.deadline ?? ''), status: String(b.status ?? 'claimed') as Bounty['status'], progress: Number(b.progress ?? 0) })); } + if (nRaw) { const items = (Array.isArray(nRaw) ? nRaw : (nRaw.items ?? [])) as Record[]; d.notifications = items.map((n) => ({ id: String(n.id ?? ''), type: String(n.type ?? 'info') as Notification['type'], title: String(n.title ?? ''), message: String(n.message ?? ''), timestamp: String(n.created_at ?? n.timestamp ?? ''), read: Boolean(n.read ?? false) })); } + if (Array.isArray(lRaw)) { d.stats.totalContributors = lRaw.length; const me = (lRaw as Record[]).find((e) => String(e.username ?? '').toLowerCase() === (userId ?? '').toLowerCase()); if (me) { d.stats.totalEarned = Number(me.earningsFndry ?? 0); d.stats.reputationRank = Number(me.rank ?? 0); } } + d.stats.activeBounties = d.bounties.length; + return d; } // ============================================================================ diff --git a/frontend/src/hooks/useBountyBoard.ts b/frontend/src/hooks/useBountyBoard.ts index 83c47db6..43c46c64 100644 --- a/frontend/src/hooks/useBountyBoard.ts +++ b/frontend/src/hooks/useBountyBoard.ts @@ -1,10 +1,8 @@ +/** Bounty fetching via apiClient with search + fallback. @module hooks/useBountyBoard */ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; 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 { apiClient } from '../services/apiClient'; const TIER_MAP: Record = { 1: 'T1', 2: 'T2', 3: 'T3' }; const STATUS_MAP: Record = { @@ -93,7 +91,7 @@ function applyLocalFilters(all: Bounty[], f: BountyBoardFilters, sortBy: BountyS } export function useBountyBoard() { - const [allBounties, setAllBounties] = useState(mockBounties); + const [allBounties, setAllBounties] = useState([]); const [apiResults, setApiResults] = useState<{ items: Bounty[]; total: number } | null>(null); const [loading, setLoading] = useState(false); const [filters, setFilters] = useState(DEFAULT_FILTERS); @@ -120,25 +118,18 @@ export function useBountyBoard() { 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(); + const data = await apiClient(`/api/bounties/search?${params}`, { signal: ctrl.signal }); setApiResults({ items: data.items.map(mapApiBounty), total: data.total }); - } catch (e: any) { - if (e.name === 'AbortError') return; + } catch (e: unknown) { + if (e instanceof DOMException && 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 */ } + const d = await apiClient<{ items?: unknown[] }>('/api/bounties?limit=100', { retries: 0 }); + const items = (d.items || d) as unknown[]; + if (Array.isArray(items) && items.length > 0) setAllBounties(items.map(mapApiBounty)); + } catch { /* API unavailable */ } } finally { if (!ctrl.signal.aborted) setLoading(false); } @@ -161,9 +152,9 @@ export function useBountyBoard() { useEffect(() => { (async () => { try { - const res = await fetch('/api/bounties/hot?limit=6'); - if (res.ok) setHotBounties((await res.json()).map(mapApiBounty)); - } catch { /* ignore */ } + const d = await apiClient('/api/bounties/hot?limit=6', { retries: 0, cacheTtl: 60_000 }); + setHotBounties(d.map(mapApiBounty)); + } catch { /* API unavailable */ } })(); }, []); @@ -172,9 +163,9 @@ export function useBountyBoard() { 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 */ } + const d = await apiClient(`/api/bounties/recommended?skills=${skills.join(',')}&limit=6`, { retries: 0, cacheTtl: 60_000 }); + setRecommendedBounties(d.map(mapApiBounty)); + } catch { /* API unavailable */ } })(); }, [filters.skills]); diff --git a/frontend/src/hooks/useLeaderboard.ts b/frontend/src/hooks/useLeaderboard.ts index 74f3974a..f7a97891 100644 --- a/frontend/src/hooks/useLeaderboard.ts +++ b/frontend/src/hooks/useLeaderboard.ts @@ -105,8 +105,8 @@ export function useLeaderboard() { if (!c && contribs.length > 0) { setContributors(contribs); } - } catch (e) { - if (!c) setError(e instanceof Error ? e.message : 'Failed'); + } catch (e: unknown) { + if (!c) setError(e instanceof Error ? e.message : 'Failed to load leaderboard'); } finally { if (!c) setLoading(false); } diff --git a/frontend/src/hooks/useTreasuryStats.ts b/frontend/src/hooks/useTreasuryStats.ts index 0b671d54..217e7ed6 100644 --- a/frontend/src/hooks/useTreasuryStats.ts +++ b/frontend/src/hooks/useTreasuryStats.ts @@ -1,30 +1,30 @@ +/** Tokenomics + treasury via apiClient with error surfacing. @module hooks/useTreasuryStats */ import { useState, useEffect } from 'react'; import type { TokenomicsData, TreasuryStats } from '../types/tokenomics'; -import { MOCK_TOKENOMICS, MOCK_TREASURY } from '../data/mockTokenomics'; +import { apiClient } from '../services/apiClient'; -/** - * 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. - */ +const now = () => new Date().toISOString(); +const DEF_T: TokenomicsData = { tokenName: 'FNDRY', tokenCA: 'C2TvY8E8B75EF2UP8cTpTp3EDUjTgjWmpaGnT74VBAGS', totalSupply: 1e9, circulatingSupply: 0, treasuryHoldings: 0, totalDistributed: 0, totalBuybacks: 0, totalBurned: 0, feeRevenueSol: 0, lastUpdated: now(), distributionBreakdown: {} }; +const DEF_TR: TreasuryStats = { solBalance: 0, fndryBalance: 0, treasuryWallet: '57uMiMHnRJCxM7Q1MdGVMLsEtxzRiy1F6qKFWyP1S9pp', totalPaidOutFndry: 0, totalPaidOutSol: 0, totalPayouts: 0, totalBuybackAmount: 0, totalBuybacks: 0, lastUpdated: now() }; + +/** Fetches tokenomics + treasury with error surfacing. */ export function useTreasuryStats() { - const [tokenomics, setTokenomics] = useState(MOCK_TOKENOMICS); - const [treasury, setTreasury] = useState(MOCK_TREASURY); + const [tokenomics, setTokenomics] = useState(DEF_T); + const [treasury, setTreasury] = useState(DEF_TR); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - let cancelled = false; + let c = 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); } + const [tD, trD] = await Promise.all([apiClient('/api/payouts/tokenomics', { retries: 1, cacheTtl: 30_000 }), apiClient('/api/payouts/treasury', { retries: 1, cacheTtl: 30_000 })]); + if (!c) { setTokenomics(tD); setTreasury(trD); setError(null); } + } catch (e: unknown) { + if (!c) setError(e instanceof Error ? e.message : String((e as Record)?.message ?? 'Failed to load')); + } finally { if (!c) setLoading(false); } })(); - return () => { cancelled = true; }; + return () => { c = true; }; }, []); return { tokenomics, treasury, loading, error }; diff --git a/frontend/src/pages/AgentProfilePage.tsx b/frontend/src/pages/AgentProfilePage.tsx index fff28092..c03afadb 100644 --- a/frontend/src/pages/AgentProfilePage.tsx +++ b/frontend/src/pages/AgentProfilePage.tsx @@ -1,11 +1,26 @@ +/** Route for /agents/:agentId via apiClient. @module pages/AgentProfilePage */ 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 { apiClient } from '../services/apiClient'; import type { AgentProfile as AgentProfileType } from '../types/agent'; +/** Map API response to AgentProfile. */ +function mapAgent(r: Record): AgentProfileType { + const cb = Array.isArray(r.completed_bounties) ? r.completed_bounties as Record[] : []; + return { + id: String(r.id ?? ''), name: String(r.name ?? ''), avatar: String(r.avatar ?? r.avatar_url ?? ''), + role: (r.role as AgentProfileType['role']) ?? 'developer', status: (r.status as AgentProfileType['status']) ?? 'offline', + bio: String(r.bio ?? r.description ?? ''), skills: (r.skills ?? []) as string[], languages: (r.languages ?? []) as string[], + bountiesCompleted: Number(r.bounties_completed ?? 0), successRate: Number(r.success_rate ?? 0), + avgReviewScore: Number(r.avg_review_score ?? 0), totalEarned: Number(r.total_earned ?? 0), + completedBounties: cb.map(b => ({ id: String(b.id ?? ''), title: String(b.title ?? ''), completedAt: String(b.completed_at ?? ''), score: Number(b.score ?? 0), reward: Number(b.reward ?? 0), currency: '$FNDRY' })), + joinedAt: String(r.joined_at ?? r.created_at ?? ''), + }; +} + export default function AgentProfilePage() { const { agentId } = useParams<{ agentId: string }>(); const [agent, setAgent] = useState(null); @@ -13,22 +28,15 @@ export default function AgentProfilePage() { const [notFound, setNotFound] = useState(false); 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); + if (!agentId) { setNotFound(true); setLoading(false); return; } + setLoading(true); setNotFound(false); setAgent(null); + (async () => { + try { + const data = await apiClient>(`/api/agents/${agentId}`, { retries: 1 }); + setAgent(mapAgent(data)); + } catch { setNotFound(true); } + finally { setLoading(false); } + })(); }, [agentId]); if (loading) return ; diff --git a/frontend/src/pages/ContributorProfilePage.tsx b/frontend/src/pages/ContributorProfilePage.tsx index 2ead6009..d99c0158 100644 --- a/frontend/src/pages/ContributorProfilePage.tsx +++ b/frontend/src/pages/ContributorProfilePage.tsx @@ -1,8 +1,40 @@ -/** Route entry point for /profile/:username */ +/** Route for /profile/:username — exact lookup via apiClient, resets on route change. */ +import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import ContributorProfile from '../components/ContributorProfile'; +import { SkeletonCard } from '../components/common/Skeleton'; +import { apiClient } from '../services/apiClient'; + +interface ContributorData { username: string; avatar_url?: string; wallet_address?: string; total_earned?: number; bounties_completed?: number; reputation_score?: number; } export default function ContributorProfilePage() { const { username } = useParams<{ username: string }>(); - return ; + const [p, setP] = useState(null); + const [loading, setL] = useState(true); + + useEffect(() => { + setP(null); setL(true); + if (!username) { setL(false); return; } + let ok = true; + (async () => { + try { + const data = await apiClient(`/api/contributors/${encodeURIComponent(username)}`, { retries: 1 }); + if (ok) setP(data); + } catch { if (ok) setP({ username }); } + finally { if (ok) setL(false); } + })(); + return () => { ok = false; }; + }, [username]); + + if (loading) return
; + return ( + + ); } diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 00000000..9f59acf3 --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,31 @@ +/** API client with auth, retry, and TTL cache (keyed by URL + auth). @module services/apiClient */ +export interface ApiError { status: number; message: string; code: string; } +const BASE: string = (import.meta.env?.VITE_API_URL as string) || ''; +let token: string | null = null; +/** Set/clear JWT. Clears cache to prevent cross-auth leaks. */ +export function setAuthToken(t: string | null): void { token = t; cache.clear(); } +export function getAuthToken(): string | null { return token; } +const cache = new Map(); +export function clearApiCache(): void { cache.clear(); } +export function isApiError(e: unknown): e is ApiError { return typeof e === 'object' && e !== null && 'status' in e && 'message' in e; } +function ck(url: string): string { return token ? `${token.slice(-8)}:${url}` : url; } +export async function apiClient(ep: string, o: RequestInit & { params?: Record; retries?: number; cacheTtl?: number; body?: unknown } = {}): Promise { + const { params, retries = 2, cacheTtl = 0, body, headers: hx, ...r } = o; + let url = `${BASE}${ep}`; + if (params) { const s = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== '') s.set(k, String(v)); const q = s.toString(); if (q) url += `?${q}`; } + const m = (r.method ?? (body ? 'POST' : 'GET')).toUpperCase(); + const key = ck(url); + if (m === 'GET' && cacheTtl > 0) { const c = cache.get(key); if (c && c.e > Date.now()) return c.d as T; } + const h: Record = { 'Content-Type': 'application/json', ...(hx as Record) }; + if (token) h['Authorization'] = `Bearer ${token}`; + let last: ApiError = { status: 0, message: 'Request failed', code: 'UNKNOWN' }; + for (let i = 0; i <= retries; i++) { + try { + const res = await fetch(url, { ...r, method: m, headers: h, body: body ? JSON.stringify(body) : undefined }); + if (!res.ok) { let b: Record = {}; try { b = await res.json(); } catch { /* */ } const err: ApiError = { status: res.status, message: b.message ?? b.detail ?? res.statusText, code: b.code ?? `HTTP_${res.status}` }; if (res.status < 500 && res.status !== 429) throw err; last = err; } + else { const d = (await res.json()) as T; if (m === 'GET' && cacheTtl > 0) cache.set(key, { d, e: Date.now() + cacheTtl }); return d; } + } catch (e: unknown) { if (isApiError(e) && e.status > 0 && e.status < 500 && e.status !== 429) throw e; last = isApiError(e) ? e : { status: 0, message: e instanceof Error ? e.message : 'Network error', code: 'NETWORK_ERROR' }; } + if (i < retries) await new Promise(w => setTimeout(w, 300 * 2 ** i)); + } + throw last; +}