From e9307d5ae7cac91d63ad9afb12d9964f0c98c701 Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Sun, 22 Feb 2026 06:54:31 +0100 Subject: [PATCH 1/9] feat: Implement Earnings & Payout Tracking --- app/profile/[userId]/page.tsx | 437 +++++++++++++-------- components/reputation/earnings-summary.tsx | 97 +++++ 2 files changed, 363 insertions(+), 171 deletions(-) create mode 100644 components/reputation/earnings-summary.tsx diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 743a525..6085d0e 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -4,7 +4,15 @@ 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 { + MyClaims, + type MyClaim, + normalizeStatus, +} 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"; @@ -14,184 +22,271 @@ import { useParams } from "next/navigation"; import { useMemo } from "react"; 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 } = 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 earningsSummary = useMemo(() => { + const currency = reputation?.stats.earningsCurrency ?? "USDC"; + const completedStatuses = ["completed", "closed", "accepted", "done"]; + const pendingStatuses = [ + "active", + "claimed", + "in-progress", + "in-review", + "in review", + "review", + "pending", + "under-review", + ]; - 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. -

- -
- ); + 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; + let status = "active"; + if (bounty.status === "closed") { + status = "completed"; + } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { + const expiry = new Date(bounty.claimExpiresAt); + if (!Number.isNaN(expiry.getTime()) && expiry < new Date()) { + status = "in-review"; } + } - // Generic Error - return ( -
- -

Something went wrong

-

- We encountered an error while loading the profile. -

