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