Skip to content
Merged
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
149 changes: 34 additions & 115 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import {
Github,
Copy,
Check,
AlertCircle,
XCircle,
Loader2,
Users,
Clock,
Gavel,
} from "lucide-react";
import { Button } from "@/components/ui/button";
Expand All @@ -28,17 +26,46 @@ 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 type { CancellationRecord } from "@/types/escrow";
import type { Bounty } from "@/types/bounty";
import { ApplicationDialog } from "@/components/bounty/application-dialog";
import { useBountyCTAState } from "./use-bounty-cta-state";
import { RaiseDisputeDialog } from "./raise-dispute-dialog";

import { FcfsCta } from "./cta/fcfs-cta";
import { CompetitionCta } from "./cta/competition-cta";
import { MultiWinnerCta } from "./cta/multi-winner-cta";
import { MilestoneBasedCta } from "./cta/milestone-based-cta";
import { DefaultCta } from "./cta/default-cta";

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

interface BountyCtaProps {
bounty: SidebarBounty;
state: ReturnType<typeof useBountyCTAState>;
}

function BountyCta({ bounty, state }: BountyCtaProps) {
if (state.isFcfs) {
return <FcfsCta bounty={bounty} state={state} />;
}
if (state.isCompetition) {
return <CompetitionCta bounty={bounty} state={state} />;
}
if (
bounty.type === "MULTI_WINNER_MILESTONE" &&
state.canAct &&
!state.isCreator
) {
return <MultiWinnerCta bounty={bounty} state={state} />;
}
if (bounty.type === "MILESTONE_BASED" && state.canAct && !state.isCreator) {
return <MilestoneBasedCta bounty={bounty} state={state} />;
}
return <DefaultCta bounty={bounty} state={state} />;
}

interface SidebarCTAProps {
bounty: SidebarBounty;
onCancelled?: (record: CancellationRecord) => void;
Expand All @@ -47,13 +74,9 @@ interface SidebarCTAProps {
export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);

const state = useBountyCTAState({ bounty, onCancelled });
const {
walletAddress,
hasJoined,
isPastDeadline,
joinMutation,
handleJoin,
handleApply,
copied,
handleCopy,
cancelDialogOpen,
Expand All @@ -62,8 +85,6 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
setCancelReason,
isCancelling,
handleCancel,
canAct,
isFcfs,
isCompetition,
canRaiseDispute,
canCancel,
Expand All @@ -72,14 +93,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
deadline,
isFinalized,
submissionCount,
ctaLabel,
isCreator,
applyForSlotMutation,
handleApplyForSlot,
isSlotsFull,
isAlreadyJoined,
applyForSlotButtonLabel,
} = useBountyCTAState({ bounty, onCancelled });
} = state;

return (
<div className="space-y-4">
Expand Down Expand Up @@ -149,102 +163,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
{isCompetition && <Separator className="bg-gray-800/60" />}

{/* CTA */}
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : isCompetition ? (
hasJoined ? (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled
size="lg"
>
Joined ✓
</Button>
) : (
<Button
data-testid="apply-to-bounty-btn"
className="w-full h-11 font-bold tracking-wide"
disabled={
!canAct ||
isPastDeadline ||
joinMutation.isPending ||
!walletAddress
}
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>
)
) : bounty.type === "MULTI_WINNER_MILESTONE" && canAct && !isCreator ? (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={
isSlotsFull ||
isAlreadyJoined ||
!walletAddress ||
applyForSlotMutation.isPending
}
size="lg"
onClick={() => void handleApplyForSlot()}
>
{applyForSlotMutation.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Users className="mr-2 size-4" />
)}
{applyForSlotButtonLabel}
</Button>
) : bounty.type === "MILESTONE_BASED" && canAct && !isCreator ? (
<ApplicationDialog
bountyTitle={bounty.title}
onApply={handleApply}
trigger={
<Button
className="w-full h-11 font-bold tracking-wide"
size="lg"
disabled={!walletAddress}
>
Apply for Bounty
</Button>
}
/>
) : (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(
bounty.githubIssueUrl,
"_blank",
"noopener,noreferrer",
)
}
>
{ctaLabel()}
</Button>
)}