- -
- ); + const normalized = normalizeStatus(status); + if (completedStatuses.some((s) => normalizeStatus(s) === normalized)) { + totalEarned += amount; + payoutHistory.push({ + amount, + date: bounty.claimExpiresAt ?? new Date().toISOString(), + status: "completed", + }); + } else if ( + pendingStatuses.some((s) => normalizeStatus(s) === normalized) + ) { + pendingAmount += amount; + if (normalized === "in-review") { + payoutHistory.push({ + amount, + date: bounty.claimExpiresAt ?? new Date().toISOString(), + status: "processing", + }); + } + } } - if (!reputation) { - return ( -
- -

Profile Not Found

-

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

- -
- ); + // If no real claim data, fall back to reputation stats + if (userBounties.length === 0 && reputation) { + totalEarned = reputation.stats.totalEarnings; } + 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) => { + 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 ( -
- - -
- {/* Left Sidebar: Reputation Card */} -
- - - {/* Additional Sidebar Info could go here */} -
- - {/* Main Content: Activity & History */} -
- - - - Bounty History - - - Analytics - - - My Claims - - - - -

Activity History

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

My Claims

- -
-
-
-
+
+ +
+ +
+
); + } + + 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 ( +
+ +

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

+ +
+ + +
+ Detailed analytics coming soon. +
+
+ + +

My Claims

+
+ + +
+
+
+
+
+
+ ); } 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)} + +
+ ))} +
+ )} +
+
+
+ ); +} From 9c1214bceafb225380f4c2b5a91f2ff9e39c1af5 Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Sun, 22 Feb 2026 07:47:42 +0100 Subject: [PATCH 2/9] fix reviews --- app/profile/[userId]/page.tsx | 98 +++++++++++++---------------------- 1 file changed, 36 insertions(+), 62 deletions(-) diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 6085d0e..c98a619 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -4,11 +4,7 @@ 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, - normalizeStatus, -} from "@/components/reputation/my-claims"; +import { MyClaims, type MyClaim } from "@/components/reputation/my-claims"; import { EarningsSummary, type EarningsSummary as EarningsSummaryType, @@ -21,6 +17,20 @@ 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; @@ -64,18 +74,6 @@ export default function ProfilePage() { const earningsSummary = useMemo(() => { const currency = reputation?.stats.earningsCurrency ?? "USDC"; - const completedStatuses = ["completed", "closed", "accepted", "done"]; - const pendingStatuses = [ - "active", - "claimed", - "in-progress", - "in-review", - "in review", - "review", - "pending", - "under-review", - ]; - const allBounties = bountyResponse?.data ?? []; const userBounties = allBounties.filter((b) => b.claimedBy === userId); @@ -85,35 +83,27 @@ export default function ProfilePage() { for (const bounty of userBounties) { const amount = bounty.rewardAmount ?? 0; - let status = "active"; - if (bounty.status === "closed") { - status = "completed"; - } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { - const expiry = new Date(bounty.claimExpiresAt); - if (!Number.isNaN(expiry.getTime()) && expiry < new Date()) { - status = "in-review"; - } - } + const status = deriveBountyStatus(bounty); - const normalized = normalizeStatus(status); - if (completedStatuses.some((s) => normalizeStatus(s) === normalized)) { + if (status === "completed") { totalEarned += amount; - payoutHistory.push({ - amount, - date: bounty.claimExpiresAt ?? new Date().toISOString(), - status: "completed", - }); - } else if ( - pendingStatuses.some((s) => normalizeStatus(s) === normalized) - ) { - pendingAmount += amount; - if (normalized === "in-review") { + if (bounty.claimExpiresAt) { payoutHistory.push({ amount, - date: bounty.claimExpiresAt ?? new Date().toISOString(), - status: "processing", + date: bounty.claimExpiresAt, + 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; } } @@ -130,28 +120,12 @@ export default function ProfilePage() { 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, - }; - }); + .map((bounty) => ({ + bountyId: bounty.id, + title: bounty.issueTitle, + status: deriveBountyStatus(bounty), + rewardAmount: bounty.rewardAmount ?? undefined, + })); }, [bountyResponse?.data, userId]); if (isLoading) { From 3feca06d4d79e0e5dd5e11b18bd5d8b736f6b65e Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Sun, 22 Feb 2026 08:18:18 +0100 Subject: [PATCH 3/9] fix reviews --- app/profile/[userId]/page.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index c98a619..8d9e785 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -39,7 +39,7 @@ export default function ProfilePage() { isLoading, error, } = useContributorReputation(userId); - const { data: bountyResponse } = useBounties(); + const { data: bountyResponse, isLoading: isBountiesLoading } = useBounties(); const MAX_MOCK_HISTORY = 50; @@ -87,13 +87,9 @@ export default function ProfilePage() { if (status === "completed") { totalEarned += amount; - if (bounty.claimExpiresAt) { - payoutHistory.push({ - amount, - date: bounty.claimExpiresAt, - status: "completed", - }); - } + // 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" @@ -110,6 +106,7 @@ export default function ProfilePage() { // 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 } return { totalEarned, pendingAmount, currency, payoutHistory }; @@ -128,7 +125,7 @@ export default function ProfilePage() { })); }, [bountyResponse?.data, userId]); - if (isLoading) { + if (isLoading || isBountiesLoading) { return (
From d9e79c3fbcb8f48b92a09446b334130bc3ba2e44 Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Sun, 22 Feb 2026 15:54:18 +0100 Subject: [PATCH 4/9] fix reviews --- app/profile/[userId]/page.tsx | 82 ++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 8d9e785..b143603 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -39,38 +39,36 @@ export default function ProfilePage() { isLoading, error, } = useContributorReputation(userId); - const { data: bountyResponse, isLoading: isBountiesLoading } = useBounties(); - - const MAX_MOCK_HISTORY = 50; + const { + data: bountyResponse, + isLoading: isBountiesLoading, + error: bountiesError, + } = useBounties(); - 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 + 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: 500, - rewardCurrency: "USDC", - claimedAt: "2023-01-01T00:00:00Z", - completedAt: "2024-01-15T12:00:00Z", - completionTimeHours: 48, - maintainerRating: 5, - maintainerFeedback: "Great work!", - pointsEarned: 150, + rewardAmount: b.rewardAmount ?? 0, + rewardCurrency: b.rewardCurrency, + claimedAt: b.claimedAt ?? b.createdAt, + completedAt: b.updatedAt, + completionTimeHours: 0, + maintainerRating: null, + maintainerFeedback: null, + pointsEarned: 0, })); - }, [reputation]); + }, [bountyResponse?.data, userId]); const earningsSummary = useMemo(() => { const currency = reputation?.stats.earningsCurrency ?? "USDC"; @@ -236,10 +234,17 @@ export default function ProfilePage() {

Activity History

- + {bountiesError ? ( +
+ + Failed to load bounty history. Please try again. +
+ ) : ( + + )}
@@ -250,10 +255,17 @@ export default function ProfilePage() {

My Claims

-
- - -
+ {bountiesError ? ( +
+ + Failed to load claims and earnings. Please try again. +
+ ) : ( +
+ + +
+ )}
From 5fc38a6e92962c4f32eec08ff867b3eefbd488a2 Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Sun, 22 Feb 2026 16:20:56 +0100 Subject: [PATCH 5/9] fix reviews --- app/profile/[userId]/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index b143603..9cdef84 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -98,6 +98,11 @@ export default function ProfilePage() { }); } else { pendingAmount += amount; + payoutHistory.push({ + amount, + date: bounty.claimExpiresAt ?? bounty.createdAt, + status: "processing", + }); } } From da695fc559abfdd9faf504fc73faebea966d426d Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Mon, 23 Feb 2026 01:41:15 +0100 Subject: [PATCH 6/9] fix review and update branch --- app/api/bounties/[id]/submit/route.ts | 119 +- .../[userId]/completion-history/route.ts | 77 + app/api/transparency/payouts/route.ts | 28 +- app/api/transparency/stats/route.ts | 18 +- app/profile/[userId]/page.tsx | 73 +- app/transparency/page.tsx | 419 ++--- codegen.ts | 39 +- .../bounty-detail-sidebar-cta.tsx | 28 +- .../bounty-detail/submission-dialog.tsx | 354 +++++ components/bounty/forms/schemas.ts | 73 +- components/global-navbar.tsx | 4 +- components/login/sign-in.tsx | 19 +- .../reputation/__tests__/my-claims.test.ts | 76 +- components/reputation/my-claims.tsx | 182 ++- hooks/use-reputation.ts | 114 +- hooks/use-transparency.ts | 33 +- lib/api/bounties.ts | 162 +- lib/api/reputation.ts | 70 +- lib/api/transparency.ts | 40 +- lib/graphql/client.ts | 307 +--- lib/graphql/generated.ts | 139 ++ package-lock.json | 1415 +++++++++++++---- package.json | 3 +- providers/query-provider.tsx | 33 +- types/participation.ts | 8 +- 25 files changed, 2574 insertions(+), 1259 deletions(-) create mode 100644 app/api/reputation/[userId]/completion-history/route.ts create mode 100644 components/bounty-detail/submission-dialog.tsx create mode 100644 lib/graphql/generated.ts diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index 2ef4cf7..a870603 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -1,55 +1,96 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; -import { Submission } from '@/types/participation'; +import { NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import { Submission } from "@/types/participation"; +import { submissionFormSchema } from "@/components/bounty/forms/schemas"; +import { getCurrentUser } from "@/lib/server-auth"; const generateId = () => crypto.randomUUID(); export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } + request: Request, + { params }: { params: Promise<{ id: string }> }, ) { - const { id: bountyId } = await params; + const { id: bountyId } = await params; - try { - const body = await request.json(); - const { contributorId, content } = body; + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const contributorId = user.id; - if (!contributorId || !content) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } + const body = await request.json(); + const { contributorId: _clientContributorId, ...formData } = body; - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } + const parsed = submissionFormSchema.safeParse(formData); + if (!parsed.success) { + const fieldErrors = parsed.error.flatten().fieldErrors; + return NextResponse.json( + { error: "Validation failed", fieldErrors }, + { status: 400 }, + ); + } - const allowedModels = ['single-claim', 'competition', 'multi-winner', 'application']; - if (!allowedModels.includes(bounty.claimingModel)) { - return NextResponse.json({ error: 'Submission not allowed for this bounty type' }, { status: 400 }); - } + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } - const existingSubmission = BountyStore.getSubmissionsByBounty(bountyId).find( - s => s.contributorId === contributorId - ); + if (bounty.status !== "open") { + return NextResponse.json( + { error: "Submissions are not accepted for this bounty" }, + { status: 400 }, + ); + } - if (existingSubmission) { - return NextResponse.json({ error: 'Duplicate submission' }, { status: 409 }); - } + const allowedModels = [ + "single-claim", + "competition", + "multi-winner", + "application", + ]; + if (!allowedModels.includes(bounty.claimingModel)) { + return NextResponse.json( + { error: "Submission not allowed for this bounty type" }, + { status: 400 }, + ); + } - const submission: Submission = { - id: generateId(), - bountyId, - contributorId, - content, - status: 'pending', - submittedAt: new Date().toISOString(), - }; + const existingSubmission = BountyStore.getSubmissionsByBounty( + bountyId, + ).find((s) => s.contributorId === contributorId); - BountyStore.addSubmission(submission); + if (existingSubmission) { + return NextResponse.json( + { error: "Duplicate submission" }, + { status: 409 }, + ); + } - return NextResponse.json({ success: true, data: submission }); + const { explanation, walletAddress, githubUrl, demoUrl, attachments } = + parsed.data; - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } + const submission: Submission = { + id: generateId(), + bountyId, + contributorId, + content: explanation, + explanation, + walletAddress, + githubUrl: githubUrl || undefined, + demoUrl: demoUrl || undefined, + attachments: attachments?.length ? attachments : undefined, + status: "pending", + submittedAt: new Date().toISOString(), + }; + + BountyStore.addSubmission(submission); + + return NextResponse.json({ success: true, data: submission }); + } catch { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/app/api/reputation/[userId]/completion-history/route.ts b/app/api/reputation/[userId]/completion-history/route.ts new file mode 100644 index 0000000..d98b87b --- /dev/null +++ b/app/api/reputation/[userId]/completion-history/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import type { BountyCompletionRecord } from "@/types/reputation"; +import type { Bounty } from "@/types/bounty"; + +const DIFFICULTY_MAP = { + beginner: "BEGINNER" as const, + intermediate: "INTERMEDIATE" as const, + advanced: "ADVANCED" as const, +}; + +function bountyToCompletionRecord( + bounty: Bounty, + index: number, +): BountyCompletionRecord { + const difficulty = bounty.difficulty + ? (DIFFICULTY_MAP[bounty.difficulty] ?? "BEGINNER") + : "BEGINNER"; + const reward = bounty.rewardAmount ?? 0; + const claimedAt = bounty.claimedAt ?? bounty.createdAt; + const completedAt = bounty.updatedAt; + + return { + id: `completion-${bounty.id}-${index}`, + bountyId: bounty.id, + bountyTitle: bounty.issueTitle, + projectName: bounty.projectName, + projectLogoUrl: bounty.projectLogoUrl, + difficulty, + rewardAmount: reward, + rewardCurrency: bounty.rewardCurrency, + claimedAt, + completedAt, + completionTimeHours: 0, + maintainerRating: null, + maintainerFeedback: null, + pointsEarned: reward, + }; +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ userId: string }> }, +) { + try { + const { userId } = await context.params; + const { searchParams } = new URL(request.url); + const limit = Math.min( + Math.max(1, Number(searchParams.get("limit")) || 50), + 100, + ); + const offset = Math.max(0, Number(searchParams.get("offset")) || 0); + + const bounties = BountyStore.getBounties(); + const completed = bounties.filter( + (b) => b.status === "closed" && b.claimedBy === userId, + ); + + const totalCount = completed.length; + const paginated = completed.slice(offset, offset + limit); + const records: BountyCompletionRecord[] = paginated.map((b, i) => + bountyToCompletionRecord(b, offset + i), + ); + + return NextResponse.json({ + records, + totalCount, + hasMore: offset + records.length < totalCount, + }); + } catch (error) { + console.error("Error fetching completion history:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/transparency/payouts/route.ts b/app/api/transparency/payouts/route.ts index d74dc48..4ef5581 100644 --- a/app/api/transparency/payouts/route.ts +++ b/app/api/transparency/payouts/route.ts @@ -1,19 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const limit = Number(searchParams.get("limit")) || 10; + const { searchParams } = new URL(request.url); + const limit = Number(searchParams.get("limit")) || 10; - // TODO: Replace with real DB queries when backend is ready - const payouts: { - id: string; - contributorName: string; - contributorAvatar: string | null; - amount: number; - currency: string; - projectName: string; - paidAt: string; - }[] = []; + // TODO: Replace with real DB queries when backend is ready + const payouts: { + id: string; + contributorName: string; + contributorAvatar: string | null; + amount: number; + currency: string; + projectName: string; + paidAt: string; + }[] = []; - return NextResponse.json(payouts.slice(0, limit)); -} \ No newline at end of file + return NextResponse.json(payouts.slice(0, limit)); +} diff --git a/app/api/transparency/stats/route.ts b/app/api/transparency/stats/route.ts index baf83b3..2c2e815 100644 --- a/app/api/transparency/stats/route.ts +++ b/app/api/transparency/stats/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server"; export async function GET() { - // TODO: Replace with real DB queries when backend is ready - const stats = { - totalFundsDistributed: 0, - totalContributorsPaid: 0, - totalProjectsFunded: 0, - averagePayoutTimeDays: 0, - }; + // TODO: Replace with real DB queries when backend is ready + const stats = { + totalFundsDistributed: 0, + totalContributorsPaid: 0, + totalProjectsFunded: 0, + averagePayoutTimeDays: 0, + }; - return NextResponse.json(stats); -} \ No newline at end of file + return NextResponse.json(stats); +} diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 9cdef84..a2953fb 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -1,6 +1,9 @@ "use client"; -import { useContributorReputation } from "@/hooks/use-reputation"; +import { + useContributorReputation, + useCompletionHistory, +} from "@/hooks/use-reputation"; import { useBounties } from "@/hooks/use-bounties"; import { ReputationCard } from "@/components/reputation/reputation-card"; import { CompletionHistory } from "@/components/reputation/completion-history"; @@ -20,12 +23,12 @@ import { useMemo } from "react"; function deriveBountyStatus(bounty: { status: string; claimExpiresAt?: string | null; -}): "completed" | "in-review" | "active" { +}): "completed" | "expired" | "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 "expired"; } } return "active"; @@ -44,36 +47,18 @@ export default function ProfilePage() { isLoading: isBountiesLoading, error: bountiesError, } = useBounties(); + const { data: completionData, isLoading: completionLoading } = + useCompletionHistory(userId); - 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 completionRecords = completionData?.records ?? []; const earningsSummary = useMemo(() => { const currency = reputation?.stats.earningsCurrency ?? "USDC"; const allBounties = bountyResponse?.data ?? []; - const userBounties = allBounties.filter((b) => b.claimedBy === userId); + const userBounties = allBounties.filter( + (b) => + b.claimedBy === userId && (b.rewardCurrency ?? "USDC") === currency, + ); let totalEarned = 0; let pendingAmount = 0; @@ -88,15 +73,9 @@ export default function ProfilePage() { // 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 { + } else if (status === "active") { + // "active" — claimed but not yet submitted; include in history so + // the "Pending" card total matches the sum of history rows. pendingAmount += amount; payoutHistory.push({ amount, @@ -104,6 +83,7 @@ export default function ProfilePage() { status: "processing", }); } + // "expired" claims are forfeited; omit from both totals and history } // If no real claim data, fall back to reputation stats @@ -141,7 +121,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"); @@ -161,7 +140,6 @@ export default function ProfilePage() { ); } - // Generic Error return (
@@ -209,8 +187,6 @@ export default function ProfilePage() { {/* Left Sidebar: Reputation Card */}
- - {/* Additional Sidebar Info could go here */}
{/* Main Content: Activity & History */} @@ -239,15 +215,16 @@ export default function ProfilePage() {

Activity History

- {bountiesError ? ( -
- - Failed to load bounty history. Please try again. -
+ {completionLoading ? ( + ) : ( 0 + ? `Showing the last ${completionRecords.length} completed bounties.` + : undefined + } /> )}
diff --git a/app/transparency/page.tsx b/app/transparency/page.tsx index d75abd6..39dc2f6 100644 --- a/app/transparency/page.tsx +++ b/app/transparency/page.tsx @@ -1,7 +1,14 @@ "use client"; +import type { ElementType } from "react"; import { usePlatformStats, useRecentPayouts } from "@/hooks/use-transparency"; -import { AlertCircle, DollarSign, Users, FolderOpen, Clock } from "lucide-react"; +import { + AlertCircle, + DollarSign, + Users, + FolderOpen, + Clock, +} from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -10,221 +17,235 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import type { RecentPayout } from "@/lib/api/transparency"; -// Stat Card +// Stat Card function StatCard({ - title, - value, - icon: Icon, - isLoading, + title, + value, + icon: Icon, + isLoading, }: { - title: string; - value: string; - icon: React.ElementType; - isLoading: boolean; + title: string; + value: string; + icon: ElementType; + isLoading: boolean; }) { - return ( - - - - {title} - - - - - {isLoading ? ( - - ) : ( -

{value}

- )} -
-
- ); + return ( + + + + {title} + + + + + {isLoading ? ( + + ) : ( +

{value}

+ )} +
+
+ ); } -// Payout Row +// Payout Row function PayoutRow({ payout }: { payout: RecentPayout }) { - return ( -
-
- - - - {payout.contributorName.slice(0, 2).toUpperCase()} - - -
-

- {payout.contributorName} -

-

{payout.projectName}

-
-
-
- - {payout.amount.toLocaleString()} {payout.currency} - - - {new Date(payout.paidAt).toLocaleDateString()} - -
+ return ( +
+
+ + + + {payout.contributorName.slice(0, 2).toUpperCase()} + + +
+

+ {payout.contributorName} +

+

{payout.projectName}

- ); +
+
+ + {payout.amount.toLocaleString()} {payout.currency} + + + {new Date(payout.paidAt).toLocaleDateString()} + +
+
+ ); } -// Page +// Page export default function TransparencyPage() { - const { - data: stats, - isLoading: statsLoading, - isError: statsError, - error: statsErr, - refetch: refetchStats, - } = usePlatformStats(); + const { + data: stats, + isLoading: statsLoading, + isError: statsError, + error: statsErr, + refetch: refetchStats, + } = usePlatformStats(); - const { - data: payouts, - isLoading: payoutsLoading, - isError: payoutsError, - refetch: refetchPayouts, - } = useRecentPayouts(10); + const { + data: payouts, + isLoading: payoutsLoading, + isError: payoutsError, + refetch: refetchPayouts, + } = useRecentPayouts(10); - const statCards = [ - { - title: "Total Funds Distributed", - value: stats - ? `$${stats.totalFundsDistributed.toLocaleString()}` - : "$0", - icon: DollarSign, - }, - { - title: "Contributors Paid", - value: stats ? stats.totalContributorsPaid.toLocaleString() : "0", - icon: Users, - }, - { - title: "Projects Funded", - value: stats ? stats.totalProjectsFunded.toLocaleString() : "0", - icon: FolderOpen, - }, - { - title: "Avg. Payout Time", - value: stats ? `${stats.averagePayoutTimeDays} days` : "0 days", - icon: Clock, - }, - ]; + const statCards = [ + { + title: "Total Funds Distributed", + value: statsError + ? "—" + : stats + ? `$${stats.totalFundsDistributed.toLocaleString()}` + : "$0", + icon: DollarSign, + }, + { + title: "Contributors Paid", + value: statsError + ? "—" + : stats + ? stats.totalContributorsPaid.toLocaleString() + : "0", + icon: Users, + }, + { + title: "Projects Funded", + value: statsError + ? "—" + : stats + ? stats.totalProjectsFunded.toLocaleString() + : "0", + icon: FolderOpen, + }, + { + title: "Avg. Payout Time", + value: statsError + ? "—" + : stats + ? `${stats.averagePayoutTimeDays} days` + : "0 days", + icon: Clock, + }, + ]; - return ( -
- {/* Hero Header */} -
-
-

- Transparency -

-

- A real-time look at platform funding activity, contributor payouts, - and ecosystem growth. -

-
-
- -
+ return ( +
+ {/* Hero Header */} +
+
+

+ Transparency +

+

+ A real-time look at platform funding activity, contributor payouts, + and ecosystem growth. +

+
+
- {/* Stats Error */} - {statsError && ( - - - Error - -

- Failed to load platform stats.{" "} - {(statsErr as Error)?.message} -

- -
-
- )} +
+ {/* Stats Error */} + {statsError && ( + + + Error + +

+ Failed to load platform stats. {(statsErr as Error)?.message} +

+ +
+
+ )} - {/* Stats Grid */} -
-

- Platform Overview -

-
- {statCards.map((card) => ( - - ))} -
-
+ {/* Stats Grid - hidden when error so zeros aren't shown */} + {!statsError && ( +
+

+ Platform Overview +

+
+ {statCards.map((card) => ( + + ))} +
+
+ )} - {/* Recent Payouts */} -
-

- Recent Payouts -

+ {/* Recent Payouts */} +
+

+ Recent Payouts +

- {payoutsError ? ( - - - Error - -

Failed to load recent payouts.

- -
-
- ) : ( - - - {payoutsLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - -
- -
- ))} -
- ) : !payouts || payouts.length === 0 ? ( -

- No payouts recorded yet. -

- ) : ( - payouts.map((payout) => ( - - )) - )} -
-
- )} -
-
-
- ); -} \ No newline at end of file + {payoutsError ? ( + + + Error + +

Failed to load recent payouts.

+ +
+
+ ) : ( + + + {payoutsLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : !payouts || payouts.length === 0 ? ( +

+ No payouts recorded yet. +

+ ) : ( + payouts.map((payout) => ( + + )) + )} +
+
+ )} + +
+
+ ); +} diff --git a/codegen.ts b/codegen.ts index 54d12d6..768a827 100644 --- a/codegen.ts +++ b/codegen.ts @@ -1,35 +1,34 @@ -import type { CodegenConfig } from '@graphql-codegen/cli'; +import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { - schema: './lib/graphql/schema.graphql', - documents: ['lib/graphql/**/*.ts', 'lib/graphql/**/*.tsx', 'hooks/**/*.ts', 'hooks/**/*.tsx'], + schema: "../boundless-nestjs/src/schema.gql", + documents: [ + "lib/graphql/**/*.ts", + "lib/graphql/**/*.tsx", + "hooks/**/*.ts", + "hooks/**/*.tsx", + ], ignoreNoDocuments: true, generates: { - './lib/graphql/generated.ts': { + "./lib/graphql/generated.ts": { plugins: [ - 'typescript', - 'typescript-operations', - 'typescript-react-apollo', + "typescript", + "typescript-operations", + "typescript-react-query", ], config: { - withHooks: true, - withComponent: false, - withHOC: false, - skipTypename: false, - enumsAsTypes: true, - dedupeFragments: true, - avoidOptionals: { - field: false, - inputValue: false, - object: false, + fetcher: { + func: "./client#fetcher", + isReactHook: false, }, + exposeQueryKeys: true, scalars: { - DateTime: 'string', - JSON: 'Record', + DateTime: "string", + JSON: "Record", }, }, }, }, }; -export default config; \ No newline at end of file +export default config; diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index a26cf5d..730f5a4 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -8,9 +8,11 @@ import { Separator } from "@/components/ui/separator"; import type { Bounty } from "@/lib/api"; import { DifficultyBadge, StatusBadge } from "./bounty-badges"; import { CLAIMING_MODEL_CONFIG } from "@/lib/bounty-config"; +import { SubmissionDialog } from "./submission-dialog"; export function SidebarCTA({ bounty }: { bounty: Bounty }) { const [copied, setCopied] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); const canAct = bounty.status === "open"; const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; const ClaimIcon = claimCfg.icon; @@ -102,14 +104,18 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) { className="w-full h-11 font-bold tracking-wide" disabled={!canAct} size="lg" - onClick={() => - canAct && - window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer") - } + onClick={() => canAct && setDialogOpen(true)} > {ctaLabel()} + + {!canAct && (

@@ -169,8 +175,8 @@ export function ClaimModelInfo({ } export function MobileCTA({ bounty }: { bounty: Bounty }) { + const [dialogOpen, setDialogOpen] = useState(false); const canAct = bounty.status === "open"; - // const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; const label = () => { if (!canAct) @@ -193,13 +199,17 @@ export function MobileCTA({ bounty }: { bounty: Bounty }) { className="w-full h-11 font-bold tracking-wide" disabled={!canAct} size="lg" - onClick={() => - canAct && - window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer") - } + onClick={() => canAct && setDialogOpen(true)} > {label()} + +

); } diff --git a/components/bounty-detail/submission-dialog.tsx b/components/bounty-detail/submission-dialog.tsx new file mode 100644 index 0000000..bb6c007 --- /dev/null +++ b/components/bounty-detail/submission-dialog.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { Plus, Trash2, Save, Send, Loader2 } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { + submissionFormSchema, + type SubmissionFormValue, +} from "@/components/bounty/forms/schemas"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { bountiesApi } from "@/lib/api/bounties"; +import { authClient } from "@/lib/auth-client"; +import { mockWalletInfo } from "@/lib/mock-wallet"; + +interface SubmissionDialogProps { + bountyId: string; + bountyTitle: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const getBaseDefaults = (): SubmissionFormValue => ({ + githubUrl: "", + demoUrl: "", + explanation: "", + attachments: [], + walletAddress: mockWalletInfo.address, +}); + +export function SubmissionDialog({ + bountyId, + bountyTitle, + open, + onOpenChange, +}: SubmissionDialogProps) { + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const timeoutRef = useRef | null>(null); + const { data: session } = authClient.useSession(); + const storageKey = `submission-draft-${bountyId}`; + const [draft, setDraft] = useLocalStorage( + storageKey, + null, + ); + + const baseDefaults = getBaseDefaults(); + + const form = useForm({ + resolver: zodResolver(submissionFormSchema), + defaultValues: draft + ? { ...draft, walletAddress: baseDefaults.walletAddress } + : baseDefaults, + }); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "attachments" as never, + }); + + useEffect(() => { + if (open && draft) { + form.reset({ ...draft, walletAddress: baseDefaults.walletAddress }); + } else if (open) { + form.reset(baseDefaults); + } + if (open) { + setSubmitted(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const saveDraft = useCallback(() => { + const values = form.getValues(); + setDraft(values); + toast.success("Draft saved"); + onOpenChange(false); + }, [form, setDraft, onOpenChange]); + + const clearDraft = useCallback(() => { + setDraft(null); + }, [setDraft]); + + const onSubmit = async (data: SubmissionFormValue) => { + const contributorId = session?.user?.id; + if (!contributorId) { + toast.error("You must be signed in to submit."); + return; + } + + setSubmitting(true); + try { + const payload = { + ...data, + githubUrl: data.githubUrl || undefined, + demoUrl: data.demoUrl || undefined, + attachments: data.attachments?.filter(Boolean), + contributorId, + }; + + await bountiesApi.submit(bountyId, payload); + + clearDraft(); + form.reset(baseDefaults); + setSubmitted(true); + toast.success("Submission sent successfully!"); + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + onOpenChange(false); + setSubmitted(false); + }, 2000); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to submit. Please try again."; + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + if (submitted) { + return ( + + +
+
+ +
+

+ Submission Sent! +

+

+ Your work for "{bountyTitle}" has been submitted and is + pending review. +

+
+
+
+ ); + } + + return ( + + + + Submit Work + + Submit your work for "{bountyTitle}" + + + +
+ + ( + + + Wallet Address * + + + + + + Your connected wallet (rewards will be sent here) + + + + )} + /> + + ( + + + Explanation * + + +