From ebbc724a0e31521b55ca5eb65b6a15fcdf38ca1e Mon Sep 17 00:00:00 2001 From: Ekene Ngwudike Date: Mon, 23 Feb 2026 12:38:41 +0100 Subject: [PATCH 1/4] Refactor bounty logic and data structures to align with backend schema - Updated BountyLogic class to simplify status processing and align with backend-driven status model. - Modified mock bounty data to reflect new structure and status values. - Adjusted mock data to include organization and project details. - Aligned Bounty and related types with backend GraphQL schema, including new status values and organization/project relationships. - Removed deprecated fields and streamlined submission interface for clarity. --- app/api/bounties/[id]/claim/route.ts | 120 ++-- .../bounties/[id]/competition/join/route.ts | 112 ++-- app/api/bounties/[id]/join/route.ts | 92 +-- .../bounties/[id]/milestones/advance/route.ts | 135 +++-- app/api/bounties/[id]/submit/route.ts | 44 +- app/api/bounties/route.ts | 100 ++-- .../[userId]/completion-history/route.ts | 77 --- app/api/transparency/payouts/route.ts | 28 +- app/api/transparency/stats/route.ts | 18 +- app/bounty/page.tsx | 215 ++----- app/discover/page.tsx | 43 +- app/profile/[userId]/page.tsx | 72 ++- app/transparency/page.tsx | 419 +++++++------- codegen.ts | 6 +- components/bounty-detail/bounty-badges.tsx | 18 +- .../bounty-detail/bounty-detail-client.tsx | 12 +- .../bounty-detail-header-card.tsx | 59 +- .../bounty-detail-requirements-card.tsx | 10 +- .../bounty-detail-sidebar-cta.tsx | 133 ++--- .../bounty-detail/submission-dialog.tsx | 354 ------------ components/bounty/bounty-card.tsx | 83 ++- components/bounty/bounty-content.tsx | 62 +- components/bounty/bounty-header.tsx | 149 ++--- components/bounty/bounty-sidebar.tsx | 320 +++++------ components/bounty/forms/schemas.ts | 73 +-- components/bounty/github-bounty-card.tsx | 285 +++++----- components/cards/bounty-card.tsx | 138 ++--- components/global-navbar.tsx | 4 +- components/login/sign-in.tsx | 4 +- components/projects/project-bounties.tsx | 128 +---- .../reputation/__tests__/my-claims.test.ts | 76 +-- components/reputation/my-claims.tsx | 182 +++--- components/search-command.tsx | 9 +- lib/api/bounties.ts | 103 ++-- lib/api/index.ts | 54 +- lib/api/reputation.ts | 70 +-- lib/api/transparency.ts | 40 +- lib/bounty-config.ts | 88 ++- lib/graphql/client.ts | 24 +- lib/graphql/generated.ts | 146 ++++- lib/graphql/schema.graphql | 528 +++++------------- lib/logic/bounty-logic.ts | 146 ++--- lib/mock-bounty.ts | 414 ++++++-------- lib/mock-data.ts | 181 +++--- lib/types.ts | 41 +- types/bounty.ts | 141 +++-- types/participation.ts | 8 +- 47 files changed, 2298 insertions(+), 3266 deletions(-) delete mode 100644 app/api/reputation/[userId]/completion-history/route.ts delete mode 100644 components/bounty-detail/submission-dialog.tsx diff --git a/app/api/bounties/[id]/claim/route.ts b/app/api/bounties/[id]/claim/route.ts index 9f2608b..a9c9aa1 100644 --- a/app/api/bounties/[id]/claim/route.ts +++ b/app/api/bounties/[id]/claim/route.ts @@ -1,60 +1,70 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; -import { addDays } from 'date-fns'; -import { getCurrentUser } from '@/lib/server-auth'; +import { NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import { getCurrentUser } from "@/lib/server-auth"; export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } + request: Request, + { params }: { params: Promise<{ id: string }> }, ) { - const { id: bountyId } = await params; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await request.json(); - const { contributorId } = body; - - // If client sends contributorId, ensure it matches the authenticated user - if (contributorId && contributorId !== user.id) { - return NextResponse.json({ error: 'Contributor ID mismatch' }, { status: 403 }); - } - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } - - if (bounty.claimingModel !== 'single-claim') { - return NextResponse.json({ error: 'Invalid claiming model for this action' }, { status: 400 }); - } - - if (bounty.status !== 'open') { - return NextResponse.json({ error: 'Bounty is not available' }, { status: 409 }); - } - - const now = new Date(); - const updates = { - status: 'claimed' as const, - claimedBy: user.id, // Use authenticated user ID - claimedAt: now.toISOString(), - claimExpiresAt: addDays(now, 7).toISOString(), - updatedAt: now.toISOString() - }; - - const updatedBounty = BountyStore.updateBounty(bountyId, updates); - - if (!updatedBounty) { - return NextResponse.json({ success: false, error: 'Failed to update bounty' }, { status: 500 }); - } - - return NextResponse.json({ success: true, data: updatedBounty }); - - } catch (error) { - console.error('Error claiming bounty:', error); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + const { id: bountyId } = await params; + + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { contributorId } = body; + + // If client sends contributorId, ensure it matches the authenticated user + if (contributorId && contributorId !== user.id) { + return NextResponse.json( + { error: "Contributor ID mismatch" }, + { status: 403 }, + ); + } + + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } + + if (bounty.type !== "FIXED_PRICE") { + return NextResponse.json( + { error: "Invalid bounty type for this action" }, + { status: 400 }, + ); } + + if (bounty.status !== "OPEN") { + return NextResponse.json( + { error: "Bounty is not available" }, + { status: 409 }, + ); + } + + const now = new Date(); + const updates = { + status: "IN_PROGRESS" as const, + updatedAt: now.toISOString(), + }; + + const updatedBounty = BountyStore.updateBounty(bountyId, updates); + + if (!updatedBounty) { + return NextResponse.json( + { success: false, error: "Failed to update bounty" }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true, data: updatedBounty }); + } catch (error) { + console.error("Error claiming bounty:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/app/api/bounties/[id]/competition/join/route.ts b/app/api/bounties/[id]/competition/join/route.ts index e0c7a48..517e32d 100644 --- a/app/api/bounties/[id]/competition/join/route.ts +++ b/app/api/bounties/[id]/competition/join/route.ts @@ -1,57 +1,69 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; -import { CompetitionParticipation } from '@/types/participation'; -import { getCurrentUser } from '@/lib/server-auth'; +import { NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import { CompetitionParticipation } from "@/types/participation"; +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; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } - - if (bounty.claimingModel !== 'competition') { - return NextResponse.json({ error: 'Invalid claiming model for this action' }, { status: 400 }); - } - - // Validate status is open - if (bounty.status !== 'open') { - return NextResponse.json({ error: 'Bounty is not open for registration' }, { status: 409 }); - } - - const existing = BountyStore.getCompetitionParticipationsByBounty(bountyId) - .find(p => p.contributorId === user.id); - - if (existing) { - return NextResponse.json({ error: 'Already joined this competition' }, { status: 409 }); - } - - const participation: CompetitionParticipation = { - id: generateId(), - bountyId, - contributorId: user.id, // Use authenticated user ID - status: 'registered', - registeredAt: new Date().toISOString() - }; - - BountyStore.addCompetitionParticipation(participation); - - return NextResponse.json({ success: true, data: participation }); - - } catch (error) { - console.error('Error joining competition:', error); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + const { id: bountyId } = await params; + + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } + + if (bounty.type !== "COMPETITION") { + return NextResponse.json( + { error: "Invalid bounty type for this action" }, + { status: 400 }, + ); + } + + // Validate status is open + if (bounty.status !== "OPEN") { + return NextResponse.json( + { error: "Bounty is not open for registration" }, + { status: 409 }, + ); } + + const existing = BountyStore.getCompetitionParticipationsByBounty( + bountyId, + ).find((p) => p.contributorId === user.id); + + if (existing) { + return NextResponse.json( + { error: "Already joined this competition" }, + { status: 409 }, + ); + } + + const participation: CompetitionParticipation = { + id: generateId(), + bountyId, + contributorId: user.id, // Use authenticated user ID + status: "registered", + registeredAt: new Date().toISOString(), + }; + + BountyStore.addCompetitionParticipation(participation); + + return NextResponse.json({ success: true, data: participation }); + } catch (error) { + console.error("Error joining competition:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/app/api/bounties/[id]/join/route.ts b/app/api/bounties/[id]/join/route.ts index 794528b..70b9b8d 100644 --- a/app/api/bounties/[id]/join/route.ts +++ b/app/api/bounties/[id]/join/route.ts @@ -1,55 +1,67 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; -import { MilestoneParticipation } from '@/types/participation'; +import { NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import { MilestoneParticipation } from "@/types/participation"; 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 } = body; + try { + const body = await request.json(); + const { contributorId } = body; - if (!contributorId) { - return NextResponse.json({ error: 'Missing contributorId' }, { status: 400 }); - } - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } + if (!contributorId) { + return NextResponse.json( + { error: "Missing contributorId" }, + { status: 400 }, + ); + } - if (bounty.claimingModel !== 'milestone') { - return NextResponse.json({ error: 'Invalid claiming model' }, { status: 400 }); - } + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } - // Check if already joined - const existing = BountyStore.getMilestoneParticipationsByBounty(bountyId) - .find(p => p.contributorId === contributorId); + if (bounty.type !== "MILESTONE_BASED") { + return NextResponse.json( + { error: "Invalid bounty type" }, + { status: 400 }, + ); + } - if (existing) { - return NextResponse.json({ error: 'Already joined this bounty' }, { status: 409 }); - } + // Check if already joined + const existing = BountyStore.getMilestoneParticipationsByBounty( + bountyId, + ).find((p) => p.contributorId === contributorId); - const participation: MilestoneParticipation = { - id: generateId(), - bountyId, - contributorId, - currentMilestone: 1, // Start at milestone 1 - status: 'active', - joinedAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString() - }; + if (existing) { + return NextResponse.json( + { error: "Already joined this bounty" }, + { status: 409 }, + ); + } - BountyStore.addMilestoneParticipation(participation); + const participation: MilestoneParticipation = { + id: generateId(), + bountyId, + contributorId, + currentMilestone: 1, // Start at milestone 1 + status: "active", + joinedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + }; - return NextResponse.json({ success: true, data: participation }); + BountyStore.addMilestoneParticipation(participation); - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } + return NextResponse.json({ success: true, data: participation }); + } catch { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts index 779fae9..867c9df 100644 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -1,70 +1,99 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; +import { NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; // import { MilestoneStatus } from '@/types/participation'; 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, action } = body; // action: 'advance' | 'complete' | 'remove' + try { + const body = await request.json(); + const { contributorId, action } = body; // action: 'advance' | 'complete' | 'remove' - if (!contributorId || !action) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - if (!['advance', 'complete', 'remove'].includes(action)) { - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); - } + if (!contributorId || !action) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 }, + ); + } - const participations = BountyStore.getMilestoneParticipationsByBounty(bountyId); - const participation = participations.find(p => p.contributorId === contributorId); + if (!["advance", "complete", "remove"].includes(action)) { + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } - if (!participation) { - return NextResponse.json({ error: 'Participation not found' }, { status: 404 }); - } + const participations = + BountyStore.getMilestoneParticipationsByBounty(bountyId); + const participation = participations.find( + (p) => p.contributorId === contributorId, + ); - const bounty = BountyStore.getBountyById(bountyId); + if (!participation) { + return NextResponse.json( + { error: "Participation not found" }, + { status: 404 }, + ); + } - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } + const bounty = BountyStore.getBountyById(bountyId); - const updates: Partial = { - lastUpdatedAt: new Date().toISOString() - }; + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } - const totalMilestones = participation.totalMilestones || bounty.milestones?.length; + const updates: Partial = { + lastUpdatedAt: new Date().toISOString(), + }; - if (action === 'advance') { - if (participation.status === 'completed') { - return NextResponse.json({ error: 'Cannot advance completed participation' }, { status: 409 }); - } - if (!totalMilestones) { - return NextResponse.json({ error: 'Cannot determine total milestones' }, { status: 500 }); - } - if (participation.currentMilestone >= totalMilestones) { - return NextResponse.json({ error: 'Already at last milestone' }, { status: 409 }); - } - updates.currentMilestone = participation.currentMilestone + 1; - updates.status = 'advanced'; - } else if (action === 'complete') { - if (participation.status === 'completed') { - return NextResponse.json({ error: 'Already completed' }, { status: 409 }); - } - updates.status = 'completed'; - } else if (action === 'remove') { - return NextResponse.json({ error: 'Remove action not supported yet' }, { status: 400 }); - } + const totalMilestones = participation.totalMilestones; - const updated = BountyStore.updateMilestoneParticipation(participation.id, updates); + if (action === "advance") { + if (participation.status === "completed") { + return NextResponse.json( + { error: "Cannot advance completed participation" }, + { status: 409 }, + ); + } + if (!totalMilestones) { + return NextResponse.json( + { error: "Cannot determine total milestones" }, + { status: 500 }, + ); + } + if (participation.currentMilestone >= totalMilestones) { + return NextResponse.json( + { error: "Already at last milestone" }, + { status: 409 }, + ); + } + updates.currentMilestone = participation.currentMilestone + 1; + updates.status = "advanced"; + } else if (action === "complete") { + if (participation.status === "completed") { + return NextResponse.json( + { error: "Already completed" }, + { status: 409 }, + ); + } + updates.status = "completed"; + } else if (action === "remove") { + return NextResponse.json( + { error: "Remove action not supported yet" }, + { status: 400 }, + ); + } - return NextResponse.json({ success: true, data: updated }); + const updated = BountyStore.updateMilestoneParticipation( + participation.id, + updates, + ); - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } + return NextResponse.json({ success: true, data: updated }); + } catch { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index a870603..83ba92f 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -1,8 +1,6 @@ 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(); @@ -13,20 +11,12 @@ export async function POST( const { id: bountyId } = await params; try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const contributorId = user.id; - const body = await request.json(); - const { contributorId: _clientContributorId, ...formData } = body; + const { contributorId, content } = body; - const parsed = submissionFormSchema.safeParse(formData); - if (!parsed.success) { - const fieldErrors = parsed.error.flatten().fieldErrors; + if (!contributorId || !content) { return NextResponse.json( - { error: "Validation failed", fieldErrors }, + { error: "Missing required fields" }, { status: 400 }, ); } @@ -36,22 +26,10 @@ export async function POST( return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); } - if (bounty.status !== "open") { - return NextResponse.json( - { error: "Submissions are not accepted for this bounty" }, - { status: 400 }, - ); - } - - const allowedModels = [ - "single-claim", - "competition", - "multi-winner", - "application", - ]; - if (!allowedModels.includes(bounty.claimingModel)) { + // All bounty types can accept submissions + if (bounty.status !== "OPEN" && bounty.status !== "IN_PROGRESS") { return NextResponse.json( - { error: "Submission not allowed for this bounty type" }, + { error: "Bounty is not accepting submissions" }, { status: 400 }, ); } @@ -67,19 +45,11 @@ export async function POST( ); } - const { explanation, walletAddress, githubUrl, demoUrl, attachments } = - parsed.data; - const submission: Submission = { id: generateId(), bountyId, contributorId, - content: explanation, - explanation, - walletAddress, - githubUrl: githubUrl || undefined, - demoUrl: demoUrl || undefined, - attachments: attachments?.length ? attachments : undefined, + content, status: "pending", submittedAt: new Date().toISOString(), }; diff --git a/app/api/bounties/route.ts b/app/api/bounties/route.ts index b79483a..7745349 100644 --- a/app/api/bounties/route.ts +++ b/app/api/bounties/route.ts @@ -1,56 +1,50 @@ -import { NextResponse } from 'next/server'; -import { getAllBounties } from '@/lib/mock-bounty'; -import { BountyLogic } from '@/lib/logic/bounty-logic'; +import { NextResponse } from "next/server"; +import { getAllBounties } from "@/lib/mock-bounty"; +import { BountyLogic } from "@/lib/logic/bounty-logic"; export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 500)); - - const allBounties = getAllBounties().map(b => BountyLogic.processBountyStatus(b)); - - // Apply filters from params - let filtered = allBounties; - - const status = searchParams.get('status'); - if (status) filtered = filtered.filter(b => b.status === status); - - const type = searchParams.get('type'); - if (type) filtered = filtered.filter(b => b.type === type); - - const difficulty = searchParams.get('difficulty'); - if (difficulty) filtered = filtered.filter(b => b.difficulty === difficulty); - - const projectId = searchParams.get('projectId'); - if (projectId) filtered = filtered.filter(b => b.projectId === projectId); - - const tags = searchParams.get('tags'); - if (tags) { - const tagArray = tags.split(',').filter(Boolean); - if (tagArray.length > 0) { - filtered = filtered.filter(b => - tagArray.some(tag => b.tags.includes(tag)) - ); - } - } - - const search = searchParams.get('search'); - if (search) { - const lower = search.toLowerCase(); - filtered = filtered.filter(b => - b.issueTitle.toLowerCase().includes(lower) || - b.description.toLowerCase().includes(lower) - ); - } - - return NextResponse.json({ - data: filtered, - pagination: { - page: 1, - limit: 20, - total: filtered.length, - totalPages: 1, - }, - }); + const { searchParams } = new URL(request.url); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + const allBounties = getAllBounties().map((b) => + BountyLogic.processBountyStatus(b), + ); + + // Apply filters from params + let filtered = allBounties; + + const status = searchParams.get("status"); + if (status) filtered = filtered.filter((b) => b.status === status); + + const type = searchParams.get("type"); + if (type) filtered = filtered.filter((b) => b.type === type); + + const organizationId = searchParams.get("organizationId"); + if (organizationId) + filtered = filtered.filter((b) => b.organizationId === organizationId); + + const projectId = searchParams.get("projectId"); + if (projectId) filtered = filtered.filter((b) => b.projectId === projectId); + + const search = searchParams.get("search"); + if (search) { + const lower = search.toLowerCase(); + filtered = filtered.filter( + (b) => + b.title.toLowerCase().includes(lower) || + b.description.toLowerCase().includes(lower), + ); + } + + return NextResponse.json({ + data: filtered, + pagination: { + page: 1, + limit: 20, + total: filtered.length, + totalPages: 1, + }, + }); } diff --git a/app/api/reputation/[userId]/completion-history/route.ts b/app/api/reputation/[userId]/completion-history/route.ts deleted file mode 100644 index d98b87b..0000000 --- a/app/api/reputation/[userId]/completion-history/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 4ef5581..d74dc48 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)); -} + return NextResponse.json(payouts.slice(0, limit)); +} \ No newline at end of file diff --git a/app/api/transparency/stats/route.ts b/app/api/transparency/stats/route.ts index 2c2e815..baf83b3 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); -} + return NextResponse.json(stats); +} \ No newline at end of file diff --git a/app/bounty/page.tsx b/app/bounty/page.tsx index 444e64c..e82db46 100644 --- a/app/bounty/page.tsx +++ b/app/bounty/page.tsx @@ -32,79 +32,69 @@ export default function BountiesPage() { const { data, isLoading, isError, error, refetch } = useBounties(); const allBounties = useMemo(() => data?.data ?? [], [data?.data]); - const projects = useMemo( - () => Array.from(new Set(allBounties.map((b) => b.projectName))).sort(), - [allBounties], - ); - const allTags = useMemo( - () => Array.from(new Set(allBounties.flatMap((b) => b.tags))).sort(), + const organizations = useMemo( + () => + Array.from( + new Set(allBounties.map((b) => b.organization?.name).filter(Boolean)), + ).sort() as string[], [allBounties], ); // Filters state const [searchQuery, setSearchQuery] = useState(""); const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedDifficulties, setSelectedDifficulties] = useState( - [], - ); - const [selectedProjects, setSelectedProjects] = useState([]); - const [selectedTags, setSelectedTags] = useState([]); + const [selectedOrgs, setSelectedOrgs] = useState([]); const [rewardRange, setRewardRange] = useState<[number, number]>([0, 5000]); - const [statusFilter, setStatusFilter] = useState("open"); + const [statusFilter, setStatusFilter] = useState("OPEN"); const [sortOption, setSortOption] = useState("newest"); - // Constants for filters - const BOUNTY_TYPES = ["feature", "bug", "documentation", "refactor", "other"]; - const DIFFICULTIES = ["beginner", "intermediate", "advanced"]; - const STATUSES = ["open", "claimed", "closed", "all"]; + // Constants for filters — aligned with backend enums + const BOUNTY_TYPES = [ + { value: "FIXED_PRICE", label: "Fixed Price" }, + { value: "MILESTONE_BASED", label: "Milestone Based" }, + { value: "COMPETITION", label: "Competition" }, + ]; + const STATUSES = [ + { value: "OPEN", label: "Open" }, + { value: "IN_PROGRESS", label: "In Progress" }, + { value: "COMPLETED", label: "Completed" }, + { value: "CANCELLED", label: "Cancelled" }, + { value: "DRAFT", label: "Draft" }, + { value: "SUBMITTED", label: "Submitted" }, + { value: "UNDER_REVIEW", label: "Under Review" }, + { value: "DISPUTED", label: "Disputed" }, + { value: "all", label: "All Statuses" }, + ]; // Filter Logic const filteredBounties = useMemo(() => { return allBounties .filter((bounty) => { - // Search (Title, Description) - removed tags/project from search text logic if specific filters are used? - // Better to keep search broad or restrict? Let's keep it checking main text fields. const searchLower = searchQuery.toLowerCase(); const matchesSearch = searchQuery === "" || - bounty.issueTitle.toLowerCase().includes(searchLower) || + bounty.title.toLowerCase().includes(searchLower) || bounty.description.toLowerCase().includes(searchLower); - // Type Filter const matchesType = selectedTypes.length === 0 || selectedTypes.includes(bounty.type); - // Difficulty Filter - const matchesDifficulty = - selectedDifficulties.length === 0 || - (bounty.difficulty && - selectedDifficulties.includes(bounty.difficulty)); - - // Project Filter - const matchesProject = - selectedProjects.length === 0 || - selectedProjects.includes(bounty.projectName); + const matchesOrg = + selectedOrgs.length === 0 || + (bounty.organization?.name && + selectedOrgs.includes(bounty.organization.name)); - // Tag Filter (Match ANY selected tag? or ALL? Usually ANY is friendlier) - const matchesTags = - selectedTags.length === 0 || - selectedTags.some((tag) => bounty.tags.includes(tag)); - - // Reward Filter const amount = bounty.rewardAmount || 0; const matchesReward = amount >= rewardRange[0] && amount <= rewardRange[1]; - // Status Filter const matchesStatus = statusFilter === "all" || bounty.status === statusFilter; return ( matchesSearch && matchesType && - matchesDifficulty && - matchesProject && - matchesTags && + matchesOrg && matchesReward && matchesStatus ); @@ -128,9 +118,7 @@ export default function BountiesPage() { allBounties, searchQuery, selectedTypes, - selectedDifficulties, - selectedProjects, - selectedTags, + selectedOrgs, rewardRange, statusFilter, sortOption, @@ -143,34 +131,18 @@ export default function BountiesPage() { ); }; - const toggleDifficulty = (diff: string) => { - setSelectedDifficulties((prev) => - prev.includes(diff) ? prev.filter((d) => d !== diff) : [...prev, diff], - ); - }; - - const toggleProject = useCallback((project: string) => { - setSelectedProjects((prev) => - prev.includes(project) - ? prev.filter((p) => p !== project) - : [...prev, project], - ); - }, []); - - const toggleTag = useCallback((tag: string) => { - setSelectedTags((prev) => - prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + const toggleOrg = useCallback((org: string) => { + setSelectedOrgs((prev) => + prev.includes(org) ? prev.filter((o) => o !== org) : [...prev, org], ); }, []); const clearFilters = () => { setSearchQuery(""); setSelectedTypes([]); - setSelectedDifficulties([]); - setSelectedProjects([]); - setSelectedTags([]); + setSelectedOrgs([]); setRewardRange([0, 5000]); - setStatusFilter("open"); + setStatusFilter("OPEN"); setSortOption("newest"); }; @@ -200,12 +172,10 @@ export default function BountiesPage() { {(searchQuery || selectedTypes.length > 0 || - selectedDifficulties.length > 0 || - selectedProjects.length > 0 || - selectedTags.length > 0 || + selectedOrgs.length > 0 || rewardRange[0] !== 0 || rewardRange[1] !== 5000 || - statusFilter !== "open") && ( + statusFilter !== "OPEN") && ( - - - )} + 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, + }, + ]; - {/* Stats Grid - hidden when error so zeros aren't shown */} - {!statsError && ( -
-

- Platform Overview -

-
- {statCards.map((card) => ( - - ))} + return ( +
+ {/* Hero Header */} +
+
+

+ Transparency +

+

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

+
-
- )} - {/* 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) => ( - - )) + {/* Stats Error */} + {statsError && ( + + + Error + +

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

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

+ Platform Overview +

+
+ {statCards.map((card) => ( + + ))} +
+
+ + {/* 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 diff --git a/codegen.ts b/codegen.ts index 768a827..f3b5d99 100644 --- a/codegen.ts +++ b/codegen.ts @@ -3,10 +3,8 @@ import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { schema: "../boundless-nestjs/src/schema.gql", documents: [ - "lib/graphql/**/*.ts", - "lib/graphql/**/*.tsx", - "hooks/**/*.ts", - "hooks/**/*.tsx", + "lib/graphql/operations/**/*.ts", + "lib/graphql/operations/**/*.tsx", ], ignoreNoDocuments: true, generates: { diff --git a/components/bounty-detail/bounty-badges.tsx b/components/bounty-detail/bounty-badges.tsx index 30c8b15..64dc0d0 100644 --- a/components/bounty-detail/bounty-badges.tsx +++ b/components/bounty-detail/bounty-badges.tsx @@ -1,8 +1,7 @@ -import { Zap } from "lucide-react"; -import type { Bounty } from "@/lib/api"; -import { DIFFICULTY_CONFIG, STATUS_CONFIG } from "@/lib/bounty-config"; +import type { BountyStatus, BountyType } from "@/types/bounty"; +import { STATUS_CONFIG, TYPE_CONFIG } from "@/lib/bounty-config"; -export function StatusBadge({ status }: { status: Bounty["status"] }) { +export function StatusBadge({ status }: { status: BountyStatus }) { const cfg = STATUS_CONFIG[status]; return ( ; -}) { - const cfg = DIFFICULTY_CONFIG[difficulty]; +export function TypeBadge({ type }: { type: BountyType }) { + const cfg = TYPE_CONFIG[type]; return ( - {cfg.label} ); diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index 5e38854..ae151b5 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -3,12 +3,7 @@ import { useRouter } from "next/navigation"; import { AlertCircle, ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - ClaimModelInfo, - MobileCTA, - SidebarCTA, -} from "./bounty-detail-sidebar-cta"; -import { RequirementsCard, ScopeCard } from "./bounty-detail-requirements-card"; +import { MobileCTA, SidebarCTA } from "./bounty-detail-sidebar-cta"; import { HeaderCard } from "./bounty-detail-header-card"; import { DescriptionCard } from "./bounty-detail-description-card"; import { BountyDetailSkeleton } from "./bounty-detail-bounty-detail-skeleton"; @@ -74,17 +69,12 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
- {bounty.requirements && bounty.requirements.length > 0 && ( - - )} - {bounty.scope && }
{/* Sidebar */} diff --git a/components/bounty-detail/bounty-detail-header-card.tsx b/components/bounty-detail/bounty-detail-header-card.tsx index 5bc88cc..7a1d0e0 100644 --- a/components/bounty-detail/bounty-detail-header-card.tsx +++ b/components/bounty-detail/bounty-detail-header-card.tsx @@ -1,40 +1,35 @@ -import Link from "next/link"; -import { ExternalLink, Tag, GitBranch } from "lucide-react"; +import { ExternalLink, GitBranch } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import type { Bounty } from "@/lib/api"; -import { DifficultyBadge, StatusBadge } from "./bounty-badges"; +import type { Bounty } from "@/types/bounty"; +import { StatusBadge, TypeBadge } from "./bounty-badges"; export function HeaderCard({ bounty }: { bounty: Bounty }) { + const orgName = bounty.organization?.name ?? "Unknown"; + const orgLogo = bounty.organization?.logo; + return (
{/* Badges */}
- {bounty.difficulty && ( - - )} - - {bounty.type} - +
{/* Title */}

- {bounty.issueTitle} + {bounty.title}

{/* Repo + issue number */} @@ -54,45 +49,21 @@ export function HeaderCard({ bounty }: { bounty: Bounty }) {
- {/* Project */} -
+ {/* Organization */} +
- + - {bounty.projectName.slice(0, 2).toUpperCase()} + {orgName.slice(0, 2).toUpperCase()}

- Project + Organization

- - {bounty.projectName} - - +

{orgName}

- - {/* Tags */} - {bounty.tags.length > 0 && ( -
- - {bounty.tags.map((tag) => ( - - {tag} - - ))} -
- )}
); } diff --git a/components/bounty-detail/bounty-detail-requirements-card.tsx b/components/bounty-detail/bounty-detail-requirements-card.tsx index d6f99a1..b1e8efc 100644 --- a/components/bounty-detail/bounty-detail-requirements-card.tsx +++ b/components/bounty-detail/bounty-detail-requirements-card.tsx @@ -1,10 +1,8 @@ -import type { Bounty } from "@/lib/api"; +// Requirements and Scope cards are kept as generic components +// but no longer tied to the Bounty type since the backend +// doesn't include requirements/scope on the Bounty model. -export function RequirementsCard({ - requirements, -}: { - requirements: NonNullable; -}) { +export function RequirementsCard({ requirements }: { requirements: string[] }) { if (requirements.length === 0) return null; return ( diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index 730f5a4..cb8a682 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -1,21 +1,16 @@ "use client"; import { useState } from "react"; -import { Github, Copy, Check, AlertCircle, Clock } from "lucide-react"; +import { Github, Copy, Check, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; 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"; +import type { Bounty } from "@/types/bounty"; +import { StatusBadge, TypeBadge } from "./bounty-badges"; 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; + const canAct = bounty.status === "OPEN"; const handleCopy = async () => { try { @@ -23,23 +18,24 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) { setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - // clipboard write failed (e.g. non-HTTPS, permission denied) + // clipboard write failed } }; const ctaLabel = () => { - if (!canAct) - return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; - switch (bounty.claimingModel) { - case "single-claim": - return "Claim Bounty"; - case "application": - return "Apply Now"; - case "competition": - return "Submit Entry"; - case "multi-winner": - return "Submit Work"; + if (!canAct) { + switch (bounty.status) { + case "IN_PROGRESS": + return "In Progress"; + case "COMPLETED": + return "Completed"; + case "CANCELLED": + return "Cancelled"; + default: + return "Not Available"; + } } + return "Submit to Bounty"; }; return ( @@ -70,31 +66,9 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) {
- Model - - - {claimCfg.label} - + Type +
- {bounty.difficulty && ( -
- Difficulty - -
- )} - {bounty.submissionsEndDate && ( -
- Deadline - - - {new Date(bounty.submissionsEndDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - -
- )} @@ -104,24 +78,18 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) { className="w-full h-11 font-bold tracking-wide" disabled={!canAct} size="lg" - onClick={() => canAct && setDialogOpen(true)} + onClick={() => + canAct && + window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer") + } > {ctaLabel()} - - {!canAct && (

- {bounty.status === "claimed" - ? "A contributor has already claimed this bounty." - : "This bounty is no longer accepting submissions."} + This bounty is no longer accepting new submissions.

)} @@ -157,40 +125,21 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) { ); } -export function ClaimModelInfo({ - claimingModel, -}: { - claimingModel: Bounty["claimingModel"]; -}) { - return ( -
-

- Claim Model -

-

- {CLAIMING_MODEL_CONFIG[claimingModel].description} -

-
- ); -} - export function MobileCTA({ bounty }: { bounty: Bounty }) { - const [dialogOpen, setDialogOpen] = useState(false); - const canAct = bounty.status === "open"; + const canAct = bounty.status === "OPEN"; const label = () => { - if (!canAct) - return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; - switch (bounty.claimingModel) { - case "single-claim": - return "Claim Bounty"; - case "application": - return "Apply Now"; - case "competition": - return "Submit Entry"; - case "multi-winner": - return "Submit Work"; + if (!canAct) { + switch (bounty.status) { + case "IN_PROGRESS": + return "In Progress"; + case "COMPLETED": + return "Completed"; + default: + return "Not Available"; + } } + return "Submit to Bounty"; }; return ( @@ -199,17 +148,13 @@ export function MobileCTA({ bounty }: { bounty: Bounty }) { className="w-full h-11 font-bold tracking-wide" disabled={!canAct} size="lg" - onClick={() => canAct && setDialogOpen(true)} + onClick={() => + canAct && + window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer") + } > {label()} - - ); } diff --git a/components/bounty-detail/submission-dialog.tsx b/components/bounty-detail/submission-dialog.tsx deleted file mode 100644 index bb6c007..0000000 --- a/components/bounty-detail/submission-dialog.tsx +++ /dev/null @@ -1,354 +0,0 @@ -"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 * - - -