Skip to content

Commit c4378d4

Browse files
authored
Merge pull request #701 from Purity-Euphemia/work-flow200
feat: add contributor profile page (#591)
2 parents df578e2 + 6c56b8e commit c4378d4

4 files changed

Lines changed: 475 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { Link, useParams } from 'react-router-dom';
3+
import { User, History, Award, Wallet, TrendingUp, ExternalLink } from 'lucide-react';
4+
import { AddressAvatar } from './AddressAvatar';
5+
import { CopyButton } from './CopyButton';
6+
import { EmptyState } from './EmptyState';
7+
import { getContributorProfile } from '../services/api';
8+
import type { ContributorProfile as ContributorProfileData } from '../types/campaign';
9+
10+
function formatTimestamp(ts: number): string {
11+
if (!ts) return '—';
12+
return new Date(ts * 1000).toLocaleDateString('en-US', {
13+
year: 'numeric',
14+
month: 'short',
15+
day: 'numeric',
16+
});
17+
}
18+
19+
function formatAmount(amount: number, assetCode: string): string {
20+
return `${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${assetCode}`;
21+
}
22+
23+
const STATUS_LABELS: Record<string, string> = {
24+
open: 'Open',
25+
funded: 'Funded',
26+
claimed: 'Claimed',
27+
failed: 'Failed',
28+
};
29+
30+
export function ContributorProfile() {
31+
const { address } = useParams<{ address: string }>();
32+
const [profile, setProfile] = useState<ContributorProfileData | null>(null);
33+
const [isLoading, setIsLoading] = useState(true);
34+
const [error, setError] = useState<string | null>(null);
35+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
36+
37+
const fetchProfile = useCallback(async () => {
38+
if (!address) return;
39+
try {
40+
setError(null);
41+
const data = await getContributorProfile(address);
42+
setProfile(data);
43+
} catch (err) {
44+
setError(err instanceof Error ? err.message : 'Failed to load contributor profile');
45+
} finally {
46+
setIsLoading(false);
47+
}
48+
}, [address]);
49+
50+
useEffect(() => {
51+
setIsLoading(true);
52+
setProfile(null);
53+
void fetchProfile();
54+
timerRef.current = setInterval(() => void fetchProfile(), 30_000);
55+
return () => {
56+
if (timerRef.current) clearInterval(timerRef.current);
57+
};
58+
}, [fetchProfile]);
59+
60+
if (!address) {
61+
return (
62+
<div className="app-shell">
63+
<EmptyState message="No address provided." />
64+
</div>
65+
);
66+
}
67+
68+
if (isLoading) {
69+
return (
70+
<div className="app-shell">
71+
<section className="hero animate-fade-in">
72+
<div className="hero-topline">
73+
<div>
74+
<div className="eyebrow">Contributor Profile</div>
75+
<h1>Loading...</h1>
76+
</div>
77+
</div>
78+
</section>
79+
</div>
80+
);
81+
}
82+
83+
if (error) {
84+
return (
85+
<div className="app-shell">
86+
<section className="hero animate-fade-in">
87+
<div className="hero-topline">
88+
<div>
89+
<div className="eyebrow">Contributor Profile</div>
90+
<h1>Error loading profile</h1>
91+
</div>
92+
</div>
93+
<p className="hero-copy muted">{error}</p>
94+
<button type="button" className="btn-primary" onClick={() => void fetchProfile()}>
95+
Retry
96+
</button>
97+
</section>
98+
</div>
99+
);
100+
}
101+
102+
if (profile && profile.campaignCount === 0) {
103+
return (
104+
<div className="app-shell">
105+
<section className="hero animate-fade-in">
106+
<div className="hero-topline">
107+
<div>
108+
<div className="eyebrow">Contributor Profile</div>
109+
<h1>No activity found</h1>
110+
</div>
111+
</div>
112+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
113+
<AddressAvatar address={address} size={40} />
114+
<span className="mono">{address.slice(0, 16)}...</span>
115+
<CopyButton value={address} ariaLabel="Copy address" />
116+
</div>
117+
<p className="hero-copy muted">
118+
This address hasn&apos;t backed any campaigns yet.
119+
</p>
120+
<Link to="/" className="btn-primary" style={{ display: 'inline-block', marginTop: 16 }}>
121+
Browse campaigns
122+
</Link>
123+
</section>
124+
</div>
125+
);
126+
}
127+
128+
if (!profile) return null;
129+
130+
return (
131+
<div className="app-shell">
132+
<section className="hero animate-fade-in">
133+
<div className="hero-topline">
134+
<div>
135+
<div className="eyebrow">Contributor Profile</div>
136+
<h1 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
137+
<AddressAvatar address={address} size={40} />
138+
<span className="mono" style={{ fontSize: '1.4rem' }}>
139+
{address.slice(0, 12)}...{address.slice(-4)}
140+
</span>
141+
<CopyButton value={address} ariaLabel="Copy address" />
142+
</h1>
143+
</div>
144+
</div>
145+
</section>
146+
147+
<div className="metric-grid" style={{ marginBottom: 40 }}>
148+
<div className="metric-card">
149+
<span>Total Pledged</span>
150+
<strong>{profile.totalPledged.toLocaleString()}</strong>
151+
</div>
152+
<div className="metric-card">
153+
<span>Campaigns Backed</span>
154+
<strong>{profile.campaignCount}</strong>
155+
</div>
156+
<div className="metric-card">
157+
<span>Refunded</span>
158+
<strong>{profile.refundedAmount.toLocaleString()}</strong>
159+
</div>
160+
<div className="metric-card">
161+
<span>Global Rank</span>
162+
<strong>{profile.rank > 0 ? `#${profile.rank}` : '—'}</strong>
163+
</div>
164+
</div>
165+
166+
{profile.badges.length > 0 && (
167+
<div style={{ marginBottom: 40 }}>
168+
<div className="section-heading">
169+
<h2><Award size={20} /> Badges Earned</h2>
170+
<p className="muted">{profile.badges.length} badge{profile.badges.length !== 1 ? 's' : ''}</p>
171+
</div>
172+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
173+
{profile.badges.map((badge) => (
174+
<div key={badge.name} className="card" style={{ padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 12 }}>
175+
<span style={{ fontSize: '1.5rem' }}>{badge.icon}</span>
176+
<div>
177+
<div style={{ fontWeight: 600 }}>{badge.name}</div>
178+
<div className="muted" style={{ fontSize: '0.85rem' }}>{badge.description}</div>
179+
<div className="muted" style={{ fontSize: '0.75rem', marginTop: 2 }}>
180+
Earned {formatTimestamp(badge.earnedAt)}
181+
</div>
182+
</div>
183+
</div>
184+
))}
185+
</div>
186+
</div>
187+
)}
188+
189+
<div style={{ marginBottom: 40 }}>
190+
<div className="section-heading">
191+
<h2><TrendingUp size={20} /> Backed Campaigns</h2>
192+
<p className="muted">{profile.backedCampaigns.length} campaign{profile.backedCampaigns.length !== 1 ? 's' : ''}</p>
193+
</div>
194+
{profile.backedCampaigns.length === 0 ? (
195+
<EmptyState message="No backed campaigns." />
196+
) : (
197+
<div style={{ display: 'grid', gap: 12 }}>
198+
{profile.backedCampaigns.map((campaign) => (
199+
<Link
200+
key={campaign.campaignId}
201+
to={`/campaigns/${campaign.campaignId}`}
202+
className="card"
203+
style={{ padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', textDecoration: 'none', color: 'inherit' }}
204+
>
205+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
206+
<Wallet size={18} className="muted" style={{ flexShrink: 0 }} />
207+
<div style={{ minWidth: 0 }}>
208+
<div style={{ fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
209+
{campaign.title}
210+
</div>
211+
<div className="muted" style={{ fontSize: '0.85rem' }}>
212+
Pledged {formatAmount(campaign.pledgedAmount, campaign.assetCode)} · {formatTimestamp(campaign.pledgedAt)}
213+
</div>
214+
</div>
215+
</div>
216+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
217+
<span className={`badge badge-${campaign.status}`}>{STATUS_LABELS[campaign.status]}</span>
218+
{campaign.refundedAmount > 0 && (
219+
<span className="muted" style={{ fontSize: '0.8rem' }}>
220+
Refunded {formatAmount(campaign.refundedAmount, campaign.assetCode)}
221+
</span>
222+
)}
223+
<ExternalLink size={14} className="muted" />
224+
</div>
225+
</Link>
226+
))}
227+
</div>
228+
)}
229+
</div>
230+
231+
{profile.refundHistory.length > 0 && (
232+
<div style={{ marginBottom: 40 }}>
233+
<div className="section-heading">
234+
<h2><History size={20} /> Refund History</h2>
235+
<p className="muted">{profile.refundHistory.length} refund{profile.refundHistory.length !== 1 ? 's' : ''}</p>
236+
</div>
237+
<div style={{ display: 'grid', gap: 8 }}>
238+
{profile.refundHistory.map((refund, index) => (
239+
<div
240+
key={`${refund.campaignId}-${refund.refundedAt}-${index}`}
241+
className="card"
242+
style={{ padding: '12px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
243+
>
244+
<div>
245+
<span style={{ fontWeight: 500 }}>{refund.title}</span>
246+
<span className="muted" style={{ marginLeft: 8, fontSize: '0.85rem' }}>
247+
{formatTimestamp(refund.refundedAt)}
248+
</span>
249+
</div>
250+
<span style={{ fontWeight: 600, color: 'var(--text-muted)' }}>
251+
{formatAmount(refund.amount, refund.assetCode)}
252+
</span>
253+
</div>
254+
))}
255+
</div>
256+
</div>
257+
)}
258+
259+
<div style={{ textAlign: 'center', marginTop: 40 }}>
260+
<Link to="/" className="btn-ghost">
261+
Back to campaigns
262+
</Link>
263+
</div>
264+
</div>
265+
);
266+
}
267+
268+
export default ContributorProfile;

frontend/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
22
import ReactDOM from "react-dom/client";
33
import { BrowserRouter, Routes, Route } from "react-router-dom";
44
import App from "./App";
5+
import { ContributorProfile } from "./components/ContributorProfile";
56
import { NotFoundPage } from "./components/NotFoundPage";
67
import "./index.css";
78

@@ -11,6 +12,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
1112
<Routes>
1213
<Route path="/" element={<App />} />
1314
<Route path="/campaigns/:id" element={<App />} />
15+
<Route path="/contributors/:address" element={<ContributorProfile />} />
1416
<Route path="*" element={<NotFoundPage />} />
1517
</Routes>
1618
</BrowserRouter>

0 commit comments

Comments
 (0)