Skip to content
Closed
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
223 changes: 188 additions & 35 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import {
AlertCircle,
XCircle,
Loader2,
Users,
Clock,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
Expand All @@ -24,7 +23,7 @@ import {
AlertDialogFooter,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";

import { toast } from "sonner";
import { BountyFieldsFragment } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";
Expand All @@ -33,6 +32,7 @@ import { CompetitionStatus } from "@/components/bounty/competition-status";
import { authClient } from "@/lib/auth-client";
import { useCompetitionJoinState } from "@/hooks/use-competition-join-state";
import type { CancellationRecord } from "@/types/escrow";
import { useRaiseDisputeMutation } from "@/hooks/use-dispute-mutations";
import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog";
import type { Bounty } from "@/types/bounty";

Expand All @@ -59,6 +59,11 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
handleCancel,
} = useCancelBountyDialog(bounty.id, onCancelled);

const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
const [disputeReason, setDisputeReason] = useState("");
const [isDisputing, setIsDisputing] = useState(false);
const raiseDisputeMutation = useRaiseDisputeMutation();

const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";
const isCompetition = bounty.type === "COMPETITION";
Expand Down Expand Up @@ -87,6 +92,36 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
}
};

const handleRaiseDispute = async () => {
setIsDisputing(true);
try {
await raiseDisputeMutation.mutateAsync({
bountyId: bounty.id,
reason: disputeReason,
});
toast.success("Dispute raised successfully. A reviewer will mediate shortly.");
setDisputeDialogOpen(false);
setDisputeReason("");
} catch (error) {
console.error("Failed to raise dispute:", error);
toast.error("Failed to raise dispute");
} finally {
setIsDisputing(false);
}
};
Comment thread
Belzabeem marked this conversation as resolved.

// Check if current user is the sponsor or the contributor with a submission
const isContributor = bounty.submissions?.some(
(s) => s.submittedBy === session?.user?.id
);

const canDispute =
((bounty.status === "SUBMITTED" ||
bounty.status === "UNDER_REVIEW" ||
bounty.status === "IN_PROGRESS" ||
bounty.status === "DISPUTED") &&
(isCreator || isContributor)) ?? false;
Comment thread
Belzabeem marked this conversation as resolved.

