Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
60 changes: 60 additions & 0 deletions app/api/bounties/[id]/claim/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { BountyStore } from '@/lib/store';
import { addDays } from 'date-fns';
import { getCurrentUser } from '@/lib/server-auth';

export async function POST(
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 });
}
}
57 changes: 57 additions & 0 deletions app/api/bounties/[id]/competition/join/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 }> }
) {
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 });
}
}
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 });
}
}
83 changes: 83 additions & 0 deletions components/bounty/application-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"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<boolean>
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 {
const success = await onApply({ coverLetter, portfolioUrl })
if (success) {
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>
)
}
118 changes: 92 additions & 26 deletions components/bounty/bounty-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@ 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"
import { toast } from "sonner"

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 +47,87 @@ 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 false
}

toast('Action completed successfully')
window.location.reload()
return true

} catch (error) {
console.error('Action error:', error)
alert('Something went wrong')
return false
} 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) => {
return 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 +138,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
28 changes: 28 additions & 0 deletions lib/server-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { cookies } from "next/headers";

export interface User {
id: string;
name: string;
email?: string;
}

export async function getCurrentUser(): Promise<User | null> {
// In a real implementation, this would use the better-auth server instance:
// const session = await auth.api.getSession({ headers: await headers() });
// return session?.user;

// For now, checks for the cookie used in proxy.ts as a weak signal,
// or returns a mock user in development.
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("boundless_auth.session_token") || cookieStore.get("boundless_auth");

if (process.env.NODE_ENV === "development" || sessionCookie) {
return {
id: "mock-user-123",
name: "Mock User",
email: "mock@example.com"
};
}

return null;
}
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
Loading
Loading