Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<div className="flex flex-col items-center justify-center min-h-[40vh] gap-4 p-8" role="alert"><p className="text-lg font-semibold text-white">Something went wrong</p><p className="text-sm text-gray-400 text-center max-w-md">{err.message}</p><button onClick={() => this.setState({ error: null })} className="px-4 py-2 rounded-lg bg-[#9945FF]/20 text-[#9945FF] hover:bg-[#9945FF]/30 text-sm">Try again</button></div>); }
}

// ── Lazy-loaded page components ──────────────────────────────────────────────
const BountiesPage = lazy(() => import('./pages/BountiesPage'));
const BountyDetailPage = lazy(() => import('./pages/BountyDetailPage'));
Expand Down Expand Up @@ -46,6 +54,7 @@ function AppLayout() {
onConnectWallet={() => connect().catch(console.error)}
onDisconnectWallet={() => disconnect().catch(console.error)}
>
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
{/* Bounties */}
Expand Down Expand Up @@ -73,6 +82,7 @@ function AppLayout() {
<Route path="*" element={<Navigate to="/bounties" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
</SiteLayout>
);
}
Expand Down
86 changes: 12 additions & 74 deletions frontend/src/components/ContributorDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import React, { useState, useCallback, useEffect } from 'react';
import { apiClient } from '../services/apiClient';

// ============================================================================
// Types
Expand Down Expand Up @@ -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<T>(ep: string, p?: Record<string, string | number | boolean | undefined>): Promise<T | null> { try { return await apiClient<T>(ep, { params: p, retries: 0 }); } catch { return null; } }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Error swallowing prevents debugging and user feedback.

The safe() wrapper catches all errors and returns null, making it impossible to distinguish between "API returned empty data" vs "API is down" vs "user is unauthorized." Consider returning a result type or at least logging errors.

♻️ Alternative with error visibility
type Result<T> = { ok: true; data: T } | { ok: false; error: unknown };
async function safe<T>(ep: string, p?: Record<string, string | number | boolean | undefined>): Promise<Result<T>> {
  try {
    return { ok: true, data: await apiClient<T>(ep, { params: p, retries: 0 }) };
  } catch (error) {
    console.warn(`[Dashboard] ${ep} failed:`, error);
    return { ok: false, error };
  }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ContributorDashboard.tsx` at line 66, The safe
function currently swallows all errors and returns null which hides API
failures; change safe<T>(...) to return a discriminated Result type (e.g., { ok:
true; data: T } | { ok: false; error: unknown }) instead of T | null, call
apiClient<T>(...) inside try and return { ok: true, data }, and in catch log the
error (include endpoint and params) and return { ok: false, error } so callers
can distinguish empty data from network/authorization errors; update code paths
that call safe to handle the Result shape.

async function fetchDashboardData(userId: string | undefined): Promise<DashboardData> {
// 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<unknown[]>('/api/leaderboard', { range: 'all', limit: 50 })]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty claimed_by param sent when userId is undefined.

When userId is undefined, the code sends claimed_by: '' which may cause unexpected backend behavior. Consider skipping the bounties call entirely for anonymous users, or verify the backend handles empty claimed_by gracefully.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ContributorDashboard.tsx` at line 69, The bounties
request currently sends claimed_by: '' when userId is undefined; update the
Promise.all call in ContributorDashboard (the const [bRaw, nRaw, lRaw] = await
Promise.all(...) line) to avoid sending an empty claimed_by: either build the
requests array conditionally (only push safe('/api/bounties', { claimed_by:
userId, limit: 10 }) when userId is truthy) and call Promise.all on that array,
or replace the bounties entry with a resolved placeholder (e.g.,
Promise.resolve(null)) when userId is falsy so bRaw becomes null/empty instead
of calling the backend with an empty string; ensure you still await
notifications and leaderboard via safe(...) and adjust subsequent code that
reads bRaw accordingly.

if (bRaw) { const items = (Array.isArray(bRaw) ? bRaw : (bRaw.items ?? [])) as Record<string, unknown>[]; 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<string, unknown>[]; 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<string, unknown>[]).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;
}

// ============================================================================
Expand Down
41 changes: 16 additions & 25 deletions frontend/src/hooks/useBountyBoard.ts
Original file line number Diff line number Diff line change
@@ -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<number, 'T1' | 'T2' | 'T3'> = { 1: 'T1', 2: 'T2', 3: 'T3' };
const STATUS_MAP: Record<string, 'open' | 'in-progress' | 'completed'> = {
Expand Down Expand Up @@ -93,7 +91,7 @@ function applyLocalFilters(all: Bounty[], f: BountyBoardFilters, sortBy: BountyS
}

export function useBountyBoard() {
const [allBounties, setAllBounties] = useState<Bounty[]>(mockBounties);
const [allBounties, setAllBounties] = useState<Bounty[]>([]);
const [apiResults, setApiResults] = useState<{ items: Bounty[]; total: number } | null>(null);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<BountyBoardFilters>(DEFAULT_FILTERS);
Expand All @@ -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<SearchResponse>(`/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);
}
Expand All @@ -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<unknown[]>('/api/bounties/hot?limit=6', { retries: 0, cacheTtl: 60_000 });
setHotBounties(d.map(mapApiBounty));
} catch { /* API unavailable */ }
})();
}, []);

Expand All @@ -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<unknown[]>(`/api/bounties/recommended?skills=${skills.join(',')}&limit=6`, { retries: 0, cacheTtl: 60_000 });
setRecommendedBounties(d.map(mapApiBounty));
} catch { /* API unavailable */ }
})();
}, [filters.skills]);

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useLeaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
32 changes: 16 additions & 16 deletions frontend/src/hooks/useTreasuryStats.ts
Original file line number Diff line number Diff line change
@@ -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<TokenomicsData>(MOCK_TOKENOMICS);
const [treasury, setTreasury] = useState<TreasuryStats>(MOCK_TREASURY);
const [tokenomics, setTokenomics] = useState<TokenomicsData>(DEF_T);
const [treasury, setTreasury] = useState<TreasuryStats>(DEF_TR);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<TokenomicsData>('/api/payouts/tokenomics', { retries: 1, cacheTtl: 30_000 }), apiClient<TreasuryStats>('/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<string,unknown>)?.message ?? 'Failed to load'));
} finally { if (!c) setLoading(false); }
})();
return () => { cancelled = true; };
return () => { c = true; };
}, []);

return { tokenomics, treasury, loading, error };
Expand Down
42 changes: 25 additions & 17 deletions frontend/src/pages/AgentProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
/** 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<string, unknown>): AgentProfileType {
const cb = Array.isArray(r.completed_bounties) ? r.completed_bounties as Record<string, unknown>[] : [];
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 ?? ''),
};
}
Comment on lines +11 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unsafe type assertions for enum-like fields.

The casts (r.role as AgentProfileType['role']) ?? 'developer' and (r.status as AgentProfileType['status']) ?? 'offline' don't validate the actual values. If the API returns an unexpected value (e.g., role: "admin"), it passes through unchecked and could cause downstream rendering issues.

♻️ Safer pattern with validation
const VALID_ROLES = ['developer', 'reviewer', 'manager'] as const;
const VALID_STATUSES = ['online', 'offline', 'busy'] as const;

const role = VALID_ROLES.includes(r.role as any) ? r.role as AgentProfileType['role'] : 'developer';
const status = VALID_STATUSES.includes(r.status as any) ? r.status as AgentProfileType['status'] : 'offline';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/AgentProfilePage.tsx` around lines 11 - 22, mapAgent
currently uses unsafe casts for role and status ((r.role as
AgentProfileType['role']) and (r.status as AgentProfileType['status']) ) which
let unexpected API values through; validate values against explicit whitelists
before assigning and fall back to defaults. Add const VALID_ROLES and
VALID_STATUSES arrays, then compute role and status by checking membership
(e.g., VALID_ROLES.includes(r.role as any) ? r.role as AgentProfileType['role']
: 'developer') and similarly for status, and use those validated variables in
the returned object instead of the direct casts.


export default function AgentProfilePage() {
const { agentId } = useParams<{ agentId: string }>();
const [agent, setAgent] = useState<AgentProfileType | null>(null);
const [loading, setLoading] = useState(true);
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<Record<string, unknown>>(`/api/agents/${agentId}`, { retries: 1 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing URL encoding for agentId parameter.

Unlike ContributorProfilePage which uses encodeURIComponent(username), this page passes agentId directly into the URL path. If agentId contains special characters (e.g., /, ?), this could cause routing issues or unintended API calls.

-        const data = await apiClient<Record<string, unknown>>(`/api/agents/${agentId}`, { retries: 1 });
+        const data = await apiClient<Record<string, unknown>>(`/api/agents/${encodeURIComponent(agentId)}`, { retries: 1 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = await apiClient<Record<string, unknown>>(`/api/agents/${agentId}`, { retries: 1 });
const data = await apiClient<Record<string, unknown>>(`/api/agents/${encodeURIComponent(agentId)}`, { retries: 1 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/AgentProfilePage.tsx` at line 35, The apiClient call in
AgentProfilePage uses agentId directly in the path (const data = await
apiClient<Record<string, unknown>>(`/api/agents/${agentId}`, ...)), which can
break when agentId contains special characters; update the call to URL-encode
agentId (use encodeURIComponent(agentId)) before interpolating into the
`/api/agents/...` path—mirror the approach used in ContributorProfilePage so the
request always uses an encoded agentId.

setAgent(mapAgent(data));
} catch { setNotFound(true); }
finally { setLoading(false); }
})();
}, [agentId]);

if (loading) return <AgentProfileSkeleton />;
Expand Down
Loading
Loading