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
114 changes: 112 additions & 2 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Loader2,
Users,
Clock,
Gavel,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
Expand All @@ -24,8 +25,15 @@
AlertDialogFooter,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";

import { BountyFieldsFragment } from "@/lib/graphql/generated";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";

import { BountyFieldsFragment, DisputeReasonEnum } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button";
import { CompetitionSubmission } from "@/components/bounty/competition-submission";
Expand All @@ -34,6 +42,7 @@
import { useCompetitionJoinState } from "@/hooks/use-competition-join-state";
import type { CancellationRecord } from "@/types/escrow";
import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog";
import { toast } from "sonner";
import type { Bounty } from "@/types/bounty";

/** Props accept the wider intersection returned by useBountyDetail so
Expand All @@ -59,11 +68,24 @@
handleCancel,
} = useCancelBountyDialog(bounty.id, onCancelled);

const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
const [disputeReason, setDisputeReason] = useState<DisputeReasonEnum | "">("");
const [disputeDescription, setDisputeDescription] = useState("");
const [isSubmittingDispute, setIsSubmittingDispute] = useState(false);

const canAct = bounty.status === "OPEN";
const isFcfs = bounty.type === "FIXED_PRICE";
const isCompetition = bounty.type === "COMPETITION";
const isCreator =
(session?.user as { id?: string } | undefined)?.id === bounty.createdBy;

const isParticipant = bounty.submissions?.some(
(s) => s.submittedBy === (session?.user as { id?: string } | undefined)?.id
);

const canRaiseDispute = (isParticipant || isCreator) &&
(bounty.status === "IN_PROGRESS" || bounty.status === "UNDER_REVIEW");

const canCancel =
isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS");

Expand All @@ -87,6 +109,17 @@
}
};

const handleRaiseDispute = async () => {
setIsSubmittingDispute(true);
// Note: backend mutation for raiseDispute is pending schema update
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success("Dispute raised successfully.");
setDisputeDialogOpen(false);
setDisputeReason("");
setDisputeDescription("");
setIsSubmittingDispute(false);
};
Comment thread
Belzabeem marked this conversation as resolved.

const ctaLabel = () => {
if (!canAct) {
switch (bounty.status) {
Expand Down Expand Up @@ -235,6 +268,21 @@
</p>
)}

{/* Raise Dispute */}
{canRaiseDispute && (
<>
<Separator className="bg-gray-800/60" />
<Button
variant="ghost"
className="w-full text-gray-400 hover:text-red-400 hover:bg-red-500/5 transition-all text-xs h-8"
onClick={() => setDisputeDialogOpen(true)}
>
<Gavel className="size-3 mr-2" />
Raise a Dispute
</Button>
</>
)}

{/* Cancel Bounty */}
{canCancel && (
<>
Expand Down Expand Up @@ -361,6 +409,61 @@
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Raise Dispute Dialog */}
<AlertDialog open={disputeDialogOpen} onOpenChange={setDisputeDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Gavel className="size-5 text-red-400" />
Raise a Dispute
</AlertDialogTitle>
<AlertDialogDescription>
Please select a category and describe the disagreement.
</AlertDialogDescription>
</AlertDialogHeader>

<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Reason</Label>
<Select value={disputeReason} onValueChange={(v) => setDisputeReason(v as DisputeReasonEnum)}>
<SelectTrigger>
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
<SelectContent>
{Object.values(DisputeReasonEnum).map((reason) => (
<SelectItem key={reason} value={reason}>
{reason.replace(/_/g, " ")}
</SelectItem>
))}
</SelectContent>
</Select>
Comment thread
Belzabeem marked this conversation as resolved.
</div>
<div className="space-y-2">
<Label htmlFor="dispute-desc">Description</Label>
<Textarea
id="dispute-desc"
placeholder="Provide details for the reviewer..."
value={disputeDescription}
onChange={(e) => setDisputeDescription(e.target.value)}
className="min-h-24"
/>
</div>
</div>

<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmittingDispute}>Cancel</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleRaiseDispute}
disabled={!disputeReason || !disputeDescription.trim() || isSubmittingDispute}
>
{isSubmittingDispute && <Loader2 className="mr-2 size-4 animate-spin" />}
Submit Dispute
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
Expand All @@ -387,6 +490,13 @@
const isCompetition = bounty.type === "COMPETITION";
const isCreator =
(session?.user as { id?: string } | undefined)?.id === bounty.createdBy;

const isParticipant = bounty.submissions?.some(
(s) => s.submittedBy === (session?.user as { id?: string } | undefined)?.id
);
const canRaiseDispute = (isParticipant || isCreator) &&

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

View workflow job for this annotation

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

'canRaiseDispute' is assigned a value but never used
(bounty.status === "IN_PROGRESS" || bounty.status === "UNDER_REVIEW");

