From 6516d12d6c4051737721f68873969bc0236b329f Mon Sep 17 00:00:00 2001 From: Dev Jaja Date: Thu, 23 Apr 2026 20:34:58 -0400 Subject: [PATCH 1/7] feat: implement competition (best submission wins) bounty flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useJoinCompetition, useSubmitContestWork, useApproveContestWinner, and useFinalizeContest mutations in hooks/use-competition-bounty.ts mapping to BountyRegistry contract methods (claim_bounty, submit_work, approve_contest_winner, finalize_contest) - Add CompetitionSubmission component: blind submission panel with countdown timer; submissions locked after deadline - Add CompetitionJudging component: creator-only panel post-deadline showing all revealed submissions with per-entry payout + reputation point inputs, winner/consolation selection, and finalize button - Add CompetitionStatus component: participant slot count, blind vs revealed submission state, and current phase indicator - Update bounty-detail-sidebar-cta.tsx: replace generic CTA with 'Join Competition' button for COMPETITION type; show slot count (X/max joined); wire CompetitionStatus and CompetitionSubmission panels below the main card; update MobileCTA accordingly - Update bounty-detail-client.tsx: render CompetitionJudging panel for creator after deadline/finalization; skip generic submissions card for competition type - Update bounty-card.tsx: add competition badge with slot count (Users icon + X/max joined) for COMPETITION bounties Closes: Feature — Competition Bounty Flow (#competition-flow) Depends on: #139 (TypeScript contract bindings via __contestContracts) --- .../bounty-detail/bounty-detail-client.tsx | 25 +- .../bounty-detail-sidebar-cta.tsx | 150 +++++++++- components/bounty/bounty-card.tsx | 13 +- components/bounty/competition-judging.tsx | 256 ++++++++++++++++++ components/bounty/competition-status.tsx | 87 ++++++ components/bounty/competition-submission.tsx | 148 ++++++++++ hooks/use-competition-bounty.ts | 238 ++++++++++++++++ 7 files changed, 912 insertions(+), 5 deletions(-) create mode 100644 components/bounty/competition-judging.tsx create mode 100644 components/bounty/competition-status.tsx create mode 100644 components/bounty/competition-submission.tsx create mode 100644 hooks/use-competition-bounty.ts diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index c032cb17..46f81c52 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -11,16 +11,19 @@ import { BountyDetailSubmissionsCard } from "./bounty-detail-submissions-card"; import { BountyDetailSkeleton } from "./bounty-detail-bounty-detail-skeleton"; import { useBountyDetail } from "@/hooks/use-bounty-detail"; import { FcfsApprovalPanel } from "@/components/bounty/fcfs-approval-panel"; +import { CompetitionJudging } from "@/components/bounty/competition-judging"; import { EscrowDetailPanel } from "../bounty/escrow-detail-panel"; import { RefundStatusTracker } from "../bounty/refund-status"; import { FeeCalculator } from "../bounty/fee-calculator"; import { useEscrowPool } from "@/hooks/use-escrow"; +import { authClient } from "@/lib/auth-client"; import type { CancellationRecord } from "@/types/escrow"; export function BountyDetailClient({ bountyId }: { bountyId: string }) { const router = useRouter(); const { data: bounty, isPending, isError, error } = useBountyDetail(bountyId); const { data: pool } = useEscrowPool(bountyId); + const { data: session } = authClient.useSession(); const [cancellationRecord, setCancellationRecord] = useState(null); @@ -81,6 +84,15 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { const isCancelled = bounty.status === "CANCELLED" || cancellationRecord !== null; + const isCompetition = bounty.type === "COMPETITION"; + const isCreator = + (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; + const isFinalized = bounty.status === "COMPLETED"; + const submissions = (bounty as { submissions?: unknown[] }).submissions ?? []; + const pastDeadline = + bounty.bountyWindow?.endDate != null && + Date.now() > new Date(bounty.bountyWindow.endDate).getTime(); + return (
{/* Main content */} @@ -89,10 +101,21 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { {!isCancelled && pool && } - {bounty.type !== "FIXED_PRICE" && ( + {bounty.type !== "FIXED_PRICE" && !isCompetition && ( )} {bounty.type === "FIXED_PRICE" && } + {isCompetition && isCreator && (pastDeadline || isFinalized) && ( + [0]["submissions"] + } + isFinalized={isFinalized} + totalReward={bounty.rewardAmount} + currency={bounty.rewardCurrency} + /> + )}
{/* Sidebar */} diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index b10ccc26..7fea732e 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -8,6 +8,7 @@ import { AlertCircle, XCircle, Loader2, + Users, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -22,11 +23,15 @@ import { AlertDialogFooter, AlertDialogCancel, } from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; import { BountyFieldsFragment } from "@/lib/graphql/generated"; import { StatusBadge, TypeBadge } from "./bounty-badges"; import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button"; +import { CompetitionSubmission } from "@/components/bounty/competition-submission"; +import { CompetitionStatus } from "@/components/bounty/competition-status"; import { authClient } from "@/lib/auth-client"; +import { useJoinCompetition, ContestError } from "@/hooks/use-competition-bounty"; import type { CancellationRecord } from "@/types/escrow"; import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog"; @@ -37,7 +42,9 @@ interface SidebarCTAProps { export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { const [copied, setCopied] = useState(false); + const [hasJoined, setHasJoined] = useState(false); const { data: session } = authClient.useSession(); + const joinMutation = useJoinCompetition(); const { cancelDialogOpen, @@ -50,10 +57,47 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { const canAct = bounty.status === "OPEN"; const isFcfs = bounty.type === "FIXED_PRICE"; - const isCreator = session?.user?.id === bounty.createdBy; + const isCompetition = bounty.type === "COMPETITION"; + const isCreator = + (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; const canCancel = isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS"); + const walletAddress = + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.walletAddress || + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.address || + null; + + // Competition-specific derived values + const submissionCount = bounty._count?.submissions ?? 0; + const maxParticipants = (bounty as { maxParticipants?: number | null }) + .maxParticipants ?? null; + const deadline = bounty.bountyWindow?.endDate ?? null; + const isFinalized = bounty.status === "COMPLETED"; + + const handleJoinCompetition = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to join this competition."); + return; + } + try { + await joinMutation.mutateAsync({ + bountyId: bounty.id, + contributorAddress: walletAddress, + }); + setHasJoined(true); + toast.success("You've joined the competition!"); + } catch (err) { + if (err instanceof ContestError && err.code === "already_joined") { + setHasJoined(true); + return; + } + toast.error(err instanceof Error ? err.message : "Failed to join."); + } + }; + const handleCopy = async () => { try { await navigator.clipboard.writeText(window.location.href); @@ -116,9 +160,45 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { + {/* Competition slot count */} + {isCompetition && ( +
+ + + Slots + + + {submissionCount} + {maxParticipants != null ? `/${maxParticipants}` : ""} joined + +
+ )} + + {isCompetition && } + {/* CTA */} {isFcfs ? ( + ) : isCompetition ? ( + hasJoined ? ( + + ) : ( + + ) ) : ( + {/* Competition status + submission panel */} + {isCompetition && ( + <> + + + + )} + {/* Cancel Confirmation Dialog */} @@ -262,7 +360,9 @@ interface MobileCTAProps { } export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { + const [hasJoined, setHasJoined] = useState(false); const { data: session } = authClient.useSession(); + const joinMutation = useJoinCompetition(); const { cancelDialogOpen, @@ -275,10 +375,40 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { const canAct = bounty.status === "OPEN"; const isFcfs = bounty.type === "FIXED_PRICE"; - const isCreator = session?.user?.id === bounty.createdBy; + const isCompetition = bounty.type === "COMPETITION"; + const isCreator = + (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; const canCancel = isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS"); + const walletAddress = + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.walletAddress || + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.address || + null; + + const handleJoin = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to join."); + return; + } + try { + await joinMutation.mutateAsync({ + bountyId: bounty.id, + contributorAddress: walletAddress, + }); + setHasJoined(true); + toast.success("You've joined the competition!"); + } catch (err) { + if (err instanceof ContestError && err.code === "already_joined") { + setHasJoined(true); + return; + } + toast.error(err instanceof Error ? err.message : "Failed to join."); + } + }; + const label = () => { if (!canAct) { switch (bounty.status) { @@ -297,6 +427,20 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
{isFcfs ? ( + ) : isCompetition ? ( + ) : (
diff --git a/components/bounty/competition-judging.tsx b/components/bounty/competition-judging.tsx new file mode 100644 index 00000000..c3ab5101 --- /dev/null +++ b/components/bounty/competition-judging.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Trophy, Award, CheckCircle2, Lock } from "lucide-react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + useApproveContestWinner, + useFinalizeContest, +} from "@/hooks/use-competition-bounty"; + +interface Submission { + id: string; + submittedBy: string; + submittedByUser?: { name?: string | null; image?: string | null } | null; + githubPullRequestUrl?: string | null; + status: string; +} + +interface CompetitionJudgingProps { + bountyId: string; + submissions: Submission[]; + isFinalized: boolean; + totalReward: number; + currency: string; +} + +export function CompetitionJudging({ + bountyId, + submissions, + isFinalized, + totalReward, + currency, +}: CompetitionJudgingProps) { + const { data: session } = authClient.useSession(); + const approveMutation = useApproveContestWinner(); + const finalizeMutation = useFinalizeContest(); + + const [payouts, setPayouts] = useState>({}); + const [points, setPoints] = useState>({}); + const [approved, setApproved] = useState>(new Set()); + + const walletAddress = + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.walletAddress || + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.address || + null; + + const handleApprove = async (sub: Submission) => { + if (!walletAddress) { + toast.error("Connect your wallet to approve."); + return; + } + const payout = parseFloat(payouts[sub.id] ?? "0"); + const pts = parseInt(points[sub.id] ?? "10", 10); + if (!isFinite(payout) || payout <= 0) { + toast.error("Enter a valid payout amount."); + return; + } + try { + await approveMutation.mutateAsync({ + bountyId, + creatorAddress: walletAddress, + winner: sub.submittedBy, + payoutAmount: BigInt(Math.round(payout * 1e7)), + points: pts, + }); + setApproved((prev) => new Set(prev).add(sub.id)); + toast.success( + `Payment approved for ${sub.submittedByUser?.name ?? sub.submittedBy}.`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Approval failed."); + } + }; + + const handleFinalize = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to finalize."); + return; + } + try { + await finalizeMutation.mutateAsync({ + bountyId, + creatorAddress: walletAddress, + }); + toast.success("Contest finalized. No further approvals allowed."); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Finalization failed."); + } + }; + + if (submissions.length === 0) { + return ( +
+ No submissions to review yet. +
+ ); + } + + return ( +
+
+

+ + Judge Submissions +

+ + {submissions.length} submission{submissions.length !== 1 ? "s" : ""} + +
+ +
+ {submissions.map((sub, idx) => { + const isApproved = approved.has(sub.id); + const name = sub.submittedByUser?.name ?? sub.submittedBy; + const isPending = approveMutation.isPending; + + return ( +
+
+
+ #{idx + 1} + + {name} + + {isApproved && ( + + + Approved + + )} +
+ {idx === 0 && !isApproved && ( + + + Top + + )} +
+ + {sub.githubPullRequestUrl && ( + + {sub.githubPullRequestUrl} + + )} + + {!isFinalized && !isApproved && ( +
+
+ + + setPayouts((p) => ({ ...p, [sub.id]: e.target.value })) + } + className="h-8 text-sm" + disabled={isPending} + /> +
+
+ + + setPoints((p) => ({ ...p, [sub.id]: e.target.value })) + } + className="h-8 text-sm" + disabled={isPending} + /> +
+
+ )} + + {!isFinalized && !isApproved && ( + + )} +
+ ); + })} +
+ + {!isFinalized && approved.size > 0 && ( + <> + +
+

+ Finalize to close the contest and prevent further approvals. +

+ +
+ + )} + + {isFinalized && ( +
+ + Contest finalized. Results are published. +
+ )} +
+ ); +} diff --git a/components/bounty/competition-status.tsx b/components/bounty/competition-status.tsx new file mode 100644 index 00000000..4a9b7149 --- /dev/null +++ b/components/bounty/competition-status.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Users, Clock, Eye, EyeOff, CheckCircle2 } from "lucide-react"; + +interface CompetitionStatusProps { + participantCount: number; + maxParticipants?: number | null; + submissionCount: number; + deadline: string | null | undefined; + isFinalized: boolean; +} + +function isAfterDeadline(deadline: string | null | undefined): boolean { + if (!deadline) return false; + return Date.now() > new Date(deadline).getTime(); +} + +export function CompetitionStatus({ + participantCount, + maxParticipants, + submissionCount, + deadline, + isFinalized, +}: CompetitionStatusProps) { + const pastDeadline = isAfterDeadline(deadline); + + return ( +
+

+ Competition Status +

+ +
+ {/* Participants */} +
+ + + {participantCount} + {maxParticipants != null ? `/${maxParticipants}` : ""}{" "} + joined + +
+ + {/* Submissions */} +
+ {pastDeadline ? ( + + ) : ( + + )} + + {pastDeadline ? submissionCount : "?"}{" "} + + {pastDeadline ? "revealed" : "hidden"} + + +
+
+ + {/* Phase indicator */} +
+ {isFinalized ? ( + <> + + + Results published + + + ) : pastDeadline ? ( + <> + + + Judging in progress + + + ) : ( + <> + + + Accepting submissions + + + )} +
+
+ ); +} diff --git a/components/bounty/competition-submission.tsx b/components/bounty/competition-submission.tsx new file mode 100644 index 00000000..6e3f4d26 --- /dev/null +++ b/components/bounty/competition-submission.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Loader2, Lock, Send, Clock } from "lucide-react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { useSubmitContestWork } from "@/hooks/use-competition-bounty"; + +interface CompetitionSubmissionProps { + bountyId: string; + deadline: string | null | undefined; + hasJoined: boolean; +} + +function useCountdown(deadline: string | null | undefined) { + const [remaining, setRemaining] = useState(() => + deadline ? new Date(deadline).getTime() - Date.now() : -1, + ); + + useEffect(() => { + if (!deadline) return; + const id = setInterval(() => { + setRemaining(new Date(deadline).getTime() - Date.now()); + }, 1000); + return () => clearInterval(id); + }, [deadline]); + + return remaining; +} + +function formatCountdown(ms: number): string { + if (ms <= 0) return "Deadline passed"; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d}d ${h}h ${m}m`; + return `${h}h ${m}m ${sec}s`; +} + +export function CompetitionSubmission({ + bountyId, + deadline, + hasJoined, +}: CompetitionSubmissionProps) { + const { data: session } = authClient.useSession(); + const [workCid, setWorkCid] = useState(""); + const submitMutation = useSubmitContestWork(); + const remaining = useCountdown(deadline); + const isPastDeadline = remaining <= 0 && deadline != null; + + const walletAddress = + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.walletAddress || + (session?.user as { walletAddress?: string; address?: string } | undefined) + ?.address || + null; + + if (!hasJoined) return null; + + const handleSubmit = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to submit."); + return; + } + if (!workCid.trim()) { + toast.error("Please enter your submission link or CID."); + return; + } + try { + await submitMutation.mutateAsync({ + bountyId, + contributorAddress: walletAddress, + workCid: workCid.trim(), + }); + toast.success("Submission recorded on-chain."); + setWorkCid(""); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Submission failed."); + } + }; + + return ( +
+
+

Your Submission

+ {deadline && ( + + + {formatCountdown(remaining)} + + )} +
+ + {isPastDeadline ? ( +
+ + Submissions are closed. Results will be revealed by the creator. +
+ ) : ( +
+
+ +