Skip to content

Commit 6fc1293

Browse files
Josue19-08claude
andauthored
feat: implement Public Earnings Section in Profile (#426)
* feat(types): add public earnings types Add TypeScript types for the public earnings API response: - EarningSource union type for earning categories - EarningActivity interface for individual earnings - EarningsBreakdown interface for category totals - PublicEarningsResponse interface for API response * feat(api): add public earnings API function Add getPublicEarnings function to fetch public earnings data for user profiles. This endpoint is unauthenticated and returns visibility-filtered earnings data. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(profile): add PublicEarningsTab component Add new component to display public earnings on user profiles: - Total earnings summary with prominent display - Breakdown by source (hackathons, grants, crowdfunding, bounties) - Verified activity history with timestamps - Consistent styling with existing ActivityTab component Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(profile): integrate Earnings tab in public profile Add Earnings tab to the public profile page tabs: - New tab alongside Activity and Projects - Displays PublicEarningsTab component - Extract TAB_CLASS constant to reduce code duplication - Rename authUser to authUsername for clarity Closes #391 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(types): remove id from EarningActivity per security requirements Entity IDs should not be exposed in public earnings data as specified in issue requirements. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(api): add input validation and sanitization to getPublicEarnings - Validate username is a non-empty string - Sanitize limit to range [1, 100] - Sanitize offset to be non-negative - Trim whitespace from username - Convert to const arrow function for consistency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(components): address CodeRabbit review feedback - Add AbortController to prevent race conditions in useEffect - Update formatCurrency to accept currency parameter - Use composite key for activity list (source + occurredAt + index) - Convert function declarations to const arrow functions - Remove redundant currency label display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(components): address review feedback for PublicEarningsTab - Add guard clause for undefined earnings.breakdown to prevent crash - Add fallback values for earnings.summary and earnings.activities - Use EmptyState component for error/empty states - Adjust typography to match ActivityTab and ActivityFeedPublic: - Reduce "Total Earned" from text-3xl to text-2xl - Activity titles to text-sm, metadata to text-xs - Section header with uppercase and tracking-wider style Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 621ffab commit 6fc1293

4 files changed

Lines changed: 305 additions & 19 deletions

File tree

app/(landing)/profile/[username]/profile-data.tsx

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
'use client';
22

33
import { useEffect, useState } from 'react';
4+
import { Filter } from 'lucide-react';
45
import { getUserProfileByUsername } from '@/lib/api/auth';
6+
import { authClient } from '@/lib/auth-client';
57
import { PublicUserProfile } from '@/features/projects/types';
68
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
7-
import ActivityTab from '@/components/profile/ActivityTab';
89
import {
910
DropdownMenu,
1011
DropdownMenuContent,
1112
DropdownMenuItem,
1213
DropdownMenuTrigger,
1314
} from '@/components/ui/dropdown-menu';
1415
import { Button } from '@/components/ui/button';
16+
import ActivityTab from '@/components/profile/ActivityTab';
1517
import OrganizationsTab from '@/components/profile/OrganizationsTab';
16-
import { Filter } from 'lucide-react';
17-
import { authClient } from '@/lib/auth-client';
1818
import ProfileOverviewPublic from '@/components/profile/ProfileOverviewPublic';
1919
import ProjectsTabPublic from '@/components/profile/ProjectsTabPublic';
20+
import PublicEarningsTab from '@/components/profile/PublicEarningsTab';
2021

2122
interface PublicProfileDataProps {
2223
username: string;
@@ -32,20 +33,26 @@ const FILTER_OPTIONS = [
3233
'All Time',
3334
];
3435

35-
export function ProfileData({ username }: PublicProfileDataProps) {
36+
const TAB_CLASS =
37+
'data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white';
38+
39+
export function ProfileData({
40+
username,
41+
}: PublicProfileDataProps): React.ReactElement {
3642
const [userData, setUserData] = useState<PublicUserProfile | null>(null);
3743
const [loading, setLoading] = useState(true);
3844
const [error, setError] = useState<string | null>(null);
3945
const [selectedFilter, setSelectedFilter] = useState('All');
40-
const [authUser, setAuthUser] = useState<string | null>(null);
46+
const [authUsername, setAuthUsername] = useState<string | null>(null);
4147
const [isAuthenticated, setIsAuthenticated] = useState(false);
48+
4249
useEffect(() => {
43-
async function loadProfile() {
50+
async function loadProfile(): Promise<void> {
4451
try {
52+
setLoading(true);
4553
const { data: session } = await authClient.getSession();
46-
setAuthUser(session?.user?.profile?.username || null);
54+
setAuthUsername(session?.user?.profile?.username || null);
4755
setIsAuthenticated(!!session?.user);
48-
setLoading(true);
4956
const data = await getUserProfileByUsername(username);
5057
setUserData(data);
5158
} catch (err) {
@@ -87,8 +94,7 @@ export function ProfileData({ username }: PublicProfileDataProps) {
8794
name: org.name,
8895
avatarUrl: org.logo || '/blog1.jpg',
8996
})) || [];
90-
// Determine if it's the user's own profile
91-
const isOwnProfile = isAuthenticated && authUser === userData.username;
97+
const isOwnProfile = isAuthenticated && authUsername === userData.username;
9298

9399
return (
94100
<section className='mt-14 flex flex-col gap-8 lg:flex-row lg:gap-16'>
@@ -103,21 +109,18 @@ export function ProfileData({ username }: PublicProfileDataProps) {
103109
<Tabs defaultValue='activity' className='w-full'>
104110
<div className='border-b border-zinc-800'>
105111
<TabsList className='h-auto w-full justify-start gap-6 bg-transparent p-0'>
106-
<TabsTrigger
107-
value='activity'
108-
className='data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white'
109-
>
112+
<TabsTrigger value='activity' className={TAB_CLASS}>
110113
Activity
111114
</TabsTrigger>
112-
<TabsTrigger
113-
value='projects'
114-
className='data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white'
115-
>
115+
<TabsTrigger value='projects' className={TAB_CLASS}>
116116
Projects
117117
</TabsTrigger>
118+
<TabsTrigger value='earnings' className={TAB_CLASS}>
119+
Earnings
120+
</TabsTrigger>
118121
<TabsTrigger
119122
value='organizations'
120-
className='data-[state=active]:border-primary rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white md:hidden'
123+
className={`${TAB_CLASS} md:hidden`}
121124
>
122125
Organizations
123126
</TabsTrigger>
@@ -163,6 +166,10 @@ export function ProfileData({ username }: PublicProfileDataProps) {
163166
<ProjectsTabPublic user={userData} />
164167
</TabsContent>
165168

169+
<TabsContent value='earnings' className='mt-0'>
170+
<PublicEarningsTab username={username} />
171+
</TabsContent>
172+
166173
{isAuthenticated && isOwnProfile && (
167174
<TabsContent value='organizations' className='mt-0'>
168175
<OrganizationsTab organizations={organizationsData} />
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { motion } from 'framer-motion';
5+
import {
6+
Trophy,
7+
Award,
8+
Users,
9+
Target,
10+
TrendingUp,
11+
Loader2,
12+
} from 'lucide-react';
13+
import { formatDistanceToNow } from 'date-fns';
14+
import {
15+
PublicEarningsResponse,
16+
EarningActivity,
17+
EarningSource,
18+
} from '@/types/earnings';
19+
import { getPublicEarnings } from '@/lib/api/earnings';
20+
import EmptyState from '@/components/EmptyState';
21+
22+
interface PublicEarningsTabProps {
23+
username: string;
24+
}
25+
26+
const SOURCE_CONFIG: Record<
27+
EarningSource,
28+
{ label: string; icon: typeof Trophy; color: string }
29+
> = {
30+
hackathons: { label: 'Hackathons', icon: Trophy, color: 'text-amber-400' },
31+
grants: { label: 'Grants', icon: Award, color: 'text-green-400' },
32+
crowdfunding: { label: 'Crowdfunding', icon: Users, color: 'text-blue-400' },
33+
bounties: { label: 'Bounties', icon: Target, color: 'text-purple-400' },
34+
};
35+
36+
const formatCurrency = (amount: number, currency = 'USD'): string => {
37+
return new Intl.NumberFormat('en-US', {
38+
style: 'currency',
39+
currency,
40+
minimumFractionDigits: 0,
41+
maximumFractionDigits: 0,
42+
}).format(amount);
43+
};
44+
45+
interface EarningActivityItemProps {
46+
activity: EarningActivity;
47+
}
48+
49+
const EarningActivityItem = ({
50+
activity,
51+
}: EarningActivityItemProps): React.ReactElement => {
52+
const config = SOURCE_CONFIG[activity.source];
53+
const Icon = config.icon;
54+
55+
return (
56+
<div className='flex items-start gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-4'>
57+
<div
58+
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-zinc-800 ${config.color}`}
59+
>
60+
<Icon className='h-5 w-5' />
61+
</div>
62+
<div className='min-w-0 flex-1'>
63+
<p className='truncate text-sm text-white'>{activity.title}</p>
64+
<p className='mt-1 text-xs text-zinc-500'>
65+
{formatDistanceToNow(new Date(activity.occurredAt), {
66+
addSuffix: true,
67+
})}
68+
</p>
69+
</div>
70+
<div className='shrink-0 text-right'>
71+
<p className='text-primary text-sm font-bold'>
72+
{formatCurrency(activity.amount, activity.currency)}
73+
</p>
74+
<p className={`text-xs ${config.color}`}>{config.label}</p>
75+
</div>
76+
</div>
77+
);
78+
};
79+
80+
const PublicEarningsTab = ({
81+
username,
82+
}: PublicEarningsTabProps): React.ReactElement => {
83+
const [earnings, setEarnings] = useState<PublicEarningsResponse | null>(null);
84+
const [loading, setLoading] = useState(true);
85+
const [error, setError] = useState<string | null>(null);
86+
87+
useEffect(() => {
88+
const controller = new AbortController();
89+
90+
const loadEarnings = async (): Promise<void> => {
91+
try {
92+
setLoading(true);
93+
setError(null);
94+
const response = await getPublicEarnings({ username });
95+
if (!controller.signal.aborted) {
96+
setEarnings(response.data);
97+
}
98+
} catch (err) {
99+
if (!controller.signal.aborted) {
100+
setError('Unable to load earnings data');
101+
console.error('Failed to load earnings:', err);
102+
}
103+
} finally {
104+
if (!controller.signal.aborted) {
105+
setLoading(false);
106+
}
107+
}
108+
};
109+
110+
loadEarnings();
111+
112+
return () => {
113+
controller.abort();
114+
};
115+
}, [username]);
116+
117+
if (loading) {
118+
return (
119+
<div className='flex items-center justify-center py-12'>
120+
<Loader2 className='h-8 w-8 animate-spin text-zinc-500' />
121+
</div>
122+
);
123+
}
124+
125+
if (error || !earnings) {
126+
return (
127+
<EmptyState
128+
type='compact'
129+
title='No Earnings Data'
130+
description={error || 'No earnings data available for this user yet.'}
131+
action={<></>}
132+
/>
133+
);
134+
}
135+
136+
const breakdown = earnings.breakdown ?? {
137+
hackathons: 0,
138+
grants: 0,
139+
crowdfunding: 0,
140+
bounties: 0,
141+
};
142+
143+
const breakdownItems = Object.entries(breakdown)
144+
.filter(([, amount]) => amount > 0)
145+
.sort(([, a], [, b]) => b - a) as [EarningSource, number][];
146+
147+
return (
148+
<div className='space-y-6 rounded-xl border border-zinc-800 bg-zinc-900/30 p-6'>
149+
<div className='flex items-center gap-3'>
150+
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-800'>
151+
<TrendingUp className='text-primary h-6 w-6' />
152+
</div>
153+
<div>
154+
<p className='text-xs text-zinc-500'>Total Earned</p>
155+
<p className='text-2xl font-bold text-white'>
156+
{formatCurrency(earnings.summary?.totalEarned ?? 0)}
157+
</p>
158+
</div>
159+
</div>
160+
161+
<div className='grid grid-cols-2 gap-4 lg:grid-cols-4'>
162+
{breakdownItems.map(([source, amount]) => {
163+
const config = SOURCE_CONFIG[source];
164+
const Icon = config.icon;
165+
166+
return (
167+
<div key={source} className='space-y-2'>
168+
<div className='flex items-center gap-2'>
169+
<div
170+
className={`flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-800 ${config.color}`}
171+
>
172+
<Icon className='h-4 w-4' />
173+
</div>
174+
<span className='text-sm text-zinc-500'>{config.label}</span>
175+
</div>
176+
<p className='text-2xl font-bold text-white'>
177+
{formatCurrency(amount)}
178+
</p>
179+
</div>
180+
);
181+
})}
182+
</div>
183+
184+
<div className='flex flex-wrap gap-4 text-xs text-zinc-500'>
185+
{breakdownItems.map(([source]) => {
186+
const config = SOURCE_CONFIG[source];
187+
return (
188+
<span key={source} className='flex items-center gap-2'>
189+
<i
190+
className={`inline-block h-2 w-2 rounded-full ${config.color.replace('text-', 'bg-')}`}
191+
/>
192+
{config.label}
193+
</span>
194+
);
195+
})}
196+
</div>
197+
198+
{(earnings.activities?.length ?? 0) > 0 && (
199+
<div className='space-y-4 border-t border-zinc-800 pt-6'>
200+
<h3 className='text-xs font-semibold uppercase tracking-wider text-zinc-500'>
201+
Verified Activity
202+
</h3>
203+
<div className='space-y-3'>
204+
{(earnings.activities ?? []).map((activity, index) => (
205+
<motion.div
206+
key={`${activity.source}-${activity.occurredAt}-${index}`}
207+
initial={{ opacity: 0, y: 10 }}
208+
animate={{ opacity: 1, y: 0 }}
209+
>
210+
<EarningActivityItem activity={activity} />
211+
</motion.div>
212+
))}
213+
</div>
214+
</div>
215+
)}
216+
</div>
217+
);
218+
};
219+
220+
export default PublicEarningsTab;

lib/api/earnings.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { api, ApiResponse } from './api';
2+
import { PublicEarningsResponse } from '@/types/earnings';
3+
4+
export interface GetPublicEarningsParams {
5+
username: string;
6+
limit?: number;
7+
offset?: number;
8+
}
9+
10+
export const getPublicEarnings = async ({
11+
username,
12+
limit = 100,
13+
offset = 0,
14+
}: GetPublicEarningsParams): Promise<ApiResponse<PublicEarningsResponse>> => {
15+
if (!username || typeof username !== 'string') {
16+
throw new Error('Username is required and must be a string');
17+
}
18+
19+
const sanitizedLimit = Math.max(1, Math.min(limit, 100));
20+
const sanitizedOffset = Math.max(0, offset);
21+
22+
const params = new URLSearchParams({
23+
username: username.trim(),
24+
limit: sanitizedLimit.toString(),
25+
offset: sanitizedOffset.toString(),
26+
});
27+
28+
return api.get<PublicEarningsResponse>(
29+
`/users/earnings/public?${params.toString()}`
30+
);
31+
};

types/earnings.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type EarningSource =
2+
| 'hackathons'
3+
| 'grants'
4+
| 'crowdfunding'
5+
| 'bounties';
6+
7+
export interface EarningActivity {
8+
source: EarningSource;
9+
title: string;
10+
amount: number;
11+
currency: string;
12+
occurredAt: string;
13+
}
14+
15+
export interface EarningsBreakdown {
16+
hackathons: number;
17+
grants: number;
18+
crowdfunding: number;
19+
bounties: number;
20+
}
21+
22+
export interface PublicEarningsResponse {
23+
summary: {
24+
totalEarned: number;
25+
};
26+
breakdown: EarningsBreakdown;
27+
activities: EarningActivity[];
28+
}

0 commit comments

Comments
 (0)