Skip to content
Merged
30 changes: 27 additions & 3 deletions components/bounty-detail/bounty-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ 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 type { CompetitionSubmissionEntry } 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 { useDeadlinePassed } from "@/hooks/use-deadline-passed";
import type { CancellationRecord } from "@/types/escrow";
import { MilestoneFunnel } from "@/components/bounty/milestone-funnel";
import {
Expand Down Expand Up @@ -63,15 +66,16 @@ 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<CancellationRecord | null>(null);

const { data: session } = authClient.useSession();

const handleCancelled = useCallback((record: CancellationRecord) => {
setCancellationRecord(record);
}, []);

const pastDeadline = useDeadlinePassed(bounty?.bountyWindow?.endDate);

if (isPending) return <BountyDetailSkeleton />;

if (isError) {
Expand Down Expand Up @@ -125,6 +129,17 @@ 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";
// submissions is present on BountyQuery (single-bounty query) but not on
// BountyFieldsFragment (list query). The cast is safe here because
// useBountyDetail returns BountyFieldsFragment & Partial<BountyQuery["bounty"]>.
const competitionSubmissions =
(bounty as { submissions?: CompetitionSubmissionEntry[] | null })
.submissions ?? [];

return (
<div className="flex flex-col lg:flex-row gap-10">
{/* Main content */}
Expand Down Expand Up @@ -185,10 +200,19 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {

{!isCancelled && pool && <EscrowDetailPanel poolId={bountyId} />}
<RefundStatusTracker bountyId={bountyId} isCancelled={isCancelled} />
{bounty.type !== "FIXED_PRICE" && (
{bounty.type !== "FIXED_PRICE" && !isCompetition && (
<BountyDetailSubmissionsCard bounty={bounty} />
)}
{bounty.type === "FIXED_PRICE" && <FcfsApprovalPanel bounty={bounty} />}
{isCompetition && isCreator && (pastDeadline || isFinalized) && (
<CompetitionJudging
bountyId={bountyId}
submissions={competitionSubmissions}
isFinalized={isFinalized}
totalReward={bounty.rewardAmount}
currency={bounty.rewardCurrency}
/>
)}
Comment on lines +207 to +215

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 | 🟡 Minor

totalReward / currency may be nullable at this call site.

CompetitionJudging types totalReward: number and currency: string, but bounty.rewardAmount can be missing (the sidebar renders "TBD" when bounty.rewardAmount != null is false) and bounty.rewardCurrency is similarly optional in the fragment. When null is forwarded, the Input's max={totalReward} becomes max={null} and BigInt(Math.round(payout * 1e7)) is fine but the payout placeholder String(totalReward) renders as "null". Guard the render (or make the props nullable + handle them inside CompetitionJudging).

🔧 Example guard
-        {isCompetition && isCreator && (pastDeadline || isFinalized) && (
+        {isCompetition &&
+          isCreator &&
+          (pastDeadline || isFinalized) &&
+          bounty.rewardAmount != null &&
+          bounty.rewardCurrency && (
           <CompetitionJudging
             bountyId={bountyId}
             submissions={competitionSubmissions}
             isFinalized={isFinalized}
             totalReward={bounty.rewardAmount}
             currency={bounty.rewardCurrency}
           />
         )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{isCompetition && isCreator && (pastDeadline || isFinalized) && (
<CompetitionJudging
bountyId={bountyId}
submissions={competitionSubmissions}
isFinalized={isFinalized}
totalReward={bounty.rewardAmount}
currency={bounty.rewardCurrency}
/>
)}
{isCompetition &&
isCreator &&
(pastDeadline || isFinalized) &&
bounty.rewardAmount != null &&
bounty.rewardCurrency && (
<CompetitionJudging
bountyId={bountyId}
submissions={competitionSubmissions}
isFinalized={isFinalized}
totalReward={bounty.rewardAmount}
currency={bounty.rewardCurrency}
/>
)}
🤖 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 116 - 124,
The call to CompetitionJudging passes bounty.rewardAmount and
bounty.rewardCurrency which can be null/undefined but the component expects
totalReward: number and currency: string; update the render to guard and only
render <CompetitionJudging ... /> when bounty.rewardAmount != null &&
bounty.rewardCurrency != null (or alternatively change CompetitionJudging props
to accept nullable totalReward?: number and currency?: string and handle
placeholders/limits inside CompetitionJudging such as guarding Input max and
String(totalReward)); locate the usage of CompetitionJudging and the
bounty.rewardAmount / bounty.rewardCurrency references and apply the chosen fix
so no null is forwarded to numeric/string props.

</div>

{/* Sidebar */}
Expand Down
145 changes: 110 additions & 35 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
XCircle,
Loader2,
Users,
Clock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
Expand All @@ -27,7 +28,10 @@ import {
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 { useCompetitionJoinState } from "@/hooks/use-competition-join-state";
import type { CancellationRecord } from "@/types/escrow";
import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog";
import type { Bounty } from "@/types/bounty";
Expand All @@ -44,7 +48,6 @@ interface SidebarCTAProps {

export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
const [copied, setCopied] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const { data: session } = authClient.useSession();

const {
Expand All @@ -58,10 +61,22 @@ 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");

// claimCount: use backend claimCount when available, fall back to _count.submissions
const claimCount = bounty.claimCount ?? bounty._count?.submissions ?? 0;
const maxParticipants = bounty.maxParticipants ?? null;
const deadline = bounty.bountyWindow?.endDate ?? null;
const isFinalized = bounty.status === "COMPLETED";
const submissionCount = bounty._count?.submissions ?? 0;

const { hasJoined, isPastDeadline, joinMutation, handleJoin } =
useCompetitionJoinState(bounty);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
Expand Down Expand Up @@ -139,39 +154,49 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {

<Separator className="bg-gray-800/60" />

{/* Competition slot count */}
{isCompetition && (
<div className="flex items-center justify-between text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Users className="size-3.5" />
Slots
</span>
<span className="font-medium text-gray-200">
{claimCount}
{maxParticipants != null ? `/${maxParticipants}` : ""} joined
</span>
</div>
)}

{isCompetition && <Separator className="bg-gray-800/60" />}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* CTA */}
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : bounty.type === "MULTI_WINNER_MILESTONE" ? (
(() => {
const occupied = bounty.totalSlotsOccupied ?? 0;
const max = bounty.maxSlots ?? 5;
const isFull = occupied >= max;
return (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct || isFull || isApplying}
size="lg"
onClick={async () => {
setIsApplying(true);
console.log(
"[Coming soon] Applying for slot for bounty:",
bounty.id,
);
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsApplying(false);
}}
>
{isApplying ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : isFull ? (
"Slots Full"
) : (
"Apply for Slot [Coming soon]"
)}
</Button>
);
})()
) : isCompetition ? (
hasJoined ? (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled
size="lg"
>
Joined ✓
</Button>
) : (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct || isPastDeadline || joinMutation.isPending}
size="lg"
onClick={() => void handleJoin()}
>
{joinMutation.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Users className="mr-2 size-4" />
)}
{canAct && !isPastDeadline ? "Join Competition" : ctaLabel()}
</Button>
)
) : (
<Button
className="w-full h-11 font-bold tracking-wide"
Expand All @@ -190,14 +215,21 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
</Button>
)}

{!canAct && (
{/* Helper text when locked out */}
{isCompetition && !hasJoined && isPastDeadline && (
<p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center">
<Clock className="size-3 shrink-0" />
Submission deadline has passed.
</p>
)}
{!canAct && !isCompetition && (
<p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center">
<AlertCircle className="size-3 shrink-0" />
This bounty is no longer accepting new submissions.
</p>
)}

{/* Cancel Bounty - only for creator on open/in-progress */}
{/* Cancel Bounty */}
{canCancel && (
<>
<Separator className="bg-gray-800/60" />
Expand Down Expand Up @@ -243,6 +275,24 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
</button>
</div>

{/* Competition status + submission panel */}
{isCompetition && (
<>
<CompetitionStatus
claimCount={claimCount}
maxParticipants={maxParticipants}
submissionCount={submissionCount}
deadline={deadline}
isFinalized={isFinalized}
/>
<CompetitionSubmission
bountyId={bounty.id}
deadline={deadline}
hasJoined={hasJoined}
/>
</>
)}

{/* Cancel Confirmation Dialog */}
<AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<AlertDialogContent>
Expand Down Expand Up @@ -328,10 +378,15 @@ 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 { hasJoined, isPastDeadline, joinMutation, handleJoin } =
useCompetitionJoinState(bounty);

const label = () => {
if (!canAct) {
switch (bounty.status) {
Expand All @@ -350,6 +405,26 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
<div className="lg:hidden fixed bottom-0 left-0 right-0 p-4 bg-background/90 backdrop-blur-xl border-t border-gray-800/60 z-20">
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : isCompetition ? (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={
!canAct || hasJoined || isPastDeadline || joinMutation.isPending
}
size="lg"
onClick={() => void handleJoin()}
>
{joinMutation.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Users className="mr-2 size-4" />
)}
{hasJoined
? "Joined ✓"
: canAct && !isPastDeadline
? "Join Competition"
: label()}
</Button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) : (
<div className="flex gap-2">
<Button
Expand Down
13 changes: 12 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, Zap } from "lucide-react";
import { Clock, Users, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { BountyFieldsFragment } from "@/lib/graphql/generated";
Expand Down Expand Up @@ -91,6 +91,10 @@ export function BountyCard({
statusConfig[normalizedStatus.toLowerCase()] ?? statusConfig.open;
const isFcfsClaimed =
bounty.type === "FIXED_PRICE" && normalizedStatus === "IN_PROGRESS";
const isCompetition = bounty.type === "COMPETITION";
// claimCount: use backend claimCount when available, fall back to _count.submissions
const slotCount = bounty.claimCount ?? bounty._count?.submissions ?? 0;
const maxParticipants = bounty.maxParticipants ?? null;
const timeLeft = bounty.updatedAt
? formatDistanceToNow(new Date(bounty.updatedAt), { addSuffix: true })
: "N/A";
Expand Down Expand Up @@ -212,6 +216,13 @@ export function BountyCard({
<Badge variant="outline" className="text-xs px-2.5 py-1 ">
{bounty.type.replace(/_/g, " ")}
</Badge>
{isCompetition && (
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs px-2.5 py-1 flex items-center gap-1">
<Users className="size-3" />
{slotCount}
{maxParticipants != null ? `/${maxParticipants}` : ""} joined
</Badge>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</CardHeader>

Expand Down
Loading
Loading