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 964c475..c032cb1 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -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"; @@ -88,7 +89,10 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { {!isCancelled && pool && } - + {bounty.type !== "FIXED_PRICE" && ( + + )} + {bounty.type === "FIXED_PRICE" && } {/* 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 90d7de3..b10ccc2 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -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"; @@ -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"); @@ -104,7 +106,7 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
Status - +
Type @@ -115,17 +117,25 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { {/* CTA */} - + {isFcfs ? ( + + ) : ( + + )} {!canAct && (

@@ -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} />

@@ -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"); @@ -284,29 +295,37 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { return (
-
- - {canCancel && ( + {isFcfs ? ( + + ) : ( +
- )} -
+ {canCancel && ( + + )} +
+ )} {/* Mobile Cancel Dialog */} @@ -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} />
diff --git a/components/bounty/bounty-card.tsx b/components/bounty/bounty-card.tsx index bf0b590..c00230c 100644 --- a/components/bounty/bounty-card.tsx +++ b/components/bounty/bounty-card.tsx @@ -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", @@ -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; + const isFcfsClaimed = + bounty.type === "FIXED_PRICE" && normalizedStatus === "IN_PROGRESS"; const timeLeft = bounty.updatedAt ? formatDistanceToNow(new Date(bounty.updatedAt), { addSuffix: true }) : "N/A"; @@ -112,7 +123,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..df066bc --- /dev/null +++ b/components/bounty/fcfs-approval-panel.tsx @@ -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."); + 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.startsWith("https://") ? ( + + {targetSubmission.githubPullRequestUrl} + + ) : targetSubmission.githubPullRequestUrl ? ( + + {targetSubmission.githubPullRequestUrl} + + ) : null} +
+ ) : ( +

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

+ )} + +
+ + setPoints(Number(e.target.value))} + /> +
+ + {!walletAddress && ( +

+ Connect your wallet to approve this bounty. +

+ )} + + +
+ ); +} diff --git a/components/bounty/fcfs-claim-button.tsx b/components/bounty/fcfs-claim-button.tsx new file mode 100644 index 0000000..ba7e223 --- /dev/null +++ b/components/bounty/fcfs-claim-button.tsx @@ -0,0 +1,255 @@ +"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 || + null; + + 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 && !walletAddress && ( +

+ Connect your wallet to claim this bounty. +

+ )} + + {isOpen && !isCreator && walletAddress && ( + + )} + + {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 > 0 && + milestoneMsLeft < 24 * 60 * 60 * 1000) || + (responseMsLeft != null && + responseMsLeft > 0 && + 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. + + +
+ +