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
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
6 changes: 5 additions & 1 deletion components/bounty-detail/bounty-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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";
import { EscrowDetailPanel } from "../bounty/escrow-detail-panel";
import { RefundStatusTracker } from "../bounty/refund-status";
import { FeeCalculator } from "../bounty/fee-calculator";
Expand Down Expand Up @@ -88,7 +89,10 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
<DescriptionCard description={bounty.description} />
{!isCancelled && pool && <EscrowDetailPanel poolId={bountyId} />}
<RefundStatusTracker bountyId={bountyId} isCancelled={isCancelled} />
<BountyDetailSubmissionsCard bounty={bounty} />
{bounty.type !== "FIXED_PRICE" && (
<BountyDetailSubmissionsCard bounty={bounty} />
)}
{bounty.type === "FIXED_PRICE" && <FcfsApprovalPanel bounty={bounty} />}
</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
85 changes: 52 additions & 33 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {

import { BountyFieldsFragment } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";
import { authClient } from "@/lib/auth-client";
import type { CancellationRecord } from "@/types/escrow";
import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog";
Expand All @@ -48,6 +49,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
} = useCancelBountyDialog(bounty.id, onCancelled);

const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";
const isCreator = session?.user?.id === bounty.createdBy;
const canCancel =
isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS");
Expand Down Expand Up @@ -104,7 +106,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
<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 @@ -115,17 +117,25 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
<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 @@ -218,7 +228,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
placeholder="e.g., Requirements changed, budget reallocation, issue resolved externally..."
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
className="min-h-[80px] resize-none"
className="min-h-20 resize-none"
disabled={isCancelling}
/>
</div>
Expand Down Expand Up @@ -264,6 +274,7 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
} = useCancelBountyDialog(bounty.id, onCancelled);

const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";
const isCreator = session?.user?.id === bounty.createdBy;
const canCancel =
isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS");
Expand All @@ -284,29 +295,37 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {

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">
<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 && (
{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>
)}
Comment on lines +318 to +326
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

Add aria-label to the icon-only cancel button.

The cancel button only contains an icon with no accessible name. Screen reader users cannot determine its purpose.

Proposed fix
           <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>
📝 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
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>
)}
<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>
)}
🤖 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 318 -
326, The icon-only cancel Button in bounty-detail-sidebar-cta.tsx lacks an
accessible name; update the Button (the one with onClick={() =>
setCancelDialogOpen(true)} and containing <XCircle>) to include an aria-label
(e.g., aria-label="Cancel" or a localized string) so screen readers can announce
its purpose, and ensure the label is meaningful and matches any existing
localization patterns used elsewhere in the component.

</div>
)}
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.


{/* Mobile Cancel Dialog */}
<AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
Expand All @@ -330,7 +349,7 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
placeholder="Reason for cancellation..."
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
className="min-h-[80px]"
className="min-h-20"
disabled={isCancelling}
/>
</div>
Expand Down
15 changes: 13 additions & 2 deletions components/bounty/bounty-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ const statusConfig: Record<
label: "Under Review",
dotColor: "bg-amber-500",
},
in_review: {
variant: "secondary",
label: "In Review",
dotColor: "bg-amber-500",
},
disputed: {
variant: "destructive",
label: "Disputed",
Expand All @@ -77,7 +82,13 @@ export function BountyCard({
onClick,
variant = "grid",
}: BountyCardProps) {
const status = statusConfig[bounty.status];
const normalizedStatus = bounty.status
.toUpperCase()
.replace(/-/g, "_") as string;
const status =
statusConfig[normalizedStatus.toLowerCase()] ?? statusConfig.open;
Comment on lines +88 to +89
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

Map IN_REVIEW before defaulting to Open.

FcfsApprovalPanel in this PR treats "IN_REVIEW" as a valid review state, but statusConfig only defines under_review. When Line 82 resolves an IN_REVIEW bounty, it falls through to statusConfig.open, so review bounties render as Open on cards.

Suggested fix
 const statusConfig: Record<
   string,
   {
     variant: "default" | "secondary" | "outline" | "destructive";
     label: string;
     dotColor: string;
   }
 > = {
+  in_review: {
+    variant: "secondary",
+    label: "In Review",
+    dotColor: "bg-amber-500",
+  },
   open: {
     variant: "default",
     label: "Open",
     dotColor: "bg-emerald-500",
   },
📝 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
const status =
statusConfig[normalizedStatus.toLowerCase()] ?? statusConfig.open;
const statusConfig: Record<
string,
{
variant: "default" | "secondary" | "outline" | "destructive";
label: string;
dotColor: string;
}
> = {
in_review: {
variant: "secondary",
label: "In Review",
dotColor: "bg-amber-500",
},
open: {
variant: "default",
label: "Open",
dotColor: "bg-emerald-500",
},
🤖 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 81 - 82, The status lookup
`const status = statusConfig[normalizedStatus.toLowerCase()] ??
statusConfig.open;` incorrectly falls back to Open for "IN_REVIEW"; ensure
"IN_REVIEW" maps to the existing `under_review` key by normalizing or remapping
`normalizedStatus` (e.g., convert "IN_REVIEW" to "under_review" or check for
"in_review"/"inreview"/"in-review") before indexing `statusConfig`, so
`statusConfig[normalizedStatus.toLowerCase()]` returns
`statusConfig.under_review` for review bounties instead of defaulting to
`statusConfig.open`.

const isFcfsClaimed =
bounty.type === "FIXED_PRICE" && normalizedStatus === "IN_PROGRESS";
const timeLeft = bounty.updatedAt
? formatDistanceToNow(new Date(bounty.updatedAt), { addSuffix: true })
: "N/A";
Expand Down Expand Up @@ -112,7 +123,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
136 changes: 136 additions & 0 deletions components/bounty/fcfs-approval-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import { useState } from "react";
import Link from "next/link";
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 ||
null;

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 &&
targetSubmission.githubPullRequestUrl.startsWith("https://") ? (
<Link
href={targetSubmission.githubPullRequestUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
>
{targetSubmission.githubPullRequestUrl}
</Link>
) : targetSubmission.githubPullRequestUrl ? (
<span className="text-xs text-gray-400 break-all">
{targetSubmission.githubPullRequestUrl}
</span>
) : null}
</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>

{!walletAddress && (
<p className="text-sm text-amber-400 text-center py-2">
Connect your wallet to approve this bounty.
</p>
)}

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