{/* 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>
)}
<BountyCta bounty={bounty} state={state} />

{/* Raise Dispute */}
{canRaiseDispute && (
Expand Down
74 changes: 74 additions & 0 deletions components/bounty-detail/cta/competition-cta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Users, Loader2, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { BountyFieldsFragment } from "@/lib/graphql/generated";
import type { Bounty } from "@/types/bounty";
import type { useBountyCTAState } from "../use-bounty-cta-state";

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

interface CompetitionCtaProps {
bounty: SidebarBounty;
state: Pick<
ReturnType<typeof useBountyCTAState>,
| "hasJoined"
| "canAct"
| "isPastDeadline"
| "joinMutation"
| "walletAddress"
| "handleJoin"
| "ctaLabel"
>;
}

export function CompetitionCta({ state }: CompetitionCtaProps) {
const {
hasJoined,
canAct,
isPastDeadline,
joinMutation,
walletAddress,
handleJoin,
ctaLabel,
} = state;

return (
<>
{hasJoined ? (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled
size="lg"
>
Joined ✓
</Button>
) : (
<Button
data-testid="apply-to-bounty-btn"
className="w-full h-11 font-bold tracking-wide"
disabled={
!canAct ||
isPastDeadline ||
joinMutation.isPending ||
!walletAddress
}
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>
Comment on lines +45 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a competition-specific closed label after the deadline.

Line 62 falls back to ctaLabel() when isPastDeadline is true, but ctaLabel() only looks at bounty status. For an OPEN competition past its deadline, the disabled button will read "Submit to Bounty", which is the wrong action here.

Suggested fix
-          {canAct && !isPastDeadline ? "Join Competition" : ctaLabel()}
+          {!canAct
+            ? ctaLabel()
+            : isPastDeadline
+              ? "Deadline Passed"
+              : "Join Competition"}
📝 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
<Button
data-testid="apply-to-bounty-btn"
className="w-full h-11 font-bold tracking-wide"
disabled={
!canAct ||
isPastDeadline ||
joinMutation.isPending ||
!walletAddress
}
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
data-testid="apply-to-bounty-btn"
className="w-full h-11 font-bold tracking-wide"
disabled={
!canAct ||
isPastDeadline ||
joinMutation.isPending ||
!walletAddress
}
size="lg"
onClick={() => void handleJoin()}
>
{joinMutation.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Users className="mr-2 size-4" />
)}
{!canAct
? ctaLabel()
: isPastDeadline
? "Deadline Passed"
: "Join Competition"}
</Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/bounty-detail/cta/competition-cta.tsx` around lines 45 - 63, The
CTA in competition-cta.tsx is falling back to ctaLabel() after the deadline,
which uses bounty status only and can show the wrong action text for a
past-deadline OPEN competition. Update the Button label logic in the competition
CTA so it uses a competition-specific closed/ended label when isPastDeadline is
true, while keeping the existing “Join Competition” text only for the active
canAct case. Reference the Button rendering in competition-cta.tsx and the
ctaLabel() usage to keep the change localized.

)}

{!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>
)}
</>
);
}
39 changes: 39 additions & 0 deletions components/bounty-detail/cta/default-cta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { BountyFieldsFragment } from "@/lib/graphql/generated";
import type { Bounty } from "@/types/bounty";
import type { useBountyCTAState } from "../use-bounty-cta-state";

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

interface DefaultCtaProps {
bounty: SidebarBounty;
state: Pick<ReturnType<typeof useBountyCTAState>, "canAct" | "ctaLabel">;
}

export function DefaultCta({ bounty, state }: DefaultCtaProps) {
const { canAct, ctaLabel } = state;

return (
<>
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
}
>
{ctaLabel()}
</Button>

{!canAct && (
<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>
)}
</>
);
}
28 changes: 28 additions & 0 deletions components/bounty-detail/cta/fcfs-cta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";
import { AlertCircle } from "lucide-react";
import type { BountyFieldsFragment } from "@/lib/graphql/generated";
import type { Bounty } from "@/types/bounty";
import type { useBountyCTAState } from "../use-bounty-cta-state";

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

interface FcfsCtaProps {
bounty: SidebarBounty;
state: Pick<ReturnType<typeof useBountyCTAState>, "canAct">;
}

export function FcfsCta({ bounty, state }: FcfsCtaProps) {
const { canAct } = state;

return (
<>
<FcfsClaimButton bounty={bounty} />
{!canAct && (
<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>
)}
</>
);
}
35 changes: 35 additions & 0 deletions components/bounty-detail/cta/milestone-based-cta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Button } from "@/components/ui/button";
import { ApplicationDialog } from "@/components/bounty/application-dialog";
import type { BountyFieldsFragment } from "@/lib/graphql/generated";
import type { Bounty } from "@/types/bounty";
import type { useBountyCTAState } from "../use-bounty-cta-state";

type SidebarBounty = BountyFieldsFragment & Partial<Bounty>;

interface MilestoneBasedCtaProps {
bounty: SidebarBounty;
state: Pick<
ReturnType<typeof useBountyCTAState>,
"walletAddress" | "handleApply"
>;
}

export function MilestoneBasedCta({ bounty, state }: MilestoneBasedCtaProps) {
const { walletAddress, handleApply } = state;

return (
<ApplicationDialog
bountyTitle={bounty.title}
onApply={handleApply}
trigger={
<Button
className="w-full h-11 font-bold tracking-wide"
size="lg"
disabled={!walletAddress}
>
Apply for Bounty
</Button>
}
/>
);
}
Loading
Loading