Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/api/bounties/[id]/claim/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
53 changes: 53 additions & 0 deletions app/api/bounties/[id]/competition/join/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
2 changes: 1 addition & 1 deletion app/api/bounties/[id]/milestones/advance/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
return NextResponse.json({ error: 'Bounty not found' }, { status: 404 });
}

let updates: Partial<typeof participation> = {
const updates: Partial<typeof participation> = {
lastUpdatedAt: new Date().toISOString()
};

Expand Down Expand Up @@ -64,7 +64,7 @@

return NextResponse.json({ success: true, data: updated });

} catch (error) {

Check warning on line 67 in app/api/bounties/[id]/milestones/advance/route.ts

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

'error' is defined but never used
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
81 changes: 81 additions & 0 deletions components/bounty/application-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
trigger: React.ReactNode
}

export function ApplicationDialog({ 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[525px] bg-background text-foreground border-border">
<DialogHeader>
<DialogTitle>Apply for Bounty</DialogTitle>
<DialogDescription>
Submit your application for &quot;{bountyTitle}&quot;.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="coverLetter">Cover Letter</Label>
<Textarea
id="coverLetter"
placeholder="Explain why you are a good fit..."
className="min-h-[150px]"
value={coverLetter}
onChange={(e) => setCoverLetter(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="portfolio">Portfolio URL (Optional)</Label>
<Input
id="portfolio"
placeholder="https://..."
value={portfolioUrl}
onChange={(e) => setPortfolioUrl(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={loading}>
{loading ? "Submitting..." : "Submit Application"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
116 changes: 90 additions & 26 deletions components/bounty/bounty-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@ import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { Bounty } from "@/types/bounty"
import { Github, Link2, Clock, Calendar, Check } from "lucide-react"
import { Github, Link2, Clock, Calendar, Check, Loader2 } from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import { cn } from "@/lib/utils"
// import { cn } from "@/lib/utils"
// import { useRouter } from "next/navigation" // If we need refresh
import { ApplicationDialog } from "./application-dialog"

interface BountySidebarProps {
bounty: Bounty
}

export function BountySidebar({ bounty }: BountySidebarProps) {
const [copied, setCopied] = useState(false)
const [loading, setLoading] = useState(false)
// const router = useRouter()

const isClaimable = bounty.status === "open"
// Mock user ID for now - in real app this comes from auth context
const CURRENT_USER_ID = "mock-user-123"

// const isClaimable = bounty.status === "open"

const createdTimeAgo = useMemo(
() => formatDistanceToNow(new Date(bounty.createdAt), { addSuffix: true }),
Expand All @@ -39,15 +46,86 @@ export function BountySidebar({ bounty }: BountySidebarProps) {
}
}

const claimButtonText = bounty.status === "claimed"
? "Already Claimed"
: bounty.status === "closed"
? "Bounty Closed"
: "Claim Bounty"
const handleAction = async (endpoint: string, body: object = {}) => {
setLoading(true)
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contributorId: CURRENT_USER_ID, ...body })
})

if (!res.ok) {
const error = await res.json()
alert(error.error || 'Action failed')
return
}

// Success - ideally show toast and refresh status
alert('Success!')
window.location.reload()

} catch (error) {
console.error('Action error:', error)
alert('Something went wrong')
} finally {
setLoading(false)
}
}

const renderActionButton = () => {
if (bounty.status !== 'open') {
const labels: Record<string, string> = {
claimed: 'Already Claimed',
closed: 'Bounty Closed'
}
return (
<Button disabled className="w-full gap-2 bg-gray-800 text-gray-400 cursor-not-allowed">
{labels[bounty.status] || 'Not Available'}
</Button>
)
}

if (bounty.claimingModel === 'application') {
return (
<ApplicationDialog
bountyId={bounty.id}
bountyTitle={bounty.issueTitle}
trigger={
<Button className="w-full gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
Apply Now
</Button>
}
onApply={async (data) => {
await handleAction(`/api/bounties/${bounty.id}/apply`, { ...data, applicantId: CURRENT_USER_ID })
}}
/>
)
}

const claimButtonAriaLabel = !isClaimable
? `Cannot claim: ${claimButtonText}`
: "Claim this bounty"
let label = 'Claim Bounty'
let endpoint = `/api/bounties/${bounty.id}/claim`
const body = {}

if (bounty.claimingModel === 'competition') {
label = 'Join Competition'
endpoint = `/api/bounties/${bounty.id}/competition/join`
} else if (bounty.claimingModel === 'milestone') {
label = 'Join Milestone'
endpoint = `/api/bounties/${bounty.id}/join`
}

return (
<Button
onClick={() => handleAction(endpoint, body)}
disabled={loading}
className="w-full gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{label}
</Button>
)
}

return (
<div className="sticky top-4 rounded-xl border border-gray-800 bg-background-card p-6 space-y-4">
Expand All @@ -58,24 +136,10 @@ export function BountySidebar({ bounty }: BountySidebarProps) {
</a>
</Button>

<Button
className={cn(
"w-full gap-2",
isClaimable
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-gray-800 text-gray-400 cursor-not-allowed"
)}
disabled={!isClaimable}
aria-label={claimButtonAriaLabel}
title={!isClaimable ? claimButtonText : undefined}
>
{claimButtonText}
</Button>
{renderActionButton()}

<Separator className="bg-gray-800" />



<a
href={`https://github.com/${bounty.githubRepo}`}
target="_blank"
Expand Down
2 changes: 1 addition & 1 deletion lib/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { BountyStore } from './store';
import { Application, Submission, MilestoneParticipation } from '@/types/participation';

Expand Down
19 changes: 18 additions & 1 deletion lib/store.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Bounty } from "@/types/bounty";
import { Application, Submission, MilestoneParticipation } from "@/types/participation";
import { Application, Submission, MilestoneParticipation, CompetitionParticipation } from "@/types/participation";
import { mockBounties } from "./mock-bounty";

class BountyStoreData {
bounties: Bounty[] = [...mockBounties];
applications: Application[] = [];
submissions: Submission[] = [];
milestoneParticipations: MilestoneParticipation[] = [];
competitionParticipations: CompetitionParticipation[] = [];
}

declare global {
Expand Down Expand Up @@ -65,5 +66,21 @@ export const BountyStore = {
if (index === -1) return null;
globalStore.milestoneParticipations[index] = { ...globalStore.milestoneParticipations[index], ...updates };
return globalStore.milestoneParticipations[index];
},

// Competitions
addCompetitionParticipation: (cp: CompetitionParticipation) => {
globalStore.competitionParticipations.push(cp);
return cp;
},
getCompetitionParticipationsByBounty: (bountyId: string) =>
globalStore.competitionParticipations.filter((c: CompetitionParticipation) => c.bountyId === bountyId),

// Generic Bounty Update (for status changes)
updateBounty: (bountyId: string, updates: Partial<Bounty>) => {
const index = globalStore.bounties.findIndex((b: Bounty) => b.id === bountyId);
if (index === -1) return null;
globalStore.bounties[index] = { ...globalStore.bounties[index], ...updates };
return globalStore.bounties[index];
}
};
2 changes: 1 addition & 1 deletion types/bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface Bounty {
// Let's add them as optional to be safe and backward compatible with existing components.
requirements?: string[]
scope?: string
milestones?: any[] // Optional milestone definition
milestones?: object[] // Optional milestone definition
}

export type BountyStatus = Bounty['status']
Expand Down
Loading
Loading