diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 743a525..9cdef84 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -5,6 +5,10 @@ import { useBounties } from "@/hooks/use-bounties"; import { ReputationCard } from "@/components/reputation/reputation-card"; import { CompletionHistory } from "@/components/reputation/completion-history"; import { MyClaims, type MyClaim } from "@/components/reputation/my-claims"; +import { + EarningsSummary, + type EarningsSummary as EarningsSummaryType, +} from "@/components/reputation/earnings-summary"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -13,185 +17,264 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useMemo } from "react"; +function deriveBountyStatus(bounty: { + status: string; + claimExpiresAt?: string | null; +}): "completed" | "in-review" | "active" { + if (bounty.status === "closed") return "completed"; + if (bounty.status === "claimed" && bounty.claimExpiresAt) { + const expiry = new Date(bounty.claimExpiresAt); + if (!Number.isNaN(expiry.getTime()) && expiry < new Date()) { + return "in-review"; + } + } + return "active"; +} + export default function ProfilePage() { - const params = useParams(); - const userId = params.userId as string; - const { data: reputation, isLoading, error } = useContributorReputation(userId); - const { data: bountyResponse } = useBounties(); - - const MAX_MOCK_HISTORY = 50; - - const mockHistory = useMemo(() => { - if (!reputation) return []; - const count = Math.min(reputation.stats.totalCompleted ?? 0, MAX_MOCK_HISTORY); - return Array(count).fill(null).map((_, i) => ({ - id: `bounty-${i}`, - bountyId: `b-${i}`, - bountyTitle: `Implemented feature #${100 + i}`, - projectName: "Drips Protocol", - projectLogoUrl: null, - difficulty: ["BEGINNER", "INTERMEDIATE", "ADVANCED"][i % 3] as "BEGINNER" | "INTERMEDIATE" | "ADVANCED", - rewardAmount: 500, - rewardCurrency: "USDC", - claimedAt: "2023-01-01T00:00:00Z", - completedAt: "2024-01-15T12:00:00Z", - completionTimeHours: 48, - maintainerRating: 5, - maintainerFeedback: "Great work!", - pointsEarned: 150 - })); - }, [reputation]); - - const myClaims = useMemo(() => { - const bounties = bountyResponse?.data ?? []; - - return bounties - .filter((bounty) => bounty.claimedBy === userId) - .map((bounty) => { - let status = "active"; - - if (bounty.status === "closed") { - status = "completed"; - } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { - const claimExpiry = new Date(bounty.claimExpiresAt); - if (!Number.isNaN(claimExpiry.getTime()) && claimExpiry < new Date()) { - status = "in-review"; - } - } - - return { - bountyId: bounty.id, - title: bounty.issueTitle, - status, - rewardAmount: bounty.rewardAmount ?? undefined, - }; - }); - }, [bountyResponse?.data, userId]); - - if (isLoading) { - return ( -
- -
- - -
-
- ); + const params = useParams(); + const userId = params.userId as string; + const { + data: reputation, + isLoading, + error, + } = useContributorReputation(userId); + const { + data: bountyResponse, + isLoading: isBountiesLoading, + error: bountiesError, + } = useBounties(); + + const completionHistory = useMemo(() => { + const bounties = bountyResponse?.data ?? []; + return bounties + .filter((b) => b.claimedBy === userId && b.status === "closed") + .map((b) => ({ + id: b.id, + bountyId: b.id, + bountyTitle: b.issueTitle, + projectName: b.projectName, + projectLogoUrl: b.projectLogoUrl, + difficulty: (b.difficulty?.toUpperCase() ?? "BEGINNER") as + | "BEGINNER" + | "INTERMEDIATE" + | "ADVANCED", + rewardAmount: b.rewardAmount ?? 0, + rewardCurrency: b.rewardCurrency, + claimedAt: b.claimedAt ?? b.createdAt, + completedAt: b.updatedAt, + completionTimeHours: 0, + maintainerRating: null, + maintainerFeedback: null, + pointsEarned: 0, + })); + }, [bountyResponse?.data, userId]); + + const earningsSummary = useMemo(() => { + const currency = reputation?.stats.earningsCurrency ?? "USDC"; + const allBounties = bountyResponse?.data ?? []; + const userBounties = allBounties.filter((b) => b.claimedBy === userId); + + let totalEarned = 0; + let pendingAmount = 0; + const payoutHistory: EarningsSummaryType["payoutHistory"] = []; + + for (const bounty of userBounties) { + const amount = bounty.rewardAmount ?? 0; + const status = deriveBountyStatus(bounty); + + if (status === "completed") { + totalEarned += amount; + // Use claimExpiresAt as a proxy for payout date, fall back to createdAt. + const payoutDate = bounty.claimExpiresAt ?? bounty.createdAt; + payoutHistory.push({ amount, date: payoutDate, status: "completed" }); + } else if (status === "in-review") { + pendingAmount += amount; + // claimExpiresAt is guaranteed when status is "in-review" + payoutHistory.push({ + amount, + date: bounty.claimExpiresAt!, + status: "processing", + }); + } else { + pendingAmount += amount; + payoutHistory.push({ + amount, + date: bounty.claimExpiresAt ?? bounty.createdAt, + status: "processing", + }); + } } - if (error) { - // Check if it's a 404 (Not Found) - const apiError = error as { status?: number; message?: string }; - const isNotFound = apiError?.status === 404 || apiError?.message?.includes("404"); - - if (isNotFound) { - return ( -
- -

Profile Not Found

-

- We could not find a reputation profile for this user. -

- -
- ); - } - - // Generic Error - return ( -
- -

Something went wrong

-

- We encountered an error while loading the profile. -

- -
- ); + // If no real claim data, fall back to reputation stats + if (userBounties.length === 0 && reputation) { + totalEarned = reputation.stats.totalEarnings; + // pendingAmount has no reputation-stats equivalent; intentionally left as 0 } - if (!reputation) { - return ( -
- -

Profile Not Found

-

- We could not find a reputation profile for this user. -

- -
- ); + return { totalEarned, pendingAmount, currency, payoutHistory }; + }, [bountyResponse?.data, userId, reputation]); + + const myClaims = useMemo(() => { + const bounties = bountyResponse?.data ?? []; + + return bounties + .filter((bounty) => bounty.claimedBy === userId) + .map((bounty) => ({ + bountyId: bounty.id, + title: bounty.issueTitle, + status: deriveBountyStatus(bounty), + rewardAmount: bounty.rewardAmount ?? undefined, + })); + }, [bountyResponse?.data, userId]); + + if (isLoading || isBountiesLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (error) { + // Check if it's a 404 (Not Found) + const apiError = error as { status?: number; message?: string }; + const isNotFound = + apiError?.status === 404 || apiError?.message?.includes("404"); + + if (isNotFound) { + return ( +
+ +

Profile Not Found

+

+ We could not find a reputation profile for this user. +

+ +
+ ); } + // Generic Error + return ( +
+ +

