Skip to content
Merged
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
23 changes: 20 additions & 3 deletions components/bounty-detail/bounty-detail-client.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<CancellationRecord | null>(null);

const handleCancelled = useCallback((record: CancellationRecord) => {
setCancellationRecord(record);
}, []);

if (isPending) return <BountyDetailSkeleton />;

Expand Down Expand Up @@ -68,26 +77,34 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
);
}

const isCancelled =
bounty.status === "CANCELLED" || cancellationRecord !== null;

return (
<div className="flex flex-col lg:flex-row gap-10">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
<HeaderCard bounty={bounty} />
<DescriptionCard description={bounty.description} />
{pool && <EscrowDetailPanel poolId={bountyId} />}
{!isCancelled && pool && <EscrowDetailPanel poolId={bountyId} />}
<RefundStatusTracker
bountyId={bountyId}
isCancelled={isCancelled}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<BountyDetailSubmissionsCard bounty={bounty} />
</div>

{/* Sidebar */}
<aside className="w-full lg:w-72 shrink-0">
<div className="lg:sticky lg:top-24 space-y-4">
<SidebarCTA bounty={bounty} />
<SidebarCTA bounty={bounty} onCancelled={handleCancelled} />
<FeeCalculator />
</div>
</aside>

{/* Mobile sticky CTA */}
<MobileCTA bounty={bounty} />
<MobileCTA bounty={bounty} onCancelled={handleCancelled} />
</div>
);
}

226 changes: 212 additions & 14 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
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";

Check warning on line 25 in components/bounty-detail/bounty-detail-sidebar-cta.tsx

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

'toast' is defined but never used

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 {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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

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

Inconsistent dialog cancel behavior between desktop and mobile.

The desktop AlertDialogCancel (line 231) clears the reason on click, but the mobile version (line 341) does not. This creates inconsistent UX where reopening the mobile dialog may show stale text.

🔧 Proposed fix for MobileCTA
-            <AlertDialogCancel disabled={isCancelling}>
+            <AlertDialogCancel
+              disabled={isCancelling}
+              onClick={() => setCancelReason("")}
+            >
               Keep Bounty
             </AlertDialogCancel>

Also applies to: 340-343

🤖 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 229 -
234, The desktop AlertDialogCancel currently calls setCancelReason("") on click
(clearing the input) but the mobile dialog's AlertDialogCancel in MobileCTA does
not, causing stale text when reopened; update the mobile AlertDialogCancel (the
one rendered in MobileCTA) to call setCancelReason("") in its onClick handler
(matching the desktop behavior) so both dialogs clear the cancel reason
consistently when dismissed.

<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) {
Expand All @@ -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>
);
}

Loading
Loading