Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { useContributorReputation } from "@/hooks/use-reputation";
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 { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Expand All @@ -15,6 +17,7 @@ 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;

Expand All @@ -39,6 +42,32 @@ export default function ProfilePage() {
}));
}, [reputation]);

const myClaims = useMemo<MyClaim[]>(() => {
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 (
<div className="container mx-auto py-8">
Expand Down Expand Up @@ -134,6 +163,12 @@ export default function ProfilePage() {
>
Analytics
</TabsTrigger>
<TabsTrigger
value="claims"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3"
>
My Claims
</TabsTrigger>
</TabsList>

<TabsContent value="history" className="mt-6">
Expand All @@ -149,6 +184,11 @@ export default function ProfilePage() {
Detailed analytics coming soon.
</div>
</TabsContent>

<TabsContent value="claims" className="mt-6">
<h2 className="text-xl font-bold mb-4">My Claims</h2>
<MyClaims claims={myClaims} />
</TabsContent>
</Tabs>
</div>
</div>
Expand Down
37 changes: 37 additions & 0 deletions components/reputation/__tests__/my-claims.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { CLAIM_SECTIONS, getClaimsBySection, normalizeStatus, type MyClaim } from "@/components/reputation/my-claims";
import { describe, expect, it } from "@jest/globals";

describe("My Claims helpers", () => {
it("normalizes status values consistently", () => {
expect(normalizeStatus(" In Review ")).toBe("in-review");
expect(normalizeStatus("UNDER_REVIEW")).toBe("under-review");
expect(normalizeStatus("in_review")).toBe("in-review");
});

it("groups claims into Active Claims, In Review, and Completed by status", () => {
const claims: MyClaim[] = [
{ bountyId: "1", title: "Active A", status: "active" },
{ bountyId: "2", title: "Active B", status: "claimed" },
{ bountyId: "3", title: "Review A", status: "in review" },
{ bountyId: "4", title: "Review B", status: "UNDER_REVIEW" },
{ bountyId: "5", title: "Completed A", status: "completed" },
{ bountyId: "6", title: "Completed B", status: "closed" },
{ bountyId: "7", title: "Unknown", status: "queued" },
];

const groups = getClaimsBySection(claims);

expect(groups).toHaveLength(CLAIM_SECTIONS.length);

const activeGroup = groups.find((group) => group.section.title === "Active Claims");
const reviewGroup = groups.find((group) => group.section.title === "In Review");
const completedGroup = groups.find((group) => group.section.title === "Completed");

expect(activeGroup?.claims.map((claim) => claim.bountyId)).toEqual(["1", "2"]);
expect(reviewGroup?.claims.map((claim) => claim.bountyId)).toEqual(["3", "4"]);
expect(completedGroup?.claims.map((claim) => claim.bountyId)).toEqual(["5", "6"]);

const groupedIds = new Set(groups.flatMap((group) => group.claims.map((claim) => claim.bountyId)));
expect(groupedIds.has("7")).toBe(false);
});
});
103 changes: 103 additions & 0 deletions components/reputation/my-claims.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency } from "@/helpers/format.helper";

export type MyClaim = {
bountyId: string;
title: string;
status: string;
nextMilestone?: string;
rewardAmount?: number;
};

interface MyClaimsProps {
claims: MyClaim[];
}

export const CLAIM_SECTIONS: { title: string; statuses: string[] }[] = [
{ title: "Active Claims", statuses: ["active", "claimed", "in-progress"] },
{ title: "In Review", statuses: ["in-review", "in review", "review", "pending", "under-review"] },
{ title: "Completed", statuses: ["completed", "closed", "accepted", "done"] },
];

export function normalizeStatus(status: string) {
return status.trim().toLowerCase().replace(/[\s_]+/g, "-");
}

function formatStatusLabel(status: string) {
return status
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}

export function getClaimsBySection(claims: MyClaim[]) {
return CLAIM_SECTIONS.map((section) => ({
section,
claims: claims.filter((claim) => {
const normalizedClaimStatus = normalizeStatus(claim.status);
return section.statuses.some((status) => normalizeStatus(status) === normalizedClaimStatus);
}),
}));
}

export function MyClaims({ claims }: MyClaimsProps) {
return (
<div className="space-y-6">
{getClaimsBySection(claims).map(({ section, claims: sectionClaims }) => {

return (
<Card key={section.title} className="gap-4 py-5">
<CardHeader className="px-5">
<CardTitle className="text-lg">{section.title}</CardTitle>
<CardDescription>
{sectionClaims.length === 0
? "No claims in this section."
: `${sectionClaims.length} claim${sectionClaims.length === 1 ? "" : "s"}`}
</CardDescription>
</CardHeader>
<CardContent className="px-5">
{sectionClaims.length === 0 ? (
<p className="text-sm text-muted-foreground">No opportunities yet.</p>
) : (
<div className="space-y-3">
{sectionClaims.map((claim) => (
<div
key={`${section.title}-${claim.bountyId}`}
className="rounded-lg border border-border/60 bg-secondary/5 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<h4 className="truncate text-sm font-semibold" title={claim.title}>
{claim.title}
</h4>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatStatusLabel(claim.status)}</Badge>
{typeof claim.rewardAmount === "number" && (
<span className="text-sm text-muted-foreground">
{formatCurrency(claim.rewardAmount, "$")}
</span>
)}
</div>
{claim.nextMilestone && (
<p className="text-xs text-muted-foreground">
Next milestone: {claim.nextMilestone}
</p>
)}
</div>
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link href={`/bounty/${claim.bountyId}`}>View Opportunity</Link>
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
);
}
Loading