Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion app/bounty/[bountyId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export default async function BountyDetailPage({ params }: Props) {
{/* Ambient glow – matches BountiesPage */}
<div className="fixed top-0 left-0 w-full h-125 bg-primary/5 rounded-full blur-[120px] -translate-y-1/2 pointer-events-none" />

<div className="container mx-auto px-4 py-10 relative z-10">
<div
className="container mx-auto px-4 py-10 relative z-10"
data-bounty-id={bountyId}
>
{/* Breadcrumb */}
<nav
aria-label="Breadcrumb"
Expand Down
21 changes: 20 additions & 1 deletion components/bounty-detail/bounty-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { DescriptionCard } from "./bounty-detail-description-card";
import { BountyDetailSubmissionsCard } from "./bounty-detail-submissions-card";
import { BountyDetailSkeleton } from "./bounty-detail-bounty-detail-skeleton";
import { useBountyDetail } from "@/hooks/use-bounty-detail";
import { CompetitionSubmission } from "@/components/bounty/competition-submission";
import { CompetitionJudging } from "@/components/bounty/competition-judging";
import { CompetitionStatus } from "@/components/bounty/competition-status";

export function BountyDetailClient({ bountyId }: { bountyId: string }) {
const router = useRouter();
Expand Down Expand Up @@ -64,13 +67,29 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
);
}

const isCompetition = bounty.type === "COMPETITION";
const deadline = bounty.bountyWindow?.endDate ?? null;

Comment on lines +70 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when a competition has no usable deadline.

These components all depend on crossing deadline to reveal submissions and unlock judging. If bountyWindow?.endDate is null or invalid, the page stays in a permanent “hidden until deadline” state and the creator never gets a reveal/finalize path.

💡 Suggested guard
   const isCompetition = bounty.type === "COMPETITION";
   const deadline = bounty.bountyWindow?.endDate ?? null;
+  const hasValidCompetitionDeadline =
+    !isCompetition ||
+    (deadline !== null && !Number.isNaN(new Date(deadline).getTime()));

   return (
     <div className="flex flex-col lg:flex-row gap-10">
       {/* Main content */}
       <div className="flex-1 min-w-0 space-y-6">
         <HeaderCard bounty={bounty} />
         <DescriptionCard description={bounty.description} />
-        {isCompetition ? (
+        {isCompetition ? (
+          hasValidCompetitionDeadline ? (
           <>
             <CompetitionSubmission bountyId={bounty.id} deadline={deadline} />
             <CompetitionStatus bountyId={bounty.id} deadline={deadline} />
             <CompetitionJudging
               bountyId={bounty.id}
               creatorId={bounty.createdBy}
               deadline={deadline}
               rewardAmount={bounty.rewardAmount}
             />
           </>
+          ) : (
+            <div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-300">
+              This competition is missing a valid submission deadline.
+            </div>
+          )
         ) : (
           <BountyDetailSubmissionsCard bounty={bounty} />
         )}

Also applies to: 79-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/bounty-detail-client.tsx` around lines 70 - 72, When
rendering a COMPETITION bounty, fail fast if bounty.bountyWindow?.endDate is
missing or invalid: check isCompetition and validate deadline (e.g., ensure
deadline is non-null and parseable as a Date) at the top of the component (where
isCompetition and deadline are computed) and, if invalid, log the problem and
return an explicit error/fallback UI (or throw) so the page does not remain
permanently "hidden until deadline"; also update any reveal/finalize code paths
that assume deadline (the logic referenced around lines 79-92) to early-return
when deadline is invalid.

return (
<div className="flex flex-col lg:flex-row gap-10">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
<HeaderCard bounty={bounty} />
<DescriptionCard description={bounty.description} />
<BountyDetailSubmissionsCard bounty={bounty} />
{isCompetition ? (
<>
<CompetitionSubmission bountyId={bounty.id} deadline={deadline} />
<CompetitionStatus bountyId={bounty.id} deadline={deadline} />
<CompetitionJudging
bountyId={bounty.id}
creatorId={bounty.createdBy}
deadline={deadline}
rewardAmount={bounty.rewardAmount}
/>
</>
) : (
<BountyDetailSubmissionsCard bounty={bounty} />
)}
</div>

{/* Sidebar */}
Expand Down
18 changes: 16 additions & 2 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 githubIssueUrl. Because canAct only looks at bounty status, it can also remain enabled after the submission deadline. That makes the main action misleading right when the competition is actually closed.

Also applies to: 85-95, 143-173

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 14 - 20,
The primary CTA is misleading because canAct only checks bounty.status and the
button still opens githubIssueUrl; update the logic so that for competitions
(isCompetition) you compute a real "canJoin" by checking submission deadline
(submissionDeadline or bounty.bountyWindow?.endDate), participantCount vs
maxParticipants from useCompetitionBounty, and current time, and use that to
determine the label and handler: if canJoin is true label "Join Competition" and
run the join flow; if not, label "View on GitHub" (or disable) and open
githubIssueUrl. Replace usages of canAct in the CTA render/handler (including
the other CTA blocks referenced) with this new computed canJoin and adjust
labels/disabled state accordingly.


const handleCopy = async () => {
try {
Expand All @@ -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 (
Expand Down Expand Up @@ -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" />
Expand Down Expand Up @@ -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) {
Expand All @@ -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 (
Expand Down
11 changes: 10 additions & 1 deletion components/bounty/bounty-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use participant count here, not submission count.

_count.submissions only increments after work is uploaded, but this badge is labeled as joined/max joined. A competition can have filled slots and still render 0/5 joined until people submit, which makes the card disagree with the rest of the competition flow.

Also applies to: 141-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/bounty-card.tsx` around lines 85 - 87, The badge is using
bounty._count.submissions which only counts after uploads; change it to use the
participant count instead so the "joined/max joined" badge reflects people who
joined regardless of submission status. Update the filledSlots computation
(where isCompetition is set and filledSlots is defined) to use
bounty._count.participants (or the correct participants property on bounty)
instead of bounty._count.submissions, and make the same replacement for the
other occurrence around lines where filledSlots/maxSlots are computed (the
second block referenced). Ensure the property exists on the bounty type or add a
safe fallback (e.g., ?? 0) to avoid undefined values.


return (
<Card
Expand Down Expand Up @@ -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>

Expand Down
195 changes: 195 additions & 0 deletions components/bounty/competition-judging.tsx
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Require a single primary winner before finalizing.

This only de-duplicates recipients, so multiple submissions can still be marked as winners, and handleFinalize accepts a consolation-only state because it checks state.awards.length instead of winner presence. That breaks the "Best Submission Wins" flow.

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
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/competition-judging.tsx` around lines 51 - 54, The current
dedupe (awardedUserIds) and finalization checks use state.awards.length which
allows consolation-only awards and multiple winning submissions; change logic to
require one primary winner: add a computed flag (e.g., primaryWinnerExists =
state.awards.some(a => a.isPrimary || a.isWinner)) and use that in
handleFinalize and any "can finalize" guards instead of state.awards.length;
keep awardedUserIds for deduping recipients but ensure UI paths
(buttons/validation in handleFinalize, any checks around the award editing code)
prevent finalization unless primaryWinnerExists is true.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 rewardAmount, so this UI can present a finalized distribution that the underlying bounty cannot honor.

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
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/competition-judging.tsx` around lines 60 - 73, The handler
currently only validates a single payout amount (using payoutBySubmission,
pointsBySubmission and then calling approveContestWinner) but does not ensure
the sum of all payouts stays within the bounty's rewardAmount; update the logic
(in the same function that computes payout and points and calls
approveContestWinner) to compute the totalPayout = sum of
Number(payoutBySubmission[id] || 0) for all submission ids but replace the
current submissionId's entry with the new payout, then if totalPayout >
Number(rewardAmount) show toast.error("Total awards exceed prize pool.") and
return; otherwise proceed to call approveContestWinner as before. Ensure you
continue to use Number.isFinite checks for individual payout and points before
summing.

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>
);
}
Loading
Loading