const ctaLabel = () => {
if (!canAct) {
switch (bounty.status) {
Expand Down Expand Up @@ -221,14 +256,20 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
</Button>
)}

{/* Helper text when locked out */}
{isCompetition && !hasJoined && isPastDeadline && (
<p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center">
<Clock className="size-3 shrink-0" />
Submission deadline has passed.
</p>
{/* Raise Dispute Button */}
{canDispute && bounty.status !== "DISPUTED" && (
<Button
variant="ghost"
className="w-full text-xs text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 flex items-center justify-center gap-1.5"
onClick={() => setDisputeDialogOpen(true)}
disabled={isDisputing}
>
<AlertTriangle className="size-3" />
Raise Dispute
</Button>
)}
{!canAct && !isCompetition && (

{!canAct && (
<p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center">
<AlertCircle className="size-3 shrink-0" />
This bounty is no longer accepting new submissions.
Expand Down Expand Up @@ -361,6 +402,51 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Raise Dispute Confirmation Dialog */}
<AlertDialog open={disputeDialogOpen} onOpenChange={setDisputeDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-yellow-500">
<AlertTriangle className="size-5" />
Raise Dispute
</AlertDialogTitle>
<AlertDialogDescription>
Raising a dispute will lock the bounty and notify our reviewers to mediate. Please provide a clear reason for the dispute.
</AlertDialogDescription>
</AlertDialogHeader>

<div className="space-y-3 mt-2">
<div className="space-y-2">
<Label htmlFor="dispute-reason" className="text-sm font-medium">
Reason for dispute <span className="text-red-400">*</span>
</Label>
<Textarea
id="dispute-reason"
placeholder="e.g., Deliverables don't match requirements, feedback is unfair, technical disagreement..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
className="min-h-[100px] resize-none"
disabled={isDisputing}
/>
</div>
</div>

<AlertDialogFooter className="mt-4">
<AlertDialogCancel disabled={isDisputing} onClick={() => setDisputeReason("")}>
Cancel
</AlertDialogCancel>
<Button
className="bg-yellow-500 text-black hover:bg-yellow-600"
onClick={handleRaiseDispute}
disabled={!disputeReason.trim() || isDisputing || raiseDisputeMutation.isPending}
>
{isDisputing && <Loader2 className="mr-2 size-4 animate-spin" />}
Submit Dispute
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
Expand All @@ -382,6 +468,11 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
handleCancel,
} = useCancelBountyDialog(bounty.id, onCancelled);

const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
const [disputeReason, setDisputeReason] = useState("");
const [isDisputing, setIsDisputing] = useState(false);
const raiseDisputeMutation = useRaiseDisputeMutation();

const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";
Comment thread
Belzabeem marked this conversation as resolved.
const isCompetition = bounty.type === "COMPETITION";
Expand All @@ -407,37 +498,60 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
return "Submit to Bounty";
};

const handleRaiseDispute = async () => {
setIsDisputing(true);
try {
await raiseDisputeMutation.mutateAsync({
bountyId: bounty.id,
reason: disputeReason,
});
toast.success("Dispute raised successfully");
setDisputeDialogOpen(false);
setDisputeReason("");
} catch (error) {
console.error("Failed to raise dispute:", error);
toast.error("Failed to raise dispute");
} finally {
setIsDisputing(false);
}
};

const isContributor = bounty.submissions?.some(
(s) => s.submittedBy === session?.user?.id
);

const canDispute =
(bounty.status === "SUBMITTED" ||
bounty.status === "UNDER_REVIEW" ||
bounty.status === "IN_PROGRESS" ||
bounty.status === "DISPUTED") &&
(isCreator || isContributor);

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">
{isFcfs ? (
<FcfsClaimButton bounty={bounty} />
) : isCompetition ? (
<div className="flex gap-2">
<Button
data-testid="apply-to-bounty-btn"
className="w-full h-11 font-bold tracking-wide"
disabled={
!canAct ||
hasJoined ||
isPastDeadline ||
joinMutation.isPending ||
!walletAddress
}
className="flex-1 h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() => void handleJoin()}
onClick={() =>
canAct &&
window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
}
>
{joinMutation.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Users className="mr-2 size-4" />
)}
{hasJoined
? "Joined ✓"
: canAct && !isPastDeadline
? "Join Competition"
: label()}
{label()}
</Button>
) : (
<div className="flex gap-2">
{canDispute && (
<Button
variant="outline"
size="lg"
className="h-11 border-yellow-500/30 text-yellow-500 hover:bg-yellow-500/10 shrink-0"
onClick={() => setDisputeDialogOpen(true)}
>
<AlertTriangle className="size-4" />
</Button>
)}
{canCancel && (
<Button
className="flex-1 h-11 font-bold tracking-wide"
disabled={!canAct}
Expand Down Expand Up @@ -507,6 +621,45 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Mobile Raise Dispute Dialog */}
<AlertDialog open={disputeDialogOpen} onOpenChange={setDisputeDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-yellow-500">
<AlertTriangle className="size-5" />
Raise Dispute
</AlertDialogTitle>
<AlertDialogDescription>
This will lock the bounty and notify reviewers.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="mobile-dispute-reason">
Reason <span className="text-red-400">*</span>
</Label>
<Textarea
id="mobile-dispute-reason"
placeholder="Describe the issue..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
className="min-h-[80px]"
disabled={isDisputing}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDisputing}>Cancel</AlertDialogCancel>
<Button
className="bg-yellow-500 text-black hover:bg-yellow-600"
onClick={handleRaiseDispute}
disabled={!disputeReason.trim() || isDisputing || raiseDisputeMutation.isPending}
>
{isDisputing && <Loader2 className="mr-2 size-4 animate-spin" />}
Submit Dispute
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
2 changes: 2 additions & 0 deletions components/bounty-detail/bounty-detail-submissions-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ export function BountyDetailSubmissionsCard({
return "bg-emerald-100 text-emerald-900";
case "REJECTED":
return "bg-red-100 text-red-900";
case "DISPUTED":
return "bg-yellow-100 text-yellow-900 border border-yellow-200";
case "PENDING":
default:
return "bg-gray-100 text-gray-900";
Expand Down
Loading