Skip to content

Commit c7a83dd

Browse files
FankouzuPipixia Watchdog0xdevcollins
authored
Atlas-Bounty: Implement Private Earnings Dashboard (/me/earnings) (#419)
* Atlas-Bounty: Implement Private Earnings Dashboard * Atlas-Bounty: Address review feedback for Earnings Dashboard * Atlas-Bounty: Refactor components to const arrow functions and improve empty states * refactor: Earnings Dashboard: Simplify ActivityItem component, remove claim functionality, and update earnings data structure for improved clarity and performance. --------- Co-authored-by: Pipixia Watchdog <pipixia@openclaw.local> Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com>
1 parent b415b68 commit c7a83dd

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

app/me/earnings/page.tsx

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
'use client';
2+
3+
import React, { useEffect, useState } from 'react';
4+
import { motion } from 'framer-motion';
5+
import {
6+
IconCurrencyDollar,
7+
IconClock,
8+
IconCheck,
9+
IconTrophy,
10+
IconBriefcase,
11+
IconUsers,
12+
IconTarget,
13+
} from '@tabler/icons-react';
14+
import {
15+
Card,
16+
CardContent,
17+
CardDescription,
18+
CardHeader,
19+
CardTitle,
20+
} from '@/components/ui/card';
21+
import { Skeleton } from '@/components/ui/skeleton';
22+
import {
23+
getUserEarnings,
24+
EarningsData,
25+
EarningActivity,
26+
} from '@/lib/api/user/earnings';
27+
import { toast } from 'sonner';
28+
29+
/**
30+
* Interface for SummaryCard props.
31+
*/
32+
interface SummaryCardProps {
33+
title: string;
34+
value: string;
35+
icon: React.ReactNode;
36+
description: string;
37+
}
38+
39+
/**
40+
* SummaryCard component for displaying high-level stats.
41+
*/
42+
const SummaryCard: React.FC<SummaryCardProps> = ({
43+
title,
44+
value,
45+
icon,
46+
description,
47+
}) => (
48+
<Card>
49+
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
50+
<CardTitle className='text-sm font-medium'>{title}</CardTitle>
51+
{icon}
52+
</CardHeader>
53+
<CardContent>
54+
<div className='text-2xl font-bold'>{value}</div>
55+
<p className='text-muted-foreground mt-1 text-xs'>{description}</p>
56+
</CardContent>
57+
</Card>
58+
);
59+
60+
/**
61+
* Interface for BreakdownItem props.
62+
*/
63+
interface BreakdownItemProps {
64+
label: string;
65+
value: number;
66+
icon: React.ReactNode;
67+
}
68+
69+
/**
70+
* BreakdownItem component for showing source-specific earnings.
71+
*/
72+
const BreakdownItem: React.FC<BreakdownItemProps> = ({
73+
label,
74+
value,
75+
icon,
76+
}) => (
77+
<div className='group flex items-center justify-between'>
78+
<div className='flex items-center gap-3'>
79+
<div className='bg-muted group-hover:bg-primary/10 rounded-md p-2 transition-colors'>
80+
{icon}
81+
</div>
82+
<span className='font-medium'>{label}</span>
83+
</div>
84+
<span className='font-semibold'>${value.toLocaleString()}</span>
85+
</div>
86+
);
87+
88+
/**
89+
* Interface for ActivityItem props.
90+
*/
91+
interface ActivityItemProps {
92+
activity: EarningActivity;
93+
}
94+
95+
/**
96+
* ActivityItem component for displaying a single reward entry.
97+
*/
98+
const ActivityItem: React.FC<ActivityItemProps> = ({ activity }) => (
99+
<div className='flex items-center justify-between border-b pb-4 last:border-0 last:pb-0'>
100+
<div className='space-y-1'>
101+
<div className='font-semibold'>{activity.title}</div>
102+
<div className='text-muted-foreground flex items-center gap-3 text-sm'>
103+
<span className='capitalize'>{activity.source}</span>
104+
<span></span>
105+
<span>{new Date(activity.occurredAt).toLocaleDateString()}</span>
106+
</div>
107+
</div>
108+
<div className='text-right'>
109+
<p className='text-lg font-bold'>${activity.amount.toLocaleString()}</p>
110+
{activity.currency && (
111+
<p className='text-muted-foreground text-xs'>{activity.currency}</p>
112+
)}
113+
</div>
114+
</div>
115+
);
116+
117+
/**
118+
* EarningsSkeleton component for loading states.
119+
*/
120+
const EarningsSkeleton: React.FC = () => (
121+
<div className='container mx-auto space-y-8 py-8'>
122+
<div className='space-y-2'>
123+
<Skeleton className='h-10 w-[250px]' />
124+
<Skeleton className='h-6 w-[350px]' />
125+
</div>
126+
<div className='grid gap-4 md:grid-cols-3'>
127+
<Skeleton className='h-[120px] rounded-xl' />
128+
<Skeleton className='h-[120px] rounded-xl' />
129+
<Skeleton className='h-[120px] rounded-xl' />
130+
</div>
131+
<div className='grid gap-8 md:grid-cols-7'>
132+
<Skeleton className='h-[400px] rounded-xl md:col-span-3' />
133+
<Skeleton className='h-[400px] rounded-xl md:col-span-4' />
134+
</div>
135+
</div>
136+
);
137+
138+
/**
139+
* EarningsPage component for managing and tracking user rewards.
140+
*/
141+
const EarningsPage: React.FC = () => {
142+
const [loading, setLoading] = useState(true);
143+
const [data, setData] = useState<EarningsData | null>(null);
144+
145+
useEffect(() => {
146+
const fetchData = async () => {
147+
try {
148+
const res = await getUserEarnings();
149+
if (res.success && res.data) {
150+
setData(res.data);
151+
}
152+
} catch (error) {
153+
console.error('Failed to fetch earnings:', error);
154+
toast.error('Failed to load earnings data');
155+
} finally {
156+
setLoading(false);
157+
}
158+
};
159+
fetchData();
160+
}, []);
161+
162+
if (loading) {
163+
return <EarningsSkeleton />;
164+
}
165+
166+
if (!data) {
167+
return (
168+
<div className='container mx-auto py-8'>
169+
<p className='text-muted-foreground py-8 text-center'>
170+
No earnings data found.
171+
</p>
172+
</div>
173+
);
174+
}
175+
176+
return (
177+
<div className='container mx-auto space-y-8 py-8'>
178+
<motion.div
179+
initial={{ opacity: 0, y: 20 }}
180+
animate={{ opacity: 1, y: 0 }}
181+
className='flex flex-col gap-2'
182+
>
183+
<h1 className='text-3xl font-bold tracking-tight'>
184+
Earnings Dashboard
185+
</h1>
186+
<p className='text-muted-foreground text-lg'>
187+
Manage and track your rewards across the platform.
188+
</p>
189+
</motion.div>
190+
191+
{/* Summary Cards */}
192+
<div className='grid gap-4 md:grid-cols-3'>
193+
<SummaryCard
194+
title='Total Earned'
195+
value={`$${data.summary.totalEarned.toLocaleString()}`}
196+
icon={<IconCurrencyDollar className='text-primary h-5 w-5' />}
197+
description='Lifetime earnings'
198+
/>
199+
<SummaryCard
200+
title='Pending Withdrawal'
201+
value={`$${data.summary.pendingWithdrawal.toLocaleString()}`}
202+
icon={<IconClock className='h-5 w-5 text-yellow-500' />}
203+
description='Awaiting processing'
204+
/>
205+
<SummaryCard
206+
title='Completed Withdrawal'
207+
value={`$${data.summary.completedWithdrawal.toLocaleString()}`}
208+
icon={<IconCheck className='h-5 w-5 text-green-500' />}
209+
description='Successfully cashed out'
210+
/>
211+
</div>
212+
213+
<div className='grid gap-8 md:grid-cols-7'>
214+
{/* Breakdown */}
215+
<Card className='md:col-span-3'>
216+
<CardHeader>
217+
<CardTitle>Source Breakdown</CardTitle>
218+
<CardDescription>
219+
Earnings categorized by activity type
220+
</CardDescription>
221+
</CardHeader>
222+
<CardContent className='space-y-4'>
223+
<BreakdownItem
224+
label='Hackathons'
225+
value={data.breakdown.hackathons}
226+
icon={<IconTrophy className='h-4 w-4' />}
227+
/>
228+
<BreakdownItem
229+
label='Grants'
230+
value={data.breakdown.grants}
231+
icon={<IconTarget className='h-4 w-4' />}
232+
/>
233+
<BreakdownItem
234+
label='Crowdfunding'
235+
value={data.breakdown.crowdfunding}
236+
icon={<IconUsers className='h-4 w-4' />}
237+
/>
238+
<BreakdownItem
239+
label='Bounties'
240+
value={data.breakdown.bounties}
241+
icon={<IconBriefcase className='h-4 w-4' />}
242+
/>
243+
</CardContent>
244+
</Card>
245+
246+
{/* Activity Feed */}
247+
<Card className='md:col-span-4'>
248+
<CardHeader>
249+
<CardTitle>Recent Activity</CardTitle>
250+
<CardDescription>Your latest wins and rewards</CardDescription>
251+
</CardHeader>
252+
<CardContent>
253+
<div className='space-y-6'>
254+
{data.activities.length === 0 ? (
255+
<p className='text-muted-foreground py-8 text-center'>
256+
No recent activity found.
257+
</p>
258+
) : (
259+
data.activities.map(activity => (
260+
<ActivityItem key={activity.id} activity={activity} />
261+
))
262+
)}
263+
</div>
264+
</CardContent>
265+
</Card>
266+
</div>
267+
</div>
268+
);
269+
};
270+
271+
export default EarningsPage;

components/app-sidebar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44
import {
55
IconBell,
66
IconChartBar,
7+
IconCurrencyDollar,
78
IconDashboard,
89
IconFileText,
910
IconFolder,
@@ -39,6 +40,11 @@ const navigationData = {
3940
url: '/me/analytics',
4041
icon: IconChartBar,
4142
},
43+
{
44+
title: 'Earnings',
45+
url: '/me/earnings',
46+
icon: IconCurrencyDollar,
47+
},
4248
],
4349
projects: [
4450
{

lib/api/user/earnings.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { api } from '../api';
2+
import { ApiResponse } from '../types';
3+
4+
export interface EarningActivity {
5+
id: string;
6+
source: 'hackathons' | 'grants' | 'crowdfunding' | 'bounties';
7+
title: string;
8+
amount: number;
9+
currency: string;
10+
occurredAt: string;
11+
}
12+
13+
export interface EarningsData {
14+
summary: {
15+
totalEarned: number;
16+
pendingWithdrawal: number;
17+
completedWithdrawal: number;
18+
};
19+
breakdown: {
20+
hackathons: number;
21+
grants: number;
22+
crowdfunding: number;
23+
bounties: number;
24+
};
25+
activities: EarningActivity[];
26+
}
27+
28+
export interface GetEarningsResponse extends ApiResponse<EarningsData> {
29+
success: true;
30+
data: EarningsData;
31+
}
32+
33+
export interface ClaimEarningRequest {
34+
activityId: string;
35+
}
36+
37+
export interface ClaimEarningResponse extends ApiResponse {
38+
success: boolean;
39+
message: string;
40+
data?: {
41+
transactionHash: string;
42+
};
43+
}
44+
45+
/**
46+
* Get user earnings data
47+
*/
48+
export const getUserEarnings = async (): Promise<GetEarningsResponse> => {
49+
const res = await api.get<GetEarningsResponse>('/user/earnings');
50+
return res.data;
51+
};
52+
53+
/**
54+
* Claim a specific earning
55+
*/
56+
export const claimEarning = async (
57+
data: ClaimEarningRequest
58+
): Promise<ClaimEarningResponse> => {
59+
const res = await api.post<ClaimEarningResponse>(
60+
'/user/earnings/claim',
61+
data
62+
);
63+
return res.data;
64+
};

0 commit comments

Comments
 (0)