From 74c438f28920a59be5e354cfaa38ce354050d8e0 Mon Sep 17 00:00:00 2001 From: Pipixia Watchdog Date: Sat, 28 Feb 2026 23:02:29 +0800 Subject: [PATCH 1/4] Atlas-Bounty: Implement Private Earnings Dashboard --- app/me/earnings/page.tsx | 221 +++++++++++++++++++++++++++++++++++++ components/app-sidebar.tsx | 6 + lib/api/user/earnings.ts | 58 ++++++++++ 3 files changed, 285 insertions(+) create mode 100644 app/me/earnings/page.tsx create mode 100644 lib/api/user/earnings.ts diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx new file mode 100644 index 000000000..fb49a40fb --- /dev/null +++ b/app/me/earnings/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + IconCurrencyDollar, + IconClock, + IconCheck, + IconArrowUpRight, + IconTrophy, + IconBriefcase, + IconUsers, + IconTarget +} from '@tabler/icons-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { getUserEarnings, claimEarning, EarningsData, EarningActivity } from '@/lib/api/user/earnings'; +import { toast } from 'sonner'; + +export default function EarningsPage() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [claimingId, setClaimingId] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + const res = await getUserEarnings(); + if (res.success && res.data) { + setData(res.data); + } + } catch (error) { + console.error('Failed to fetch earnings:', error); + toast.error('Failed to load earnings data'); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + const handleClaim = async (id: string) => { + setClaimingId(id); + try { + const res = await claimEarning({ activityId: id }); + if (res.success) { + toast.success('Claim successful!'); + // Refresh data + const updated = await getUserEarnings(); + if (updated.data) setData(updated.data); + } else { + toast.error(res.message || 'Claim failed'); + } + } catch (error) { + toast.error('An error occurred during claiming'); + } finally { + setClaimingId(null); + } + }; + + if (loading) { + return ; + } + + if (!data) return
No data found.
; + + return ( +
+ +

Earnings Dashboard

+

Manage and track your rewards across the platform.

+
+ + {/* Summary Cards */} +
+ } + description="Lifetime earnings" + /> + } + description="Awaiting processing" + /> + } + description="Successfully cashed out" + /> +
+ +
+ {/* Breakdown */} + + + Source Breakdown + Earnings categorized by activity type + + + } /> + } /> + } /> + } /> + + + + {/* Activity Feed */} + + + Recent Activity + Your latest wins and rewards + + +
+ {data.activities.length === 0 ? ( +

No recent activity found.

+ ) : ( + data.activities.map((activity) => ( + handleClaim(activity.id)} + isClaiming={claimingId === activity.id} + /> + )) + )} +
+
+
+
+
+ ); +} + +function SummaryCard({ title, value, icon, description }: { title: string, value: string, icon: React.ReactNode, description: string }) { + return ( + + + {title} + {icon} + + +
{value}
+

{description}

+
+
+ ); +} + +function BreakdownItem({ label, value, icon }: { label: string, value: number, icon: React.ReactNode }) { + return ( +
+
+
+ {icon} +
+ {label} +
+ ${value.toLocaleString()} +
+ ); +} + +function ActivityItem({ activity, onClaim, isClaiming }: { activity: EarningActivity, onClaim: () => void, isClaiming: boolean }) { + return ( +
+
+

+ {activity.title} + + {activity.status} + +

+
+ {activity.source} + + {new Date(activity.createdAt).toLocaleDateString()} +
+
+
+

${activity.amount.toLocaleString()}

+ {activity.status === 'claimable' && ( + + )} +
+
+ ); +} + +function EarningsSkeleton() { + return ( +
+
+ + +
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ad72176a8..6f0bf025e 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { IconBell, IconChartBar, + IconCurrencyDollar, IconDashboard, IconFileText, IconFolder, @@ -39,6 +40,11 @@ const navigationData = { url: '/me/analytics', icon: IconChartBar, }, + { + title: 'Earnings', + url: '/me/earnings', + icon: IconCurrencyDollar, + }, ], projects: [ { diff --git a/lib/api/user/earnings.ts b/lib/api/user/earnings.ts new file mode 100644 index 000000000..824fb6d55 --- /dev/null +++ b/lib/api/user/earnings.ts @@ -0,0 +1,58 @@ +import api from '../api'; +import { ApiResponse } from '../types'; + +export interface EarningActivity { + id: string; + source: 'hackathon' | 'grant' | 'crowdfund' | 'bounty'; + title: string; + amount: number; + currency: string; + status: 'pending' | 'completed' | 'claimable'; + createdAt: string; +} + +export interface EarningsData { + totalEarned: number; + pendingWithdrawal: number; + completedWithdrawal: number; + breakdown: { + hackathons: number; + grants: number; + crowdfunding: number; + bounties: number; + }; + activities: EarningActivity[]; +} + +export interface GetEarningsResponse extends ApiResponse { + success: true; + data: EarningsData; +} + +export interface ClaimEarningRequest { + activityId: string; +} + +export interface ClaimEarningResponse extends ApiResponse { + success: boolean; + message: string; + data?: { + transactionHash: string; + }; +} + +/** + * Get user earnings data + */ +export const getUserEarnings = async (): Promise => { + const res = await api.get('/user/earnings'); + return res.data; +}; + +/** + * Claim a specific earning + */ +export const claimEarning = async (data: ClaimEarningRequest): Promise => { + const res = await api.post('/user/earnings/claim', data); + return res.data; +}; From 9461e9b66ce3ab219905383e0fb446f07582c10a Mon Sep 17 00:00:00 2001 From: Pipixia Watchdog Date: Sat, 28 Feb 2026 23:09:21 +0800 Subject: [PATCH 2/4] Atlas-Bounty: Address review feedback for Earnings Dashboard --- app/me/earnings/page.tsx | 23 +++++++++++++++++------ lib/api/user/earnings.ts | 6 +++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx index fb49a40fb..3d083c052 100644 --- a/app/me/earnings/page.tsx +++ b/app/me/earnings/page.tsx @@ -19,7 +19,10 @@ import { Skeleton } from '@/components/ui/skeleton'; import { getUserEarnings, claimEarning, EarningsData, EarningActivity } from '@/lib/api/user/earnings'; import { toast } from 'sonner'; -export default function EarningsPage() { +/** + * EarningsPage component for managing and tracking user rewards. + */ +const EarningsPage: React.FC = () => { const [loading, setLoading] = useState(true); const [data, setData] = useState(null); const [claimingId, setClaimingId] = useState(null); @@ -41,6 +44,9 @@ export default function EarningsPage() { fetchData(); }, []); + /** + * Handles the claiming of a specific earning activity. + */ const handleClaim = async (id: string) => { setClaimingId(id); try { @@ -49,11 +55,14 @@ export default function EarningsPage() { toast.success('Claim successful!'); // Refresh data const updated = await getUserEarnings(); - if (updated.data) setData(updated.data); + if (updated.success && updated.data) { + setData(updated.data); + } } else { toast.error(res.message || 'Claim failed'); } } catch (error) { + console.error('Failed to claim earning:', error); toast.error('An error occurred during claiming'); } finally { setClaimingId(null); @@ -140,7 +149,7 @@ export default function EarningsPage() { ); -} +}; function SummaryCard({ title, value, icon, description }: { title: string, value: string, icon: React.ReactNode, description: string }) { return ( @@ -175,12 +184,12 @@ function ActivityItem({ activity, onClaim, isClaiming }: { activity: EarningActi return (
-

+

{activity.title} - + {activity.status} -

+
{activity.source} @@ -219,3 +228,5 @@ function EarningsSkeleton() {
); } + +export default EarningsPage; diff --git a/lib/api/user/earnings.ts b/lib/api/user/earnings.ts index 824fb6d55..3f33e318a 100644 --- a/lib/api/user/earnings.ts +++ b/lib/api/user/earnings.ts @@ -1,4 +1,4 @@ -import api from '../api'; +import { api } from '../api'; import { ApiResponse } from '../types'; export interface EarningActivity { @@ -45,7 +45,7 @@ export interface ClaimEarningResponse extends ApiResponse { * Get user earnings data */ export const getUserEarnings = async (): Promise => { - const res = await api.get('/user/earnings'); + const res = await api.get('/user/earnings'); return res.data; }; @@ -53,6 +53,6 @@ export const getUserEarnings = async (): Promise => { * Claim a specific earning */ export const claimEarning = async (data: ClaimEarningRequest): Promise => { - const res = await api.post('/user/earnings/claim', data); + const res = await api.post('/user/earnings/claim', data); return res.data; }; From 7476ee49bf2102f88b134d5081bb6a382503f4fa Mon Sep 17 00:00:00 2001 From: Pipixia Watchdog Date: Sat, 28 Feb 2026 23:14:22 +0800 Subject: [PATCH 3/4] Atlas-Bounty: Refactor components to const arrow functions and improve empty states --- app/me/earnings/page.tsx | 196 +++++++++++++++++++++++---------------- 1 file changed, 117 insertions(+), 79 deletions(-) diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx index 3d083c052..5b74d1ff8 100644 --- a/app/me/earnings/page.tsx +++ b/app/me/earnings/page.tsx @@ -19,6 +19,116 @@ import { Skeleton } from '@/components/ui/skeleton'; import { getUserEarnings, claimEarning, EarningsData, EarningActivity } from '@/lib/api/user/earnings'; import { toast } from 'sonner'; +/** + * Interface for SummaryCard props. + */ +interface SummaryCardProps { + title: string; + value: string; + icon: React.ReactNode; + description: string; +} + +/** + * SummaryCard component for displaying high-level stats. + */ +const SummaryCard: React.FC = ({ title, value, icon, description }) => ( + + + {title} + {icon} + + +
{value}
+

{description}

+
+
+); + +/** + * Interface for BreakdownItem props. + */ +interface BreakdownItemProps { + label: string; + value: number; + icon: React.ReactNode; +} + +/** + * BreakdownItem component for showing source-specific earnings. + */ +const BreakdownItem: React.FC = ({ label, value, icon }) => ( +
+
+
+ {icon} +
+ {label} +
+ ${value.toLocaleString()} +
+); + +/** + * Interface for ActivityItem props. + */ +interface ActivityItemProps { + activity: EarningActivity; + onClaim: () => void; + isClaiming: boolean; +} + +/** + * ActivityItem component for displaying a single reward entry. + */ +const ActivityItem: React.FC = ({ activity, onClaim, isClaiming }) => ( +
+
+
+ {activity.title} + + {activity.status} + +
+
+ {activity.source} + + {new Date(activity.createdAt).toLocaleDateString()} +
+
+
+

${activity.amount.toLocaleString()}

+ {activity.status === 'claimable' && ( + + )} +
+
+); + +/** + * EarningsSkeleton component for loading states. + */ +const EarningsSkeleton: React.FC = () => ( +
+
+ + +
+
+ + + +
+
+ + +
+
+); + /** * EarningsPage component for managing and tracking user rewards. */ @@ -73,7 +183,13 @@ const EarningsPage: React.FC = () => { return ; } - if (!data) return
No data found.
; + if (!data) { + return ( +
+

No earnings data found.

+
+ ); + } return (
@@ -151,82 +267,4 @@ const EarningsPage: React.FC = () => { ); }; -function SummaryCard({ title, value, icon, description }: { title: string, value: string, icon: React.ReactNode, description: string }) { - return ( - - - {title} - {icon} - - -
{value}
-

{description}

-
-
- ); -} - -function BreakdownItem({ label, value, icon }: { label: string, value: number, icon: React.ReactNode }) { - return ( -
-
-
- {icon} -
- {label} -
- ${value.toLocaleString()} -
- ); -} - -function ActivityItem({ activity, onClaim, isClaiming }: { activity: EarningActivity, onClaim: () => void, isClaiming: boolean }) { - return ( -
-
-
- {activity.title} - - {activity.status} - -
-
- {activity.source} - - {new Date(activity.createdAt).toLocaleDateString()} -
-
-
-

${activity.amount.toLocaleString()}

- {activity.status === 'claimable' && ( - - )} -
-
- ); -} - -function EarningsSkeleton() { - return ( -
-
- - -
-
- - - -
-
- - -
-
- ); -} - export default EarningsPage; From 50ddce62276e563a147a1de395cfe66f2a428476 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sat, 28 Feb 2026 20:58:59 +0100 Subject: [PATCH 4/4] refactor: Earnings Dashboard: Simplify ActivityItem component, remove claim functionality, and update earnings data structure for improved clarity and performance. --- app/me/earnings/page.tsx | 241 ++++++++++++++++++++------------------- lib/api/user/earnings.ts | 22 ++-- 2 files changed, 135 insertions(+), 128 deletions(-) diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx index 5b74d1ff8..f599da39f 100644 --- a/app/me/earnings/page.tsx +++ b/app/me/earnings/page.tsx @@ -2,21 +2,28 @@ import React, { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; -import { - IconCurrencyDollar, - IconClock, - IconCheck, - IconArrowUpRight, +import { + IconCurrencyDollar, + IconClock, + IconCheck, IconTrophy, IconBriefcase, IconUsers, - IconTarget + IconTarget, } from '@tabler/icons-react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { getUserEarnings, claimEarning, EarningsData, EarningActivity } from '@/lib/api/user/earnings'; +import { + getUserEarnings, + EarningsData, + EarningActivity, +} from '@/lib/api/user/earnings'; import { toast } from 'sonner'; /** @@ -32,15 +39,20 @@ interface SummaryCardProps { /** * SummaryCard component for displaying high-level stats. */ -const SummaryCard: React.FC = ({ title, value, icon, description }) => ( +const SummaryCard: React.FC = ({ + title, + value, + icon, + description, +}) => ( - - {title} + + {title} {icon} -
{value}
-

{description}

+
{value}
+

{description}

); @@ -57,15 +69,19 @@ interface BreakdownItemProps { /** * BreakdownItem component for showing source-specific earnings. */ -const BreakdownItem: React.FC = ({ label, value, icon }) => ( -
-
-
+const BreakdownItem: React.FC = ({ + label, + value, + icon, +}) => ( +
+
+
{icon}
- {label} + {label}
- ${value.toLocaleString()} + ${value.toLocaleString()}
); @@ -74,35 +90,25 @@ const BreakdownItem: React.FC = ({ label, value, icon }) => */ interface ActivityItemProps { activity: EarningActivity; - onClaim: () => void; - isClaiming: boolean; } /** * ActivityItem component for displaying a single reward entry. */ -const ActivityItem: React.FC = ({ activity, onClaim, isClaiming }) => ( -
-
-
- {activity.title} - - {activity.status} - -
-
- {activity.source} +const ActivityItem: React.FC = ({ activity }) => ( +
+
+
{activity.title}
+
+ {activity.source} - {new Date(activity.createdAt).toLocaleDateString()} + {new Date(activity.occurredAt).toLocaleDateString()}
-
-

${activity.amount.toLocaleString()}

- {activity.status === 'claimable' && ( - +
+

${activity.amount.toLocaleString()}

+ {activity.currency && ( +

{activity.currency}

)}
@@ -112,19 +118,19 @@ const ActivityItem: React.FC = ({ activity, onClaim, isClaimi * EarningsSkeleton component for loading states. */ const EarningsSkeleton: React.FC = () => ( -
-
- - +
+
+ +
-
- - - +
+ + +
-
- - +
+ +
); @@ -135,7 +141,6 @@ const EarningsSkeleton: React.FC = () => ( const EarningsPage: React.FC = () => { const [loading, setLoading] = useState(true); const [data, setData] = useState(null); - const [claimingId, setClaimingId] = useState(null); useEffect(() => { const fetchData = async () => { @@ -154,109 +159,105 @@ const EarningsPage: React.FC = () => { fetchData(); }, []); - /** - * Handles the claiming of a specific earning activity. - */ - const handleClaim = async (id: string) => { - setClaimingId(id); - try { - const res = await claimEarning({ activityId: id }); - if (res.success) { - toast.success('Claim successful!'); - // Refresh data - const updated = await getUserEarnings(); - if (updated.success && updated.data) { - setData(updated.data); - } - } else { - toast.error(res.message || 'Claim failed'); - } - } catch (error) { - console.error('Failed to claim earning:', error); - toast.error('An error occurred during claiming'); - } finally { - setClaimingId(null); - } - }; - if (loading) { return ; } if (!data) { return ( -
-

No earnings data found.

+
+

+ No earnings data found. +

); } return ( -
- + -

Earnings Dashboard

-

Manage and track your rewards across the platform.

+

+ Earnings Dashboard +

+

+ Manage and track your rewards across the platform. +

{/* Summary Cards */} -
- } - description="Lifetime earnings" +
+ } + description='Lifetime earnings' /> - } - description="Awaiting processing" + } + description='Awaiting processing' /> - } - description="Successfully cashed out" + } + description='Successfully cashed out' />
-
+
{/* Breakdown */} - + Source Breakdown - Earnings categorized by activity type + + Earnings categorized by activity type + - - } /> - } /> - } /> - } /> + + } + /> + } + /> + } + /> + } + /> {/* Activity Feed */} - + Recent Activity Your latest wins and rewards -
+
{data.activities.length === 0 ? ( -

No recent activity found.

+

+ No recent activity found. +

) : ( - data.activities.map((activity) => ( - handleClaim(activity.id)} - isClaiming={claimingId === activity.id} - /> + data.activities.map(activity => ( + )) )}
diff --git a/lib/api/user/earnings.ts b/lib/api/user/earnings.ts index 3f33e318a..a86d7dc13 100644 --- a/lib/api/user/earnings.ts +++ b/lib/api/user/earnings.ts @@ -3,18 +3,19 @@ import { ApiResponse } from '../types'; export interface EarningActivity { id: string; - source: 'hackathon' | 'grant' | 'crowdfund' | 'bounty'; + source: 'hackathons' | 'grants' | 'crowdfunding' | 'bounties'; title: string; amount: number; currency: string; - status: 'pending' | 'completed' | 'claimable'; - createdAt: string; + occurredAt: string; } export interface EarningsData { - totalEarned: number; - pendingWithdrawal: number; - completedWithdrawal: number; + summary: { + totalEarned: number; + pendingWithdrawal: number; + completedWithdrawal: number; + }; breakdown: { hackathons: number; grants: number; @@ -52,7 +53,12 @@ export const getUserEarnings = async (): Promise => { /** * Claim a specific earning */ -export const claimEarning = async (data: ClaimEarningRequest): Promise => { - const res = await api.post('/user/earnings/claim', data); +export const claimEarning = async ( + data: ClaimEarningRequest +): Promise => { + const res = await api.post( + '/user/earnings/claim', + data + ); return res.data; };