From 1930e2542ceb2bdd5220c10870d1b6b5a3f92b44 Mon Sep 17 00:00:00 2001 From: Michaelkingsdev Date: Thu, 29 Jan 2026 22:07:21 +0100 Subject: [PATCH 1/6] feat: Implement Model-Specific Claim/Apply/Join Actions --- app/api/bounties/[id]/claim/route.ts | 49 ++++++++ .../bounties/[id]/competition/join/route.ts | 53 +++++++++ components/bounty/application-dialog.tsx | 81 +++++++++++++ components/bounty/bounty-sidebar.tsx | 112 ++++++++++++++---- lib/store.ts | 19 ++- types/participation.ts | 10 ++ 6 files changed, 299 insertions(+), 25 deletions(-) create mode 100644 app/api/bounties/[id]/claim/route.ts create mode 100644 app/api/bounties/[id]/competition/join/route.ts create mode 100644 components/bounty/application-dialog.tsx diff --git a/app/api/bounties/[id]/claim/route.ts b/app/api/bounties/[id]/claim/route.ts new file mode 100644 index 0000000..898c31d --- /dev/null +++ b/app/api/bounties/[id]/claim/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { addDays } from 'date-fns'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + + 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 (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: contributorId, + claimedAt: now.toISOString(), + claimExpiresAt: addDays(now, 7).toISOString(), // Default 7 days + updatedAt: now.toISOString() + }; + + const updatedBounty = BountyStore.updateBounty(bountyId, updates); + + 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 new file mode 100644 index 0000000..f4a7eb9 --- /dev/null +++ b/app/api/bounties/[id]/competition/join/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { CompetitionParticipation } from '@/types/participation'; + +const generateId = () => crypto.randomUUID(); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + + 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 (bounty.claimingModel !== 'competition') { + return NextResponse.json({ error: 'Invalid claiming model for this action' }, { status: 400 }); + } + + const existing = BountyStore.getCompetitionParticipationsByBounty(bountyId) + .find(p => p.contributorId === contributorId); + + if (existing) { + return NextResponse.json({ error: 'Already joined this competition' }, { status: 409 }); + } + + const participation: CompetitionParticipation = { + id: generateId(), + bountyId, + contributorId, + 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/components/bounty/application-dialog.tsx b/components/bounty/application-dialog.tsx new file mode 100644 index 0000000..2b32c3b --- /dev/null +++ b/components/bounty/application-dialog.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useState } from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Input } from "@/components/ui/input" + +interface ApplicationDialogProps { + bountyId: string + bountyTitle: string + onApply: (data: { coverLetter: string, portfolioUrl?: string }) => Promise + trigger: React.ReactNode +} + +export function ApplicationDialog({ bountyId, bountyTitle, onApply, trigger }: ApplicationDialogProps) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [coverLetter, setCoverLetter] = useState("") + const [portfolioUrl, setPortfolioUrl] = useState("") + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + try { + await onApply({ coverLetter, portfolioUrl }) + setOpen(false) + } catch (error) { + console.error("Failed to submit application", error) + } finally { + setLoading(false) + } + } + + return ( + + + {trigger} + + + + Apply for Bounty + + Submit your application for "{bountyTitle}". + + +
+
+
+ +