From a04c0df670afd268a4e2800dc3c74e8b5e72b8c1 Mon Sep 17 00:00:00 2001 From: wheval Date: Fri, 27 Mar 2026 17:10:09 +0100 Subject: [PATCH 1/3] feat(bounty): add FCFS single-claim flow with claim/approve UI and claimed-state handling --- components/bounty-detail/bounty-badges.tsx | 11 +- .../bounty-detail/bounty-detail-client.tsx | 2 + .../bounty-detail-header-card.tsx | 2 +- .../bounty-detail-sidebar-cta.tsx | 61 +++-- .../bounty-detail-submissions-card.tsx | 2 +- components/bounty/bounty-card.tsx | 4 +- components/bounty/fcfs-approval-panel.tsx | 124 +++++++++ components/bounty/fcfs-claim-button.tsx | 247 ++++++++++++++++++ hooks/__tests__/use-submission-draft.test.ts | 2 +- hooks/use-claim-bounty.ts | 236 +++++++++++++++++ 10 files changed, 662 insertions(+), 29 deletions(-) create mode 100644 components/bounty/fcfs-approval-panel.tsx create mode 100644 components/bounty/fcfs-claim-button.tsx create mode 100644 hooks/use-claim-bounty.ts diff --git a/components/bounty-detail/bounty-badges.tsx b/components/bounty-detail/bounty-badges.tsx index 67abcfb..66b321c 100644 --- a/components/bounty-detail/bounty-badges.tsx +++ b/components/bounty-detail/bounty-badges.tsx @@ -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 ( - {cfg.label} + {isClaimed ? "Claimed" : cfg.label} ); } diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index 140c10c..c65562d 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -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(); @@ -71,6 +72,7 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { + {/* Sidebar */} diff --git a/components/bounty-detail/bounty-detail-header-card.tsx b/components/bounty-detail/bounty-detail-header-card.tsx index 539ae09..9aaec8d 100644 --- a/components/bounty-detail/bounty-detail-header-card.tsx +++ b/components/bounty-detail/bounty-detail-header-card.tsx @@ -11,7 +11,7 @@ export function HeaderCard({ bounty }: { bounty: BountyFieldsFragment }) {
{/* Badges */}
- +
diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index 7fe5fe2..2cbe0bb 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -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 { @@ -64,7 +66,7 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) {
Status - +
Type @@ -75,17 +77,25 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { {/* CTA */} - + {isFcfs ? ( + + ) : ( + + )} {!canAct && (

@@ -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) { @@ -146,17 +157,21 @@ export function MobileCTA({ bounty }: { bounty: BountyFieldsFragment }) { return (

- + {isFcfs ? ( + + ) : ( + + )}
); } diff --git a/components/bounty-detail/bounty-detail-submissions-card.tsx b/components/bounty-detail/bounty-detail-submissions-card.tsx index 96f9df4..0fe4a20 100644 --- a/components/bounty-detail/bounty-detail-submissions-card.tsx +++ b/components/bounty-detail/bounty-detail-submissions-card.tsx @@ -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 { diff --git a/components/bounty/bounty-card.tsx b/components/bounty/bounty-card.tsx index 199666c..89648d7 100644 --- a/components/bounty/bounty-card.tsx +++ b/components/bounty/bounty-card.tsx @@ -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"; @@ -107,7 +109,7 @@ export function BountyCard({
- {status.label} + {isFcfsClaimed ? "Claimed" : status.label}
diff --git a/components/bounty/fcfs-approval-panel.tsx b/components/bounty/fcfs-approval-panel.tsx new file mode 100644 index 0000000..c85e082 --- /dev/null +++ b/components/bounty/fcfs-approval-panel.tsx @@ -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."); + 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 ( +
+

+ FCFS Approval & Release +

+ + {targetSubmission ? ( +
+

+ Contributor:{" "} + {targetSubmission.submittedByUser?.name || + targetSubmission.submittedBy} +

+ {targetSubmission.githubPullRequestUrl && ( + + {targetSubmission.githubPullRequestUrl} + + )} +
+ ) : ( +

+ No submission metadata is available yet. You can still approve using + on-chain state. +

+ )} + +
+ + setPoints(Number(e.target.value))} + /> +
+ + +
+ ); +} diff --git a/components/bounty/fcfs-claim-button.tsx b/components/bounty/fcfs-claim-button.tsx new file mode 100644 index 0000000..9eb01b7 --- /dev/null +++ b/components/bounty/fcfs-claim-button.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AlertTriangle, Clock3, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + FcfsError, + useClaimBounty, + useUnclaimBounty, +} from "@/hooks/use-claim-bounty"; + +type FcfsBounty = { + id: string; + type: string; + status: string; + createdBy: string; + updatedAt?: string | null; + claimsLastMilestoneAt?: string | null; + claimsLastResponseAt?: string | null; + claimedBy?: string | null; + claimedByUser?: { name?: string | null } | null; + submissions?: Array<{ + submittedBy?: string; + submittedByUser?: { name?: string | null } | null; + }> | null; +}; + +function getClaimOwner(bounty: FcfsBounty): { + address: string | null; + label: string | null; +} { + const address = + bounty.claimedBy || bounty.submissions?.[0]?.submittedBy || null; + const label = + bounty.claimedByUser?.name || + bounty.submissions?.[0]?.submittedByUser?.name || + address; + return { address, label: label ? `@${label}` : null }; +} + +function formatRemaining(ms: number) { + if (ms <= 0) return "Expired"; + const totalHours = Math.floor(ms / (1000 * 60 * 60)); + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return `${days}d ${hours}h`; +} + +export function FcfsClaimButton({ bounty }: { bounty: FcfsBounty }) { + const { data: session } = authClient.useSession(); + const [unclaimOpen, setUnclaimOpen] = useState(false); + const [justification, setJustification] = useState(""); + const [now, setNow] = useState(() => Date.now()); + const claimMutation = useClaimBounty(); + const unclaimMutation = useUnclaimBounty(); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 60_000); + return () => window.clearInterval(id); + }, []); + + 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 isFcfs = bounty.type === "FIXED_PRICE"; + const isOpen = bounty.status === "OPEN"; + const isClaimed = bounty.status === "IN_PROGRESS"; + const isCreator = Boolean( + currentUserId && currentUserId === bounty.createdBy, + ); + const owner = getClaimOwner(bounty); + const isOwner = Boolean(owner.address && owner.address === walletAddress); + + const milestoneBase = bounty.claimsLastMilestoneAt || bounty.updatedAt; + const responseBase = bounty.claimsLastResponseAt || bounty.updatedAt; + + const milestoneMsLeft = useMemo(() => { + if (!milestoneBase) return null; + return new Date(milestoneBase).getTime() + 7 * 24 * 60 * 60 * 1000 - now; + }, [milestoneBase, now]); + const responseMsLeft = useMemo(() => { + if (!responseBase) return null; + return new Date(responseBase).getTime() + 3 * 24 * 60 * 60 * 1000 - now; + }, [responseBase, now]); + + if (!isFcfs) return null; + + const handleClaim = async () => { + if (!walletAddress) { + toast.error("Connect your wallet to claim this bounty."); + return; + } + try { + await claimMutation.mutateAsync({ + bountyId: bounty.id, + contributorAddress: walletAddress, + }); + toast.success("Bounty claimed successfully."); + } catch (error) { + if (error instanceof FcfsError) { + toast.error(error.message); + return; + } + toast.error(error instanceof Error ? error.message : "Claim failed."); + } + }; + + const handleUnclaim = async () => { + if (!walletAddress) return; + try { + await unclaimMutation.mutateAsync({ + bountyId: bounty.id, + creatorAddress: walletAddress, + justification: justification.trim(), + }); + toast.success("Bounty was unclaimed."); + setUnclaimOpen(false); + setJustification(""); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Unclaim failed."); + } + }; + + return ( +
+ {isOpen && !isCreator && ( + + )} + + {isClaimed && ( +
+

+ {isOwner + ? "This FCFS bounty is currently claimed by your wallet." + : `Already Claimed${owner.label ? ` by ${owner.label}` : ""}`} +

+ +
+ {milestoneMsLeft != null && ( +

+ + Auto-release in {formatRemaining(milestoneMsLeft)} (7d no + milestone) +

+ )} + {responseMsLeft != null && ( +

+ + Auto-release in {formatRemaining(responseMsLeft)} (3d no + response) +

+ )} +
+ + {isOwner && + ((milestoneMsLeft != null && + milestoneMsLeft < 24 * 60 * 60 * 1000) || + (responseMsLeft != null && + responseMsLeft < 24 * 60 * 60 * 1000)) && ( +

+ + Your claim is close to auto-release. Post progress to avoid + abandonment. +

+ )} + + {isCreator && ( + + + + + + + Unclaim bounty + + Provide a justification before releasing this claim. + + +
+ +