diff --git a/app/(landing)/organizations/[id]/hackathons/page.tsx b/app/(landing)/organizations/[id]/hackathons/page.tsx index 699c39057..f949e2571 100644 --- a/app/(landing)/organizations/[id]/hackathons/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/page.tsx @@ -87,6 +87,7 @@ export default function HackathonsPage() { const [hackathonToDelete, setHackathonToDelete] = useState<{ id: string; title: string; + isDraft: boolean; } | null>(null); const { hackathons, hackathonsLoading, drafts, draftsLoading, refetchAll } = @@ -98,18 +99,14 @@ export default function HackathonsPage() { // Use the separate delete hook const { isDeleting, deleteHackathon } = useDeleteHackathon({ organizationId, - hackathonId: hackathonToDelete?.id || '', // This will be set when we have a hackathon to delete + hackathonId: hackathonToDelete?.id || '', + isDraft: hackathonToDelete?.isDraft || false, onSuccess: () => { // Refresh the hackathons list after successful deletion refetchAll(); - toast.success('Hackathon deleted successfully', { - description: `"${hackathonToDelete?.title}" has been permanently deleted.`, - }); }, - onError: error => { - toast.error('Failed to delete hackathon', { - description: error, - }); + onError: _error => { + // Error handled by hook's internal toast }, }); @@ -181,12 +178,12 @@ export default function HackathonsPage() { const handleDeleteClick = (hackathonId: string) => { const hackathon = allHackathons.find(item => item.data.id === hackathonId); if (hackathon) { - const title = - hackathon.type === 'draft' + const isDraft = hackathon.type === 'draft'; + const title = isDraft ? (hackathon.data as HackathonDraft).data.information?.name || 'Untitled Hackathon' : (hackathon.data as Hackathon).name || 'Untitled Hackathon'; - setHackathonToDelete({ id: hackathonId, title }); + setHackathonToDelete({ id: hackathonId, title, isDraft }); setDeleteDialogOpen(true); } }; diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx new file mode 100644 index 000000000..f599da39f --- /dev/null +++ b/app/me/earnings/page.tsx @@ -0,0 +1,271 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + IconCurrencyDollar, + IconClock, + IconCheck, + IconTrophy, + IconBriefcase, + IconUsers, + IconTarget, +} from '@tabler/icons-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + getUserEarnings, + 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; +} + +/** + * ActivityItem component for displaying a single reward entry. + */ +const ActivityItem: React.FC = ({ activity }) => ( +
+
+
{activity.title}
+
+ {activity.source} + + {new Date(activity.occurredAt).toLocaleDateString()} +
+
+
+

${activity.amount.toLocaleString()}

+ {activity.currency && ( +

{activity.currency}

+ )} +
+
+); + +/** + * EarningsSkeleton component for loading states. + */ +const EarningsSkeleton: React.FC = () => ( +
+
+ + +
+
+ + + +
+
+ + +
+
+); + +/** + * EarningsPage component for managing and tracking user rewards. + */ +const EarningsPage: React.FC = () => { + const [loading, setLoading] = useState(true); + const [data, setData] = 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(); + }, []); + + if (loading) { + return ; + } + + if (!data) { + return ( +
+

+ No earnings 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 => ( + + )) + )} +
+
+
+
+
+ ); +}; + +export default EarningsPage; 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/hooks/hackathon/use-delete-hackathon.ts b/hooks/hackathon/use-delete-hackathon.ts index 4ef6d5780..6f1e1b400 100644 --- a/hooks/hackathon/use-delete-hackathon.ts +++ b/hooks/hackathon/use-delete-hackathon.ts @@ -1,66 +1,81 @@ -'use client'; - import { useState, useCallback } from 'react'; import { deleteHackathon } from '@/lib/api/hackathons'; +import { deleteDraft } from '@/lib/api/hackathons/draft'; import { useAuthStatus } from '@/hooks/use-auth'; import { toast } from 'sonner'; -interface UseDeleteHackathonOptions { - organizationId: string; - hackathonId: string; - onSuccess?: () => void; - onError?: (error: string) => void; -} +type UseDeleteHackathonOptions = + | { + isDraft: true; + organizationId: string; + hackathonId: string; + onSuccess?: () => void; + onError?: (error: string) => void; + } + | { + isDraft?: false; + organizationId?: string; + hackathonId: string; + onSuccess?: () => void; + onError?: (error: string) => void; + }; -export function useDeleteHackathon({ - organizationId, - hackathonId, - onSuccess, - onError, -}: UseDeleteHackathonOptions) { +/** + * Hook to handle hackathon and draft deletion with proper API routing and feedback. + */ +export const useDeleteHackathon = (options: UseDeleteHackathonOptions) => { + const { isDraft = false, organizationId, hackathonId, onSuccess, onError } = options; const { isAuthenticated } = useAuthStatus(); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); const deleteHackathonAction = useCallback(async () => { - if (!isAuthenticated) { - toast.error('Please sign in to delete hackathons'); - throw new Error('Authentication required'); - } - - if (!organizationId || !hackathonId) { - toast.error('Organization ID and Hackathon ID are required'); - throw new Error('Organization ID and Hackathon ID are required'); - } + const targetLabel = isDraft ? 'draft' : 'hackathon'; + const idLabel = isDraft ? 'Draft ID' : 'Hackathon ID'; setIsDeleting(true); setError(null); try { - const response = await deleteHackathon(hackathonId); + if (!isAuthenticated) { + throw new Error(`Please sign in to delete ${targetLabel}s`); + } + + if (!hackathonId) { + throw new Error(`${idLabel} is required`); + } + + if (isDraft && !organizationId) { + throw new Error('Organization ID is required for draft deletion'); + } + + const response = isDraft + ? await deleteDraft(organizationId, hackathonId) + : await deleteHackathon(hackathonId); if (response.success) { - toast.success('Hackathon deleted successfully'); + toast.success(`${isDraft ? 'Draft' : 'Hackathon'} deleted successfully`); onSuccess?.(); return response.data; } else { - throw new Error(response.message || 'Failed to delete hackathon'); + throw new Error(response.message || `Failed to delete ${targetLabel}`); } } catch (err) { const errorMessage = - err instanceof Error ? err.message : 'Failed to delete hackathon'; + err instanceof Error ? err.message : `Failed to delete ${targetLabel}`; setError(errorMessage); + console.error(`Error deleting ${targetLabel}:`, err); toast.error(errorMessage); onError?.(errorMessage); - throw err; + // We don't re-throw here to allow callers to handle state via hook instead of try/catch } finally { setIsDeleting(false); } - }, [organizationId, hackathonId, isAuthenticated, onSuccess, onError]); + }, [organizationId, hackathonId, isDraft, isAuthenticated, onSuccess, onError]); return { isDeleting, error, deleteHackathon: deleteHackathonAction, }; -} +}; diff --git a/lib/api/hackathons/draft.ts b/lib/api/hackathons/draft.ts index d0bf77c8a..b50d69600 100644 --- a/lib/api/hackathons/draft.ts +++ b/lib/api/hackathons/draft.ts @@ -119,3 +119,16 @@ export const getDrafts = async ( return res.data as GetDraftsResponse; }; + +/** + * Delete a hackathon draft + */ +export const deleteDraft = async ( + organizationId: string, + draftId: string +): Promise> => { + const res = await api.delete>( + `/organizations/${organizationId}/hackathons/draft/${draftId}` + ); + return res.data; +}; diff --git a/lib/api/user/earnings.ts b/lib/api/user/earnings.ts new file mode 100644 index 000000000..a86d7dc13 --- /dev/null +++ b/lib/api/user/earnings.ts @@ -0,0 +1,64 @@ +import { api } from '../api'; +import { ApiResponse } from '../types'; + +export interface EarningActivity { + id: string; + source: 'hackathons' | 'grants' | 'crowdfunding' | 'bounties'; + title: string; + amount: number; + currency: string; + occurredAt: string; +} + +export interface EarningsData { + summary: { + 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; +};