-
Notifications
You must be signed in to change notification settings - Fork 96
Feature: Bounty Cancellation and Refund Flow #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,57 @@ | ||
| "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 { authClient } from "@/lib/auth-client"; | ||
| import type { CancellationRecord } from "@/types/escrow"; | ||
| import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog"; | ||
|
|
||
| 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 { data: session } = authClient.useSession(); | ||
|
|
||
| const { | ||
| cancelDialogOpen, | ||
| setCancelDialogOpen, | ||
| cancelReason, | ||
| setCancelReason, | ||
| isCancelling, | ||
| handleCancel, | ||
| } = useCancelBountyDialog(bounty.id, onCancelled); | ||
|
|
||
| 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 { | ||
|
|
@@ -94,6 +135,22 @@ | |
| </p> | ||
| )} | ||
|
|
||
| {/* Cancel Bounty - only for creator on open/in-progress */} | ||
| {canCancel && ( | ||
| <> | ||
| <Separator className="bg-gray-800/60" /> | ||
| <Button | ||
| variant="outline" | ||
| className="w-full border-red-500/30 text-red-400 hover:bg-red-500/10 hover:text-red-300 hover:border-red-500/50 transition-all" | ||
| onClick={() => setCancelDialogOpen(true)} | ||
| disabled={isCancelling} | ||
| > | ||
| <XCircle className="size-4 mr-2" /> | ||
| Cancel Bounty | ||
| </Button> | ||
| </> | ||
| )} | ||
|
|
||
| {/* GitHub */} | ||
| <a | ||
| href={bounty.githubIssueUrl} | ||
|
|
@@ -123,12 +180,96 @@ | |
| )} | ||
| </button> | ||
| </div> | ||
|
|
||
| {/* Cancel Confirmation Dialog */} | ||
| <AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> | ||
| <AlertDialogContent> | ||
| <AlertDialogHeader> | ||
| <AlertDialogTitle className="flex items-center gap-2 text-red-400"> | ||
| <XCircle className="size-5" /> | ||
| Cancel Bounty | ||
| </AlertDialogTitle> | ||
| <AlertDialogDescription className="text-muted-foreground space-y-2"> | ||
| <span className="block"> | ||
| Are you sure you want to cancel this bounty? This action will: | ||
| </span> | ||
| <ul className="list-disc list-inside space-y-1 text-sm"> | ||
| <li> | ||
| Mark the bounty as <strong>Cancelled</strong> | ||
| </li> | ||
| <li>Initiate a refund of escrowed funds to your wallet</li> | ||
| <li> | ||
| Notify any contributors who have started or submitted work | ||
| </li> | ||
| </ul> | ||
| <span className="block text-xs text-yellow-500/80 mt-2"> | ||
| ⚠️ This action cannot be undone. Any in-progress submissions | ||
| will be invalidated. | ||
| </span> | ||
| </AlertDialogDescription> | ||
| </AlertDialogHeader> | ||
|
|
||
| <div className="space-y-3 mt-2"> | ||
| <div className="space-y-2"> | ||
| <Label htmlFor="cancel-reason" className="text-sm font-medium"> | ||
| Reason for cancellation <span className="text-red-400">*</span> | ||
| </Label> | ||
| <Textarea | ||
| id="cancel-reason" | ||
| placeholder="e.g., Requirements changed, budget reallocation, issue resolved externally..." | ||
| value={cancelReason} | ||
| onChange={(e) => setCancelReason(e.target.value)} | ||
| className="min-h-[80px] resize-none" | ||
| disabled={isCancelling} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <AlertDialogFooter className="mt-4"> | ||
| <AlertDialogCancel | ||
| disabled={isCancelling} | ||
| onClick={() => setCancelReason("")} | ||
| > | ||
| Keep Bounty | ||
| </AlertDialogCancel> | ||
|
Comment on lines
+229
to
+234
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent dialog cancel behavior between desktop and mobile. The desktop 🔧 Proposed fix for MobileCTA- <AlertDialogCancel disabled={isCancelling}>
+ <AlertDialogCancel
+ disabled={isCancelling}
+ onClick={() => setCancelReason("")}
+ >
Keep Bounty
</AlertDialogCancel>Also applies to: 340-343 🤖 Prompt for AI Agents |
||
| <Button | ||
| variant="destructive" | ||
| onClick={handleCancel} | ||
| disabled={!cancelReason.trim() || isCancelling} | ||
| > | ||
| {isCancelling && ( | ||
| <Loader2 className="mr-2 size-4 animate-spin" /> | ||
| )} | ||
| Cancel Bounty & Refund | ||
| </Button> | ||
| </AlertDialogFooter> | ||
| </AlertDialogContent> | ||
| </AlertDialog> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function MobileCTA({ bounty }: { bounty: BountyFieldsFragment }) { | ||
| interface MobileCTAProps { | ||
| bounty: BountyFieldsFragment; | ||
| onCancelled?: (record: CancellationRecord) => void; | ||
| } | ||
|
|
||
| export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) { | ||
| const { data: session } = authClient.useSession(); | ||
|
|
||
| const { | ||
| cancelDialogOpen, | ||
| setCancelDialogOpen, | ||
| cancelReason, | ||
| setCancelReason, | ||
| isCancelling, | ||
| handleCancel, | ||
| } = useCancelBountyDialog(bounty.id, onCancelled); | ||
|
|
||
| const canAct = bounty.status === "OPEN"; | ||
| const isCreator = session?.user?.id === bounty.createdBy; | ||
| const canCancel = | ||
| isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS"); | ||
|
|
||
| const label = () => { | ||
| if (!canAct) { | ||
|
|
@@ -146,17 +287,74 @@ | |
|
|
||
| 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> | ||
| <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> | ||
|
|
||
| {/* Mobile Cancel Dialog */} | ||
| <AlertDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> | ||
| <AlertDialogContent> | ||
| <AlertDialogHeader> | ||
| <AlertDialogTitle className="flex items-center gap-2 text-red-400"> | ||
| <XCircle className="size-5" /> | ||
| Cancel Bounty | ||
| </AlertDialogTitle> | ||
| <AlertDialogDescription> | ||
| This will cancel the bounty and refund escrowed funds. This action | ||
| cannot be undone. | ||
| </AlertDialogDescription> | ||
| </AlertDialogHeader> | ||
| <div className="space-y-2"> | ||
| <Label htmlFor="mobile-cancel-reason"> | ||
| Reason <span className="text-red-400">*</span> | ||
| </Label> | ||
| <Textarea | ||
| id="mobile-cancel-reason" | ||
| placeholder="Reason for cancellation..." | ||
| value={cancelReason} | ||
| onChange={(e) => setCancelReason(e.target.value)} | ||
| className="min-h-[80px]" | ||
| disabled={isCancelling} | ||
| /> | ||
| </div> | ||
| <AlertDialogFooter> | ||
| <AlertDialogCancel disabled={isCancelling}> | ||
| Keep Bounty | ||
| </AlertDialogCancel> | ||
| <Button | ||
| variant="destructive" | ||
| onClick={handleCancel} | ||
| disabled={!cancelReason.trim() || isCancelling} | ||
| > | ||
| {isCancelling && ( | ||
| <Loader2 className="mr-2 size-4 animate-spin" /> | ||
| )} | ||
| Cancel & Refund | ||
| </Button> | ||
| </AlertDialogFooter> | ||
| </AlertDialogContent> | ||
| </AlertDialog> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.