Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 9 additions & 2 deletions components/bounty-detail/bounty-badges.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { STATUS_CONFIG, TYPE_CONFIG } from "@/lib/bounty-config";

export function StatusBadge({ status }: { status: string }) {
export function StatusBadge({
status,
type,
}: {
status: string;
type?: string;
}) {
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.COMPLETED;
const isClaimed = type === "FIXED_PRICE" && status === "IN_PROGRESS";
return (
<span
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${cfg.className}`}
>
<span className={`size-1.5 rounded-full ${cfg.dot} animate-pulse`} />
{cfg.label}
{isClaimed ? "Claimed" : cfg.label}
</span>
);
}
Expand Down
2 changes: 2 additions & 0 deletions components/bounty-detail/bounty-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { FcfsApprovalPanel } from "@/components/bounty/fcfs-approval-panel";

export function BountyDetailClient({ bountyId }: { bountyId: string }) {
const router = useRouter();
Expand Down Expand Up @@ -71,6 +72,7 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
<HeaderCard bounty={bounty} />
<DescriptionCard description={bounty.description} />
<BountyDetailSubmissionsCard bounty={bounty} />
<FcfsApprovalPanel bounty={bounty} />
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 | 🔴 Critical

The legacy submissions card still breaks the FCFS flow.

BountyDetailSubmissionsCard is still the only place that exposes “Submit PR”, and it only does so for OPEN bounties. That means fixed-price users can submit before claiming, but once a claim moves the bounty to IN_PROGRESS, the claimant loses the only submission UI entirely. Rendering the unchanged legacy card next to FcfsApprovalPanel also leaves the old review/mark-paid controls active for creators.

Suggested direction
-        <BountyDetailSubmissionsCard bounty={bounty} />
-        <FcfsApprovalPanel bounty={bounty} />
+        <BountyDetailSubmissionsCard
+          bounty={bounty}
+          hideLegacyFcfsActions={bounty.type === "FIXED_PRICE"}
+        />
+        {bounty.type === "FIXED_PRICE" && (
+          <FcfsApprovalPanel bounty={bounty} />
+        )}

components/bounty-detail/bounty-detail-submissions-card.tsx then needs to branch on hideLegacyFcfsActions so FCFS bounties only allow submission from the current claimant in the claimed state, and no longer expose the legacy review/payment path.

📝 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
<BountyDetailSubmissionsCard bounty={bounty} />
<FcfsApprovalPanel bounty={bounty} />
<BountyDetailSubmissionsCard
bounty={bounty}
hideLegacyFcfsActions={bounty.type === "FIXED_PRICE"}
/>
{bounty.type === "FIXED_PRICE" && (
<FcfsApprovalPanel bounty={bounty} />
)}
🤖 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 74 - 75,
BountyDetailSubmissionsCard is exposing the legacy “Submit PR” and
review/payment controls which breaks FCFS flow when FcfsApprovalPanel is used;
update components/bounty-detail/bounty-detail-submissions-card.tsx to branch on
the hideLegacyFcfsActions flag and bounty fields: if hideLegacyFcfsActions is
true and bounty.type is FCFS (or the FCFS-identifying field), only render the
submission UI when bounty.status === IN_PROGRESS and the current user is the
current claimant, and do NOT render the legacy review/mark-paid controls for
creators; otherwise keep existing behavior for non-FCFS or when
hideLegacyFcfsActions is false so the legacy paths remain unchanged.

</div>

{/* Sidebar */}
Expand Down
2 changes: 1 addition & 1 deletion components/bounty-detail/bounty-detail-header-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function HeaderCard({ bounty }: { bounty: BountyFieldsFragment }) {
<div className="p-6 rounded-xl border border-gray-800 bg-background-card backdrop-blur-xl shadow-sm">
{/* Badges */}
<div className="flex items-center gap-2 flex-wrap mb-4">
<StatusBadge status={bounty.status} />
<StatusBadge status={bounty.status} type={bounty.type} />
<TypeBadge type={bounty.type} />
</div>

Expand Down
61 changes: 38 additions & 23 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { Separator } from "@/components/ui/separator";

import { BountyFieldsFragment } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";

export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) {
const [copied, setCopied] = useState(false);
const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";

const handleCopy = async () => {
try {
Expand Down Expand Up @@ -64,7 +66,7 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) {
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between text-gray-400">
<span>Status</span>
<StatusBadge status={bounty.status} />
<StatusBadge status={bounty.status} type={bounty.type} />
</div>
<div className="flex items-center justify-between text-gray-400">
<span>Type</span>
Expand All @@ -75,17 +77,25 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) {
<Separator className="bg-gray-800/60" />

{/* CTA */}
<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>
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : (
<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">
Expand Down Expand Up @@ -129,6 +139,7 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) {

export function MobileCTA({ bounty }: { bounty: BountyFieldsFragment }) {
const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";

const label = () => {
if (!canAct) {
Expand All @@ -146,17 +157,21 @@ export function MobileCTA({ bounty }: { bounty: BountyFieldsFragment }) {

return (
<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">
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
}
>
{label()}
</Button>
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : (
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
}
>
{label()}
</Button>
)}
Comment on lines +298 to +328
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

FCFS bounties on mobile are missing the cancel button for creators.

When isFcfs is true, only <FcfsClaimButton> is rendered, but the cancel button (for canCancel cases) is placed inside the else branch. This means bounty creators cannot cancel FCFS bounties from mobile, unlike in SidebarCTA where the cancel button (lines 147-161) is rendered outside the FCFS conditional.

Proposed fix to include cancel button for FCFS bounties
     {isFcfs ? (
-      <FcfsClaimButton bounty={bounty} />
+      <div className="flex gap-2">
+        <div className="flex-1">
+          <FcfsClaimButton bounty={bounty} />
+        </div>
+        {canCancel && (
+          <Button
+            variant="outline"
+            size="lg"
+            className="h-11 border-red-500/30 text-red-400 hover:bg-red-500/10 shrink-0"
+            onClick={() => setCancelDialogOpen(true)}
+            aria-label="Cancel bounty"
+          >
+            <XCircle className="size-4" />
+          </Button>
+        )}
+      </div>
     ) : (
       <div className="flex gap-2">
📝 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
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : (
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
className="h-11 border-red-500/30 text-red-400 hover:bg-red-500/10 shrink-0"
onClick={() => setCancelDialogOpen(true)}
onClick={() =>
canAct &&
window.open(
bounty.githubIssueUrl,
"_blank",
"noopener,noreferrer",
)
}
>
<XCircle className="size-4" />
{label()}
</Button>
)}
</div>
{canCancel && (
<Button
variant="outline"
size="lg"
className="h-11 border-red-500/30 text-red-400 hover:bg-red-500/10 shrink-0"
onClick={() => setCancelDialogOpen(true)}
>
<XCircle className="size-4" />
</Button>
)}
</div>
)}
{isFcfs ? (
<div className="flex gap-2">
<div className="flex-1">
<FcfsClaimButton bounty={bounty} />
</div>
{canCancel && (
<Button
variant="outline"
size="lg"
className="h-11 border-red-500/30 text-red-400 hover:bg-red-500/10 shrink-0"
onClick={() => setCancelDialogOpen(true)}
aria-label="Cancel bounty"
>
<XCircle className="size-4" />
</Button>
)}
</div>
) : (
<div className="flex gap-2">
<Button
className="flex-1 h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(
bounty.githubIssueUrl,
"_blank",
"noopener,noreferrer",
)
}
>
{label()}
</Button>
{canCancel && (
<Button
variant="outline"
size="lg"
className="h-11 border-red-500/30 text-red-400 hover:bg-red-500/10 shrink-0"
onClick={() => setCancelDialogOpen(true)}
>
<XCircle className="size-4" />
</Button>
)}
</div>
)}
🤖 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 298 -
328, The FCFS branch currently only renders <FcfsClaimButton> so creators don't
see the cancel button; update the conditional rendering around isFcfs in
bounty-detail-sidebar-cta to always render the cancel-control alongside
FcfsClaimButton when canCancel is true — reuse the same cancel Button logic that
calls setCancelDialogOpen(true) and uses the XCircle icon (same appearance/props
as in the non-FCFS branch) so FCFS bounties show the cancel button for creators
just like SidebarCTA does.

</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { Loader2, DollarSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Expand Down
4 changes: 3 additions & 1 deletion components/bounty/bounty-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function BountyCard({
variant = "grid",
}: BountyCardProps) {
const status = statusConfig[bounty.status];
const isFcfsClaimed =
bounty.type === "FIXED_PRICE" && bounty.status === "IN_PROGRESS";
const timeLeft = bounty.updatedAt
? formatDistanceToNow(new Date(bounty.updatedAt), { addSuffix: true })
: "N/A";
Expand Down Expand Up @@ -107,7 +109,7 @@ export function BountyCard({
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", status.dotColor)} />
<Badge variant={status.variant} className="text-xs font-medium">
{status.label}
{isFcfsClaimed ? "Claimed" : status.label}
</Badge>
</div>

Expand Down
124 changes: 124 additions & 0 deletions components/bounty/fcfs-approval-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { useState } from "react";
import { Loader2 } 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 { useApproveFcfs } from "@/hooks/use-claim-bounty";

type FcfsApprovalBounty = {
id: string;
type: string;
status: string;
createdBy: string;
submissions?: Array<{
id: string;
githubPullRequestUrl?: string | null;
submittedBy?: string;
submittedByUser?: { name?: string | null } | null;
}> | null;
};

export function FcfsApprovalPanel({ bounty }: { bounty: FcfsApprovalBounty }) {
const { data: session } = authClient.useSession();
const approveMutation = useApproveFcfs();
const [points, setPoints] = useState(10);

const currentUserId = (session?.user as { id?: string } | undefined)?.id;
const walletAddress =
(session?.user as { walletAddress?: string; address?: string } | undefined)
?.walletAddress ||
(session?.user as { walletAddress?: string; address?: string } | undefined)
?.address ||
currentUserId;

const isCreator = Boolean(
currentUserId && currentUserId === bounty.createdBy,
);
const isFcfs = bounty.type === "FIXED_PRICE";
const isReviewState =
bounty.status === "IN_REVIEW" || bounty.status === "UNDER_REVIEW";

if (!isFcfs || !isCreator || !isReviewState) return null;

const targetSubmission = bounty.submissions?.[0];

const handleApprove = async () => {
if (!walletAddress) {
toast.error("Connect your wallet to approve this FCFS bounty.");
return;
}
if (!Number.isFinite(points) || points < 0) {
toast.error("Points must be a valid non-negative number.");
Comment on lines +55 to +56
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

Enforce integer points to avoid unintended approvals.

Line 55-Line 56 allows decimal values because only finite/non-negative is checked, and Line 109-Line 115 parses raw numeric input directly. If points are discrete reputation units, fractional values should be blocked at input + validation layers.

Suggested fix
-    if (!Number.isFinite(points) || points < 0) {
+    if (!Number.isFinite(points) || points < 0 || !Number.isInteger(points)) {
       toast.error("Points must be a valid non-negative number.");
       return;
     }
@@
         <Input
           id="fcfs-approval-points"
           type="number"
           min={0}
+          step={1}
           value={points}
           onChange={(e) => setPoints(Number(e.target.value))}
         />

Also applies to: 109-115

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

In `@components/bounty/fcfs-approval-panel.tsx` around lines 55 - 56, The current
validation only checks Number.isFinite(points) and points < 0, allowing
fractional values; update validation to require integer points by using
Number.isInteger(points) && points >= 0 and change the input parsing logic
(where the raw numeric input is parsed into points—e.g., the onChange/parse
handler that currently uses Number(...) or parseFloat) to parse/sanitize as an
integer (use parseInt or Math.round/Math.floor as appropriate for your UX) and
ensure the toast.error message is shown when !Number.isInteger(points) or points
< 0; also enforce integer-only input at the form control (e.g., number input
with step=1 or reject values containing a decimal) so both the UI and the
validation in the FCFSApprovalPanel (the points variable and its setter/parse
handler) consistently block fractional reputations.

return;
}
try {
await approveMutation.mutateAsync({
bountyId: bounty.id,
creatorAddress: walletAddress,
points,
});
toast.success("Approved and released payment.");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Approval failed.");
}
};

return (
<div className="p-5 rounded-xl border border-gray-800 bg-background-card space-y-4">
<h3 className="text-sm font-semibold text-gray-200">
FCFS Approval & Release
</h3>

{targetSubmission ? (
<div className="rounded-lg border border-gray-700 bg-gray-900/40 p-3 text-xs space-y-1">
<p className="text-gray-300">
Contributor:{" "}
{targetSubmission.submittedByUser?.name ||
targetSubmission.submittedBy}
</p>
{targetSubmission.githubPullRequestUrl && (
<a
href={targetSubmission.githubPullRequestUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
>
{targetSubmission.githubPullRequestUrl}
</a>
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

Validate the PR URL before rendering it as a link.

githubPullRequestUrl is submission metadata, so it is user-controlled. Rendering it directly into href lets javascript:/data: URLs execute when the creator clicks the link. Parse it first and allow only expected https GitHub URLs.

Suggested fix
   const targetSubmission = bounty.submissions?.[0];
+  const safePullRequestUrl = (() => {
+    const raw = targetSubmission?.githubPullRequestUrl;
+    if (!raw) return null;
+    try {
+      const url = new URL(raw);
+      if (
+        url.protocol !== "https:" ||
+        !/^(www\.)?github\.com$/i.test(url.hostname)
+      ) {
+        return null;
+      }
+      return url.toString();
+    } catch {
+      return null;
+    }
+  })();

   return (
@@
-          {targetSubmission.githubPullRequestUrl && (
+          {safePullRequestUrl && (
             <a
-              href={targetSubmission.githubPullRequestUrl}
+              href={safePullRequestUrl}
               target="_blank"
               rel="noopener noreferrer"
               className="text-primary hover:underline break-all"
             >
-              {targetSubmission.githubPullRequestUrl}
+              {safePullRequestUrl}
             </a>
           )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/fcfs-approval-panel.tsx` around lines 83 - 91, Validate
targetSubmission.githubPullRequestUrl before rendering: parse it with the URL
constructor and only render the anchor when protocol === "https:" and hostname
equals "github.com" (and optionally "www.github.com"); additionally verify the
pathname matches the GitHub PR pattern (like /:owner/:repo/pull/:number). Update
the FCFS approval panel code that renders targetSubmission.githubPullRequestUrl
to perform this check and render the <a> only on success (otherwise omit the
link or render a non-clickable, escaped plain string). Ensure you use the
validated URL variable for both href and visible text so untrusted input is
never used directly in href.

)}
</div>
) : (
<p className="text-xs text-gray-400">
No submission metadata is available yet. You can still approve using
on-chain state.
</p>
)}

<div className="space-y-2">
<Label htmlFor="fcfs-approval-points">Reputation points</Label>
<Input
id="fcfs-approval-points"
type="number"
min={0}
value={points}
onChange={(e) => setPoints(Number(e.target.value))}
/>
</div>

<Button
className="w-full"
onClick={() => void handleApprove()}
disabled={approveMutation.isPending}
>
{approveMutation.isPending && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Approve & Release Payment
</Button>
</div>
);
}
Loading
Loading