From aa11a5b6d8e94b9ac43ca8592f14e7f6a7ef9d87 Mon Sep 17 00:00:00 2001 From: codebestia Date: Mon, 30 Mar 2026 11:49:55 +0100 Subject: [PATCH 1/3] feat: implement full bounty cancellation and refund flow --- .../bounty-detail/bounty-detail-client.tsx | 21 +- .../bounty-detail-sidebar-cta.tsx | 284 +++++++++++++++++- components/bounty/refund-status.tsx | 223 ++++++++++++++ hooks/__tests__/use-cancel-bounty.test.ts | 98 ++++++ hooks/use-bounty-mutations.ts | 72 +++++ hooks/use-escrow.ts | 15 + lib/services/escrow.ts | 168 ++++++++++- types/escrow.ts | 20 ++ 8 files changed, 882 insertions(+), 19 deletions(-) create mode 100644 components/bounty/refund-status.tsx create mode 100644 hooks/__tests__/use-cancel-bounty.test.ts diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index cddbaabd..eb4cfe46 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { AlertCircle, ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -10,13 +11,21 @@ import { BountyDetailSubmissionsCard } from "./bounty-detail-submissions-card"; import { BountyDetailSkeleton } from "./bounty-detail-bounty-detail-skeleton"; import { useBountyDetail } from "@/hooks/use-bounty-detail"; import { EscrowDetailPanel } from "../bounty/escrow-detail-panel"; +import { RefundStatusTracker } from "../bounty/refund-status"; import { FeeCalculator } from "../bounty/fee-calculator"; import { useEscrowPool } from "@/hooks/use-escrow"; +import type { CancellationRecord } from "@/types/escrow"; export function BountyDetailClient({ bountyId }: { bountyId: string }) { const router = useRouter(); const { data: bounty, isPending, isError, error } = useBountyDetail(bountyId); const { data: pool } = useEscrowPool(bountyId); + const [cancellationRecord, setCancellationRecord] = + useState(null); + + const handleCancelled = useCallback((record: CancellationRecord) => { + setCancellationRecord(record); + }, []); if (isPending) return ; @@ -68,6 +77,9 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { ); } + const isCancelled = + bounty.status === "CANCELLED" || cancellationRecord !== null; + return (
{/* Main content */} @@ -75,19 +87,24 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { {pool && } +
{/* Sidebar */} {/* Mobile sticky CTA */} - + ); } + diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index 7fe5fe20..2f8c1243 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -1,16 +1,54 @@ "use client"; import { useState } from "react"; -import { Github, Copy, Check, AlertCircle } from "lucide-react"; +import { + Github, + Copy, + Check, + AlertCircle, + XCircle, + Loader2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; import { BountyFieldsFragment } from "@/lib/graphql/generated"; import { StatusBadge, TypeBadge } from "./bounty-badges"; +import { useCancelBounty } from "@/hooks/use-bounty-mutations"; +import { EscrowService } from "@/lib/services/escrow"; +import { authClient } from "@/lib/auth-client"; +import type { CancellationRecord } from "@/types/escrow"; -export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { +interface SidebarCTAProps { + bounty: BountyFieldsFragment; + onCancelled?: (record: CancellationRecord) => void; +} + +export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { const [copied, setCopied] = useState(false); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + const [isCancelling, setIsCancelling] = useState(false); + + const { data: session } = authClient.useSession(); + const cancelBounty = useCancelBounty(); + const canAct = bounty.status === "OPEN"; + const isCreator = session?.user?.id === bounty.createdBy; + const canCancel = + isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS"); const handleCopy = async () => { try { @@ -22,6 +60,40 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) { } }; + const handleCancel = async () => { + if (!cancelReason.trim()) { + toast.error("Please provide a reason for cancellation"); + return; + } + + setIsCancelling(true); + try { + // 1. Trigger escrow refund (simulates on-chain call) + const record = await EscrowService.cancelBounty( + bounty.id, + session?.user?.id ?? "", + cancelReason.trim(), + ); + + // 2. Update bounty status via GraphQL + cancelBounty.cancel({ id: bounty.id, reason: cancelReason.trim() }); + + toast.success("Bounty cancelled and refund initiated", { + description: `${record.refund?.refundedAmount ?? 0} ${record.refund?.asset ?? ""} refunded`, + }); + + onCancelled?.(record); + setCancelDialogOpen(false); + setCancelReason(""); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to cancel bounty", + ); + } finally { + setIsCancelling(false); + } + }; + const ctaLabel = () => { if (!canAct) { switch (bounty.status) { @@ -94,6 +166,22 @@ export function SidebarCTA({ bounty }: { bounty: BountyFieldsFragment }) {

)} + {/* Cancel Bounty - only for creator on open/in-progress */} + {canCancel && ( + <> + + + + )} + {/* GitHub */} + + {/* Cancel Confirmation Dialog */} + + + + + + Cancel Bounty + + + + Are you sure you want to cancel this bounty? This action will: + +
    +
  • + Mark the bounty as Cancelled +
  • +
  • Initiate a refund of escrowed funds to your wallet
  • +
  • + Notify any contributors who have started or submitted work +
  • +
+ + ⚠️ This action cannot be undone. Any in-progress submissions + will be invalidated. + +
+
+ +
+
+ +