Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 });
}
}
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({ bountyId, bountyTitle, onApply, trigger }: ApplicationDialogProps) {

Check warning on line 17 in components/bounty/application-dialog.tsx

View workflow job for this annotation

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

'bountyId' is defined but never used
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 "{bountyTitle}".

Check failure on line 45 in components/bounty/application-dialog.tsx

View workflow job for this annotation

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

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`

Check failure on line 45 in components/bounty/application-dialog.tsx

View workflow job for this annotation

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

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`
</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>
)
}
112 changes: 88 additions & 24 deletions components/bounty/bounty-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@
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"

Check warning on line 9 in components/bounty/bounty-sidebar.tsx

View workflow job for this annotation

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

'cn' is defined but never used
// 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()

// 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"

Check warning on line 25 in components/bounty/bounty-sidebar.tsx

View workflow job for this annotation

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

'isClaimable' is assigned a value but never used

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

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`
let body = {}

Check failure on line 108 in components/bounty/bounty-sidebar.tsx

View workflow job for this annotation

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

'body' is never reassigned. Use 'const' instead

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 @@
</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
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];
}
};
10 changes: 10 additions & 0 deletions types/participation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,13 @@ export interface MilestoneParticipation {
lastUpdatedAt: string
totalMilestones?: number // Optional override or cached value
}

export type CompetitionStatus = 'registered' | 'qualified' | 'disqualified' | 'winner'

export interface CompetitionParticipation {
id: string
bountyId: string
contributorId: string
status: CompetitionStatus
registeredAt: string
}
Loading