Skip to content
Merged
62 changes: 55 additions & 7 deletions app/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,7 +26,11 @@ export default function ProfilePage() {
isLoading,
error,
} = useContributorReputation(userId);
const { data: bountyResponse } = useBounties();
const {
data: bountyResponse,
isLoading: isBountiesLoading,
error: bountiesError,
} = useBounties();

const {
data: completionData,
Expand Down Expand Up @@ -65,7 +73,37 @@ export default function ProfilePage() {
});
}, [bountyResponse?.data, userId]);

if (isLoading) {
const earningsSummary = useMemo<EarningsSummaryType>(() => {
const bounties = bountyResponse?.data ?? [];

const summary: EarningsSummaryType = {
totalEarned: 0,
pendingAmount: 0,
currency: "USDC",
payoutHistory: [],
};

bounties.forEach((bounty) => {
if (bounty.status === "COMPLETED") {
const amount = Number(bounty.rewardAmount) || 0;
summary.totalEarned += amount;
summary.payoutHistory.push({
amount,
date: bounty.updatedAt || bounty.createdAt,
status: "completed",
});
} else if (
bounty.status === "SUBMITTED" ||
bounty.status === "DISPUTED"
) {
summary.pendingAmount += Number(bounty.rewardAmount) || 0;
}
});

return summary;
}, [bountyResponse?.data]);

if (isLoading || isBountiesLoading) {
return (
<div className="container mx-auto py-8">
<Skeleton className="h-10 w-32 mb-8" />
Expand All @@ -78,7 +116,6 @@ export default function ProfilePage() {
}

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");
Expand All @@ -98,7 +135,6 @@ export default function ProfilePage() {
);
}

// Generic Error
return (
<div className="container mx-auto py-16 text-center">
<AlertCircle className="w-12 h-12 mx-auto text-destructive mb-4" />
Expand Down Expand Up @@ -146,8 +182,6 @@ export default function ProfilePage() {
{/* Left Sidebar: Reputation Card */}
<div className="lg:col-span-4 space-y-6">
<ReputationCard reputation={reputation} />

{/* Additional Sidebar Info could go here */}
</div>

{/* Main Content: Activity & History */}
Expand Down Expand Up @@ -198,7 +232,21 @@ export default function ProfilePage() {

<TabsContent value="claims" className="mt-6">
<h2 className="text-xl font-bold mb-4">My Claims</h2>
<MyClaims claims={myClaims} />
{bountiesError ? (
<div className="flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertCircle className="w-4 h-4 shrink-0" />
Failed to load claims and earnings. Please try again.
</div>
) : (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
Earnings shown in {earningsSummary.currency} only. Bounties
in other currencies are not included.
</p>
<EarningsSummary earnings={earningsSummary} />
<MyClaims claims={myClaims} />
</div>
)}
</TabsContent>
</Tabs>
</div>
Expand Down
2 changes: 1 addition & 1 deletion codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
schema: "../boundless-nestjs/src/schema.gql",
schema: "./lib/graphql/schema.graphql",
documents: [
"lib/graphql/operations/**/*.graphql",
"lib/graphql/operations/**/*.ts",
Expand Down
96 changes: 96 additions & 0 deletions components/reputation/earnings-summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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;

return (
<div className="space-y-4">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Total Earned
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-2xl font-bold">
{formatCurrency(earnings.totalEarned, currencySymbol)}
</p>
</CardContent>
</Card>

<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="w-4 h-4" />
Pending
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-2xl font-bold">
{formatCurrency(earnings.pendingAmount, currencySymbol)}
</p>
</CardContent>
</Card>
</div>

{/* Payout History */}
<Card>
<CardHeader className="px-5 pt-5 pb-3">
<CardTitle className="text-base">Payout History</CardTitle>
</CardHeader>
<CardContent className="px-5 pb-5">
{earnings.payoutHistory.length === 0 ? (
<p className="text-sm text-muted-foreground">No payouts yet.</p>
) : (
<div className="space-y-2">
{earnings.payoutHistory.map((payout, index) => (
<div
key={index}
className="flex items-center justify-between rounded-lg border border-border/60 bg-secondary/5 px-4 py-3"
>
<div className="flex items-center gap-3">
<Badge
variant={
payout.status === "completed" ? "default" : "secondary"
}
className="capitalize"
>
{payout.status}
</Badge>
<span className="text-sm text-muted-foreground">
{formatDate(payout.date, "long")}
</span>
</div>
<span className="text-sm font-semibold">
{formatCurrency(payout.amount, currencySymbol)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
2 changes: 2 additions & 0 deletions lib/graphql/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { GraphQLClient } from "graphql-request";
import { isAuthStatus } from "./errors";

Expand Down
142 changes: 76 additions & 66 deletions lib/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,87 @@
import { describe, it, expect } from 'vitest';
import { BountyStore } from './store';
import { Application, Submission, MilestoneParticipation } from '@/types/participation';
import { describe, it, expect } from "vitest";
import { BountyStore } from "./store";
import {
Application,
Submission,
MilestoneParticipation,
} from "@/types/participation";

describe('BountyStore', () => {
// Note: Since BountyStore uses a global singleton, state might persist.
// Ideally we'd have a reset method, but for this basic verification we'll assume clean state or manage it.
// However, unit tests in Vitest usually run in isolation per file, but global state might persist if not reset.
// For now, let's just test distinct IDs.
describe("BountyStore", () => {
// Note: Since BountyStore uses a global singleton, state might persist.
// Ideally we'd have a reset method, but for this basic verification we'll assume clean state or manage it.
// However, unit tests in Vitest usually run in isolation per file, but global state might persist if not reset.
// For now, let's just test distinct IDs.

describe('Model 2: Applications', () => {
it('should add and retrieve an application', () => {
const app: Application = {
id: 'app-1',
bountyId: 'b-1',
applicantId: 'u-1',
coverLetter: 'Hire me',
status: 'pending',
submittedAt: new Date().toISOString()
};
BountyStore.addApplication(app);
const retrieved = BountyStore.getApplicationById('app-1');
expect(retrieved).toEqual(app);
const list = BountyStore.getApplicationsByBounty('b-1');
expect(list).toHaveLength(1);
});
describe("Model 2: Applications", () => {
it("should add and retrieve an application", () => {
const app: Application = {
id: "app-1",
bountyId: "b-1",
applicantId: "u-1",
coverLetter: "Hire me",
status: "pending",
submittedAt: new Date().toISOString(),
};
BountyStore.addApplication(app);
const retrieved = BountyStore.getApplicationById("app-1");
expect(retrieved).toEqual(app);
const list = BountyStore.getApplicationsByBounty("b-1");
expect(list).toHaveLength(1);
});

it('should update application status', () => {
const updated = BountyStore.updateApplication('app-1', { status: 'approved' });
expect(updated?.status).toBe('approved');
expect(BountyStore.getApplicationById('app-1')?.status).toBe('approved');
});
it("should update application status", () => {
const updated = BountyStore.updateApplication("app-1", {
status: "approved",
});
expect(updated?.status).toBe("approved");
expect(BountyStore.getApplicationById("app-1")?.status).toBe("approved");
});
});

describe('Model 3: Submissions', () => {
it('should add and retrieve a submission', () => {
const sub: Submission = {
id: 'sub-1',
bountyId: 'b-2',
contributorId: 'u-2',
content: 'My work',
status: 'pending',
submittedAt: new Date().toISOString()
};
BountyStore.addSubmission(sub);
expect(BountyStore.getSubmissionById('sub-1')).toEqual(sub);
});
describe("Model 3: Submissions", () => {
it("should add and retrieve a submission", () => {
const sub: Submission = {
id: "sub-1",
bountyId: "b-2",
contributorId: "u-2",
content: "My work",
explanation: "My work",
walletAddress: "GABC1234567890EXAMPLEWALLETADDRESS",
status: "pending",
submittedAt: new Date().toISOString(),
};
BountyStore.addSubmission(sub);
expect(BountyStore.getSubmissionById("sub-1")).toEqual(sub);
});

it('should update submission status', () => {
BountyStore.updateSubmission('sub-1', { status: 'accepted' });
expect(BountyStore.getSubmissionById('sub-1')?.status).toBe('accepted');
});
it("should update submission status", () => {
BountyStore.updateSubmission("sub-1", { status: "accepted" });
expect(BountyStore.getSubmissionById("sub-1")?.status).toBe("accepted");
});
});

describe('Model 4: Milestones', () => {
it('should join a milestone', () => {
const mp: MilestoneParticipation = {
id: 'mp-1',
bountyId: 'b-3',
contributorId: 'u-3',
currentMilestone: 1,
status: 'active',
joinedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString()
};
BountyStore.addMilestoneParticipation(mp);
const list = BountyStore.getMilestoneParticipationsByBounty('b-3');
expect(list).toHaveLength(1);
});
describe("Model 4: Milestones", () => {
it("should join a milestone", () => {
const mp: MilestoneParticipation = {
id: "mp-1",
bountyId: "b-3",
contributorId: "u-3",
currentMilestone: 1,
status: "active",
joinedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
BountyStore.addMilestoneParticipation(mp);
const list = BountyStore.getMilestoneParticipationsByBounty("b-3");
expect(list).toHaveLength(1);
});

it('should advance a milestone', () => {
BountyStore.updateMilestoneParticipation('mp-1', { currentMilestone: 2 });
const mp = BountyStore.getMilestoneParticipationsByBounty('b-3').find((p: MilestoneParticipation) => p.id === 'mp-1');
expect(mp?.currentMilestone).toBe(2);
});
it("should advance a milestone", () => {
BountyStore.updateMilestoneParticipation("mp-1", { currentMilestone: 2 });
const mp = BountyStore.getMilestoneParticipationsByBounty("b-3").find(
(p: MilestoneParticipation) => p.id === "mp-1",
);
expect(mp?.currentMilestone).toBe(2);
});
});
});
Loading