Something went wrong

+

+ We encountered an error while loading the profile. +

+ +
+ ); + } + + if (!reputation) { return ( -
- - -
- {/* Left Sidebar: Reputation Card */} -
- - - {/* Additional Sidebar Info could go here */} +
+ +

Profile Not Found

+

+ We could not find a reputation profile for this user. +

+ +
+ ); + } + + return ( +
+ + +
+ {/* Left Sidebar: Reputation Card */} +
+ + + {/* Additional Sidebar Info could go here */} +
+ + {/* Main Content: Activity & History */} +
+ + + + Bounty History + + + Analytics + + + My Claims + + + + +

Activity History

+ {bountiesError ? ( +
+ + Failed to load bounty history. Please try again.
+ ) : ( + + )} +
+ + +
+ Detailed analytics coming soon. +
+
- {/* Main Content: Activity & History */} -
- - - - Bounty History - - - Analytics - - - My Claims - - - - -

Activity History

- -
- - -
- Detailed analytics coming soon. -
-
- - -

My Claims

- -
-
+ +

My Claims

+ {bountiesError ? ( +
+ + Failed to load claims and earnings. Please try again.
-
+ ) : ( +
+ + +
+ )} + +
- ); +
+
+ ); } diff --git a/components/reputation/earnings-summary.tsx b/components/reputation/earnings-summary.tsx new file mode 100644 index 0000000..c6028c6 --- /dev/null +++ b/components/reputation/earnings-summary.tsx @@ -0,0 +1,97 @@ +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatCurrency, formatDate } from "@/helpers/format.helper"; +import { TrendingUp, Clock } from "lucide-react"; + +export type EarningsSummary = { + totalEarned: number; + pendingAmount: number; + currency: string; + payoutHistory: Array<{ + amount: number; + date: string; + status: "processing" | "completed"; + }>; +}; + +interface EarningsSummaryProps { + earnings: EarningsSummary; +} + +export function EarningsSummary({ earnings }: EarningsSummaryProps) { + const currencySymbol = + earnings.currency === "USDC" ? "USDC" : earnings.currency; + + return ( +
+ {/* Summary Cards */} +
+ + + + + Total Earned + + + +

+ {formatCurrency(earnings.totalEarned, currencySymbol)} +

+
+
+ + + + + + Pending + + + +

+ {formatCurrency(earnings.pendingAmount, currencySymbol)} +

+
+
+
+ + {/* Payout History */} + + + Payout History + + + {earnings.payoutHistory.length === 0 ? ( +

No payouts yet.

+ ) : ( +
+ {earnings.payoutHistory.map((payout, index) => ( +
+
+ + {payout.status} + + + {formatDate(payout.date, "long")} + +
+ + {formatCurrency(payout.amount, currencySymbol)} + +
+ ))} +
+ )} +
+
+
+ ); +}