-
Notifications
You must be signed in to change notification settings - Fork 42
feat: implement competition bounty flow UI with blind submissions and… #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,13 +4,20 @@ import { useState } from "react"; | |
| import { Github, Copy, Check, AlertCircle } from "lucide-react"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Separator } from "@/components/ui/separator"; | ||
| import { useCompetitionBounty } from "@/hooks/use-competition-bounty"; | ||
|
|
||
| import { BountyFieldsFragment } from "@/lib/graphql/generated"; | ||
| import { StatusBadge, TypeBadge } from "./bounty-badges"; | ||
|
|
||
| export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { | ||
| const [copied, setCopied] = useState(false); | ||
| const canAct = bounty.status === "OPEN"; | ||
| const isCompetition = bounty.type === "COMPETITION"; | ||
| const { participantCount, maxParticipants } = useCompetitionBounty({ | ||
| bountyId: bounty.id, | ||
| submissionDeadline: bounty.bountyWindow?.endDate ?? null, | ||
| maxParticipants: 5, | ||
| }); | ||
|
Comment on lines
14
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t label this as “Join Competition” if it still just opens GitHub. On both desktop and mobile, the primary CTA now advertises joining, but the click handler still opens Also applies to: 85-95, 143-173 🤖 Prompt for AI Agents |
||
|
|
||
| const handleCopy = async () => { | ||
| try { | ||
|
|
@@ -35,7 +42,7 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { | |
| return "Not Available"; | ||
| } | ||
| } | ||
| return "Submit to Bounty"; | ||
| return isCompetition ? "Join Competition" : "Submit to Bounty"; | ||
| }; | ||
|
|
||
| return ( | ||
|
|
@@ -87,6 +94,12 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { | |
| {ctaLabel()} | ||
| </Button> | ||
|
|
||
| {isCompetition && ( | ||
| <p className="text-xs text-gray-500 text-center"> | ||
| {participantCount}/{maxParticipants} slots filled | ||
| </p> | ||
| )} | ||
|
|
||
| {!canAct && ( | ||
| <p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center"> | ||
| <AlertCircle className="size-3 shrink-0" /> | ||
|
|
@@ -129,6 +142,7 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { | |
|
|
||
| export function MobileCTA({ bounty }: { bounty: BountyFieldsFragment }) { | ||
| const canAct = bounty.status === "OPEN"; | ||
| const isCompetition = bounty.type === "COMPETITION"; | ||
|
|
||
| const label = () => { | ||
| if (!canAct) { | ||
|
|
@@ -141,7 +155,7 @@ export function MobileCTA({ bounty }: { bounty: BountyFieldsFragment }) { | |
| return "Not Available"; | ||
| } | ||
| } | ||
| return "Submit to Bounty"; | ||
| return isCompetition ? "Join Competition" : "Submit to Bounty"; | ||
| }; | ||
|
|
||
| return ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ import { | |
| } from "@/components/ui/card"; | ||
| import { Badge } from "@/components/ui/badge"; | ||
| import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; | ||
| import { Clock } from "lucide-react"; | ||
| import { Clock, Users } from "lucide-react"; | ||
| import { cn } from "@/lib/utils"; | ||
| import { formatDistanceToNow } from "date-fns"; | ||
| import { BountyFieldsFragment } from "@/lib/graphql/generated"; | ||
|
|
@@ -82,6 +82,9 @@ export function BountyCard({ | |
|
|
||
| const orgName = bounty.organization?.name ?? "Unknown"; | ||
| const orgLogo = bounty.organization?.logo; | ||
| const isCompetition = bounty.type === "COMPETITION"; | ||
| const filledSlots = isCompetition ? (bounty._count?.submissions ?? 0) : 0; | ||
| const maxSlots = 5; | ||
|
Comment on lines
+85
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use participant count here, not submission count.
Also applies to: 141-145 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <Card | ||
|
|
@@ -135,6 +138,12 @@ export function BountyCard({ | |
| <Badge variant="outline" className="text-xs px-2.5 py-1 "> | ||
| {bounty.type.replace(/_/g, " ")} | ||
| </Badge> | ||
| {isCompetition && ( | ||
| <Badge variant="secondary" className="text-xs px-2.5 py-1 gap-1"> | ||
| <Users className="size-3" /> | ||
| {Math.min(filledSlots, maxSlots)}/{maxSlots} joined | ||
| </Badge> | ||
| )} | ||
| </div> | ||
| </CardHeader> | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| "use client"; | ||
|
|
||
| import { useMemo, useState } from "react"; | ||
| import { toast } from "sonner"; | ||
| import { authClient } from "@/lib/auth-client"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Input } from "@/components/ui/input"; | ||
| import { useCompetitionBounty } from "@/hooks/use-competition-bounty"; | ||
|
|
||
| type SessionUser = { | ||
| id: string; | ||
| name?: string | null; | ||
| }; | ||
|
|
||
| export function CompetitionJudging({ | ||
| bountyId, | ||
| creatorId, | ||
| deadline, | ||
| rewardAmount, | ||
| maxParticipants = 5, | ||
| }: { | ||
| bountyId: string; | ||
| creatorId: string; | ||
| deadline?: string | null; | ||
| rewardAmount: number; | ||
| maxParticipants?: number; | ||
| }) { | ||
| const { data: session } = authClient.useSession(); | ||
| const user = session?.user as SessionUser | undefined; | ||
| const [payoutBySubmission, setPayoutBySubmission] = useState< | ||
| Record<string, string> | ||
| >({}); | ||
| const [pointsBySubmission, setPointsBySubmission] = useState< | ||
| Record<string, string> | ||
| >({}); | ||
|
|
||
| const { | ||
| state, | ||
| isAfterDeadline, | ||
| revealedSubmissions, | ||
| approveContestWinner, | ||
| finalizeContest, | ||
| } = useCompetitionBounty({ | ||
| bountyId, | ||
| submissionDeadline: deadline, | ||
| maxParticipants, | ||
| }); | ||
|
|
||
| const isCreator = user?.id === creatorId; | ||
|
|
||
| const awardedUserIds = useMemo( | ||
| () => new Set(state.awards.map((it) => it.recipientUserId)), | ||
| [state.awards], | ||
| ); | ||
|
Comment on lines
+51
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Require a single primary winner before finalizing. This only de-duplicates recipients, so multiple submissions can still be marked as winners, and Suggested fix const awardedUserIds = useMemo(
() => new Set(state.awards.map((it) => it.recipientUserId)),
[state.awards],
);
+ const hasWinner = useMemo(
+ () => state.awards.some((it) => it.isWinner),
+ [state.awards],
+ );
const handleFinalize = () => {
- if (state.awards.length === 0) {
- toast.error("Select at least one winner or consolation award first.");
+ if (!hasWinner) {
+ toast.error("Select a winner before finalizing the contest.");
return;
}
finalizeContest();
toast.success("Contest finalized.");
};
@@
<Button
size="sm"
onClick={() => handleAward(submission.id, true)}
- disabled={state.finalized || alreadyAwarded}
+ disabled={state.finalized || alreadyAwarded || hasWinner}
>
Select as Winner
</Button>Also applies to: 77-83, 155-167 🤖 Prompt for AI Agents |
||
|
|
||
| const handleAward = (submissionId: string, isWinner: boolean) => { | ||
| const submission = revealedSubmissions.find((it) => it.id === submissionId); | ||
| if (!submission) return; | ||
|
|
||
| const payout = Number(payoutBySubmission[submissionId] || "0"); | ||
| const points = Number(pointsBySubmission[submissionId] || "0"); | ||
| if (!Number.isFinite(payout) || payout <= 0) { | ||
| toast.error("Enter a valid payout amount."); | ||
| return; | ||
| } | ||
|
|
||
| approveContestWinner({ | ||
| recipientUserId: submission.participantUserId, | ||
| recipientDisplayName: submission.participantDisplayName, | ||
| payoutAmount: payout, | ||
| points: Number.isFinite(points) ? points : 0, | ||
| isWinner, | ||
| }); | ||
|
Comment on lines
+60
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Block awards that exceed the prize pool. The handler only checks that the next payout is positive. With multiple awards, the total can easily exceed Suggested fix+ const awardedPayoutTotal = useMemo(
+ () => state.awards.reduce((sum, award) => sum + award.payoutAmount, 0),
+ [state.awards],
+ );
+
const handleAward = (submissionId: string, isWinner: boolean) => {
const submission = revealedSubmissions.find((it) => it.id === submissionId);
if (!submission) return;
const payout = Number(payoutBySubmission[submissionId] || "0");
const points = Number(pointsBySubmission[submissionId] || "0");
+ const remainingPrizePool = rewardAmount - awardedPayoutTotal;
if (!Number.isFinite(payout) || payout <= 0) {
toast.error("Enter a valid payout amount.");
return;
}
+ if (payout > remainingPrizePool) {
+ toast.error(`Payout exceeds the remaining prize pool (${remainingPrizePool}).`);
+ return;
+ }
approveContestWinner({🤖 Prompt for AI Agents |
||
| toast.success(isWinner ? "Winner selected." : "Consolation awarded."); | ||
| }; | ||
|
|
||
| const handleFinalize = () => { | ||
| if (state.awards.length === 0) { | ||
| toast.error("Select at least one winner or consolation award first."); | ||
| return; | ||
| } | ||
| finalizeContest(); | ||
| toast.success("Contest finalized."); | ||
| }; | ||
|
|
||
| if (!isCreator) return null; | ||
|
|
||
| return ( | ||
| <div className="p-5 rounded-xl border border-gray-800 bg-background-card space-y-4"> | ||
| <div className="flex items-center justify-between gap-3"> | ||
| <h3 className="text-sm font-semibold text-gray-200"> | ||
| Competition Judging | ||
| </h3> | ||
| <span className="text-xs text-gray-400"> | ||
| Prize pool: ${rewardAmount} | ||
| </span> | ||
| </div> | ||
|
|
||
| {!isAfterDeadline ? ( | ||
| <p className="text-xs text-gray-500"> | ||
| Judging opens after the submission deadline. | ||
| </p> | ||
| ) : revealedSubmissions.length === 0 ? ( | ||
| <p className="text-xs text-gray-500">No submissions to review.</p> | ||
| ) : ( | ||
| <div className="space-y-3"> | ||
| {revealedSubmissions.map((submission, index) => { | ||
| const alreadyAwarded = awardedUserIds.has( | ||
| submission.participantUserId, | ||
| ); | ||
| return ( | ||
| <div | ||
| key={submission.id} | ||
| className="rounded-lg border border-gray-700 p-3 space-y-3" | ||
| > | ||
| <div className="flex items-center justify-between gap-2"> | ||
| <p className="text-sm text-gray-200"> | ||
| Submission #{index + 1} | ||
| </p> | ||
| <span className="text-xs text-gray-500"> | ||
| {new Date(submission.submittedAt).toLocaleString()} | ||
| </span> | ||
| </div> | ||
| <p className="text-xs text-gray-300 break-all"> | ||
| {submission.workCid} | ||
| </p> | ||
|
|
||
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> | ||
| <Input | ||
| inputMode="decimal" | ||
| placeholder="Payout amount" | ||
| value={payoutBySubmission[submission.id] || ""} | ||
| onChange={(event) => | ||
| setPayoutBySubmission((prev) => ({ | ||
| ...prev, | ||
| [submission.id]: event.target.value, | ||
| })) | ||
| } | ||
| disabled={state.finalized || alreadyAwarded} | ||
| /> | ||
| <Input | ||
| inputMode="numeric" | ||
| placeholder="Points" | ||
| value={pointsBySubmission[submission.id] || ""} | ||
| onChange={(event) => | ||
| setPointsBySubmission((prev) => ({ | ||
| ...prev, | ||
| [submission.id]: event.target.value, | ||
| })) | ||
| } | ||
| disabled={state.finalized || alreadyAwarded} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="flex flex-wrap gap-2"> | ||
| <Button | ||
| size="sm" | ||
| onClick={() => handleAward(submission.id, true)} | ||
| disabled={state.finalized || alreadyAwarded} | ||
| > | ||
| Select as Winner | ||
| </Button> | ||
| <Button | ||
| size="sm" | ||
| variant="secondary" | ||
| onClick={() => handleAward(submission.id, false)} | ||
| disabled={state.finalized || alreadyAwarded} | ||
| > | ||
| Award Consolation | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="border-t border-gray-800 pt-3 flex items-center justify-between gap-2"> | ||
| <p className="text-xs text-gray-500"> | ||
| {state.finalized | ||
| ? "Contest finalized" | ||
| : "Finalizing locks further awards."} | ||
| </p> | ||
| <Button | ||
| type="button" | ||
| variant={state.finalized ? "outline" : "default"} | ||
| onClick={handleFinalize} | ||
| disabled={state.finalized || !isAfterDeadline} | ||
| > | ||
| Finalize Contest | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fail fast when a competition has no usable deadline.
These components all depend on crossing
deadlineto reveal submissions and unlock judging. IfbountyWindow?.endDateis null or invalid, the page stays in a permanent “hidden until deadline” state and the creator never gets a reveal/finalize path.💡 Suggested guard
Also applies to: 79-92
🤖 Prompt for AI Agents