const canCancel =
isCreator && (bounty.status === "OPEN" || bounty.status === "IN_PROGRESS");

Expand Down
178 changes: 178 additions & 0 deletions components/bounty-detail/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use client";

import { use, useState } from "react";
import { useRouter } from "next/navigation";
import {
useAdminDisputeDetailQuery,
useResolveDisputeMutation,
DisputeResolutionEnum
} from "@/lib/graphql/generated";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Loader2, Gavel, ArrowLeft, ShieldCheck, ShieldX } from "lucide-react";

interface DisputePageProps {
params: Promise<{ disputeId: string }>;
}

export default function DisputeReviewPage({ params }: DisputePageProps) {
const { disputeId } = use(params);
const router = useRouter();
const [resolutionNotes, setResolutionNotes] = useState("");

const { data, isLoading, error } = useAdminDisputeDetailQuery({
id: disputeId,
});

const resolveMutation = useResolveDisputeMutation({
onSuccess: () => {
toast.success("Dispute resolved successfully");
router.push("/admin/disputes");
},
onError: (err: Error) => {
toast.error(`Failed to resolve dispute: ${err.message}`);
}
});

if (isLoading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}

if (error || !data?.adminDisputeDetail) {
return (
<div className="p-8 text-center border rounded-xl bg-muted/20">
<h1 className="text-2xl font-bold">Dispute not found</h1>
<Button onClick={() => router.back()} className="mt-4" variant="outline">
Go Back
</Button>
</div>
);
}

const dispute = data.adminDisputeDetail;

const handleResolve = (resolution: DisputeResolutionEnum) => {
if (!resolutionNotes.trim()) {
toast.error("Please provide resolution notes");
return;
}

resolveMutation.mutate({
id: disputeId,
input: {
resolution,
resolutionNotes,
}
});
};

return (
<div className="container max-w-4xl py-10 space-y-6">
<Button variant="ghost" onClick={() => router.back()} className="gap-2 mb-4">
<ArrowLeft className="h-4 w-4" />
Back to Disputes
</Button>

<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-3xl font-bold flex items-center gap-2">
<Gavel className="h-8 w-8 text-primary" />
Dispute Resolution
</h1>
<p className="text-muted-foreground font-mono text-sm">Case #{disputeId}</p>
</div>
<Badge variant={dispute.status === 'OPEN' ? 'default' : 'secondary'} className="px-3 py-1">
{dispute.status}
</Badge>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="border-gray-800 bg-background-card">
<CardHeader>
<CardTitle className="text-lg">Dispute Statement</CardTitle>
<CardDescription>Filed by participant</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-[10px] uppercase text-gray-500 tracking-wider">Reason</Label>
<p className="font-medium text-gray-200">{dispute.reason.replace(/_/g, ' ')}</p>
</div>
<div>
<Label className="text-[10px] uppercase text-gray-500 tracking-wider">Description</Label>
<p className="mt-1 text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
{dispute.description}
</p>
</div>
</CardContent>
</Card>

<Card className="border-gray-800 bg-background-card">
<CardHeader>
<CardTitle className="text-lg">Reference Context</CardTitle>
<CardDescription>Targeted campaign data</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-[10px] uppercase text-gray-500 tracking-wider">Campaign ID</Label>
<p className="text-sm font-mono text-gray-400">{dispute.campaignId}</p>
</div>
{dispute.milestoneId && (
<div>
<Label className="text-[10px] uppercase text-gray-500 tracking-wider">Milestone ID</Label>
<p className="text-sm font-mono text-gray-400">{dispute.milestoneId}</p>
</div>
)}
</CardContent>
</Card>
</div>
Comment thread
Belzabeem marked this conversation as resolved.

<Card className="border-primary/20 bg-primary/5 shadow-2xl">
<CardHeader>
<CardTitle>Arbitration Decision</CardTitle>
<CardDescription>Select a resolution and provide justification.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="notes">Resolution Notes</Label>
<Textarea
id="notes"
placeholder="Explain the evidence considered and the reasoning behind your decision..."
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
className="min-h-[120px] bg-background"
/>
</div>

<div className="flex flex-wrap gap-4 pt-2">
<Button
onClick={() => handleResolve(DisputeResolutionEnum.Dismissed)}
className="flex-1 gap-2 h-11"
variant="default"
disabled={resolveMutation.isPending}
>
<ShieldCheck className="h-4 w-4" />
Approve Contributor
</Button>
<Button
onClick={() => handleResolve(DisputeResolutionEnum.FullRefund)}
className="flex-1 gap-2 h-11"
variant="destructive"
disabled={resolveMutation.isPending}
>
<ShieldX className="h-4 w-4" />
Approve Sponsor
</Button>
</div>
Comment thread
Belzabeem marked this conversation as resolved.
</CardContent>
</Card>
</div>
);
}
Loading
Loading