diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx
index e1e33314..506a8698 100644
--- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx
+++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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);
+ }
+ };
+
+ // 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;
+
const ctaLabel = () => {
if (!canAct) {
switch (bounty.status) {
@@ -221,14 +256,20 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
)}
- {/* Helper text when locked out */}
- {isCompetition && !hasJoined && isPastDeadline && (
-
-
- Submission deadline has passed.
-
+ {/* Raise Dispute Button */}
+ {canDispute && bounty.status !== "DISPUTED" && (
+ setDisputeDialogOpen(true)}
+ disabled={isDisputing}
+ >
+
+ Raise Dispute
+
)}
- {!canAct && !isCompetition && (
+
+ {!canAct && (
This bounty is no longer accepting new submissions.
@@ -361,6 +402,51 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
+
+ {/* Raise Dispute Confirmation Dialog */}
+
+
+
+
+
+ Raise Dispute
+
+
+ Raising a dispute will lock the bounty and notify our reviewers to mediate. Please provide a clear reason for the dispute.
+
+
+
+
+
+
+ Reason for dispute *
+
+
+
+
+
+ setDisputeReason("")}>
+ Cancel
+
+
+ {isDisputing && }
+ Submit Dispute
+
+
+
+
);
}
@@ -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";
const isCompetition = bounty.type === "COMPETITION";
@@ -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 (
- {isFcfs ? (
-
- ) : isCompetition ? (
+
void handleJoin()}
+ onClick={() =>
+ canAct &&
+ window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
+ }
>
- {joinMutation.isPending ? (
-
- ) : (
-
- )}
- {hasJoined
- ? "Joined ✓"
- : canAct && !isPastDeadline
- ? "Join Competition"
- : label()}
+ {label()}
- ) : (
-
+ {canDispute && (
+
setDisputeDialogOpen(true)}
+ >
+
+
+ )}
+ {canCancel && (
+
+ {/* Mobile Raise Dispute Dialog */}
+
+
+
+
+
+ Raise Dispute
+
+
+ This will lock the bounty and notify reviewers.
+
+
+
+
+ Reason *
+
+
+
+ Cancel
+
+ {isDisputing && }
+ Submit Dispute
+
+
+
+
);
}
diff --git a/components/bounty-detail/bounty-detail-submissions-card.tsx b/components/bounty-detail/bounty-detail-submissions-card.tsx
index c9270a90..aa53a8e1 100644
--- a/components/bounty-detail/bounty-detail-submissions-card.tsx
+++ b/components/bounty-detail/bounty-detail-submissions-card.tsx
@@ -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";
diff --git a/components/bounty-detail/page.tsx b/components/bounty-detail/page.tsx
new file mode 100644
index 00000000..c40c722a
--- /dev/null
+++ b/components/bounty-detail/page.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { useState } from "react";
+import { useParams } from "next/navigation";
+import Link from "next/link";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ XCircle,
+ ArrowLeft,
+ FileText,
+ User,
+ Building2,
+ Loader2,
+ AlertCircle,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import { authClient } from "@/lib/auth-client";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogClose,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { useResolveDisputeMutation } from "@/hooks/use-dispute-mutations";
+import { useQuery } from "@tanstack/react-query";
+import { fetcher } from "@/lib/graphql/client";
+
+export default function DisputeReviewPage() {
+ const params = useParams();
+ const disputeId = params.disputeId as string;
+ const { mutateAsync: resolveDispute, isPending: isResolving } = useResolveDisputeMutation();
+ const { data: session } = authClient.useSession();
+
+ const [requestInfoDialogOpen, setRequestInfoDialogOpen] = useState(false);
+ const [additionalInfoRequest, setAdditionalInfoRequest] = useState("");
+
+ // Fetch dispute details using standard query (placeholder for generated hook)
+ const { data: disputeData, isLoading, isError, error } = useQuery({
+ queryKey: ["dispute", disputeId],
+ queryFn: fetcher
(`
+ query AdminDisputeDetail($id: ID!) {
+ adminDisputeDetail(id: $id) {
+ id
+ status
+ reason
+ description
+ createdAt
+ updatedAt
+ resolutionNotes
+ bounty {
+ title
+ organization { name }
+ }
+ contributor {
+ id
+ name
+ username
+ }
+ submission {
+ githubPullRequestUrl
+ createdAt
+ }
+ }
+ }
+ `, { id: disputeId }),
+ enabled: !!disputeId,
+ });
+
+ const dispute = disputeData?.adminDisputeDetail;
+ const isReviewer = session?.user?.role === "ADMIN" || session?.user?.role === "REVIEWER";
+
+ const handleResolve = async (resolution: "APPROVED_CONTRIBUTOR" | "APPROVED_SPONSOR") => {
+ try {
+ await resolveDispute({
+ id: disputeId,
+ input: {
+ resolution: resolution === "APPROVED_CONTRIBUTOR" ? "DISMISSED" : "FULL_REFUND",
+ resolutionNotes: `Decision made by reviewer. Result: ${resolution}.`,
+ },
+ });
+ toast.success("Dispute resolved successfully");
+ } catch (error) {
+ console.error("Failed to resolve dispute:", error);
+ toast.error("Failed to resolve dispute. Please try again.");
+ }
+ };
+
+ const handleRequestAdditionalInfo = async () => {
+ toast.success("Request for additional information sent.");
+ setRequestInfoDialogOpen(false);
+ setAdditionalInfoRequest("");
+ };
+
+ if (isLoading) {
+ return (
+
+
+
Loading dispute details...
+
+ );
+ }
+
+ if (isError || !dispute) {
+ return (
+
+
+
Failed to load dispute
+
{error?.message || "Not found"}
+
+ Back to Reviews
+
+
+ );
+ }
+
+ if (!isReviewer) {
+ return (
+
+
+
Access Denied
+
+ Go to Home
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Back to Reviews
+
+
+
+
+
+
+
+
Dispute Resolution
+
+ {dispute.status.replace(/_/g, ' ').toLowerCase()}
+
+
+
+ Mediating conflict for: {dispute.bounty?.title || 'N/A'}
+
+
+
+
+ setRequestInfoDialogOpen(true)} disabled={isResolving}>
+ Request Info
+
+ handleResolve("APPROVED_CONTRIBUTOR")}
+ disabled={isResolving}
+ >
+
+ Approve Contributor
+
+ handleResolve("APPROVED_SPONSOR")} disabled={isResolving}>
+
+ Approve Sponsor
+
+
+
+
+
+
+
+
+
Contributor
+
+
+
+
+
User
+
{dispute.contributor?.name || 'N/A'} (@{dispute.contributor?.username || 'N/A'})
+
+
+
Submission
+ {dispute.submission?.githubPullRequestUrl ? (
+
+ View on GitHub
+
+ ) :
No URL provided
}
+
+
+
Submitted At
+
{dispute.submission?.createdAt ? new Date(dispute.submission.createdAt).toLocaleString() : 'N/A'}
+
+
+
+
+
+
+
+
Sponsor
+
+
+
+
+
Organization
+
{dispute.bounty?.organization?.name || 'N/A'}
+
+
+
Latest Feedback
+
+ "{dispute.resolutionNotes || 'No specific feedback provided yet.'}"
+
+
+
+
+
+
+
+
+
+
Grounds for Dispute
+
+
+
+
{dispute.reason.replace(/_/g, ' ').toLowerCase()}
+
{dispute.description || 'No detailed description provided.'}
+
+
+
+
+
+
+ Request Additional Information
+ Describe what information is needed from the participants.
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/hooks/use-dispute-mutations.ts b/hooks/use-dispute-mutations.ts
new file mode 100644
index 00000000..e5277586
--- /dev/null
+++ b/hooks/use-dispute-mutations.ts
@@ -0,0 +1,56 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ Mutation,
+ MutationRaiseDisputeArgs,
+ MutationResolveDisputeArgs,
+ DisputeReasonEnum,
+ DisputeResolutionEnum,
+} from "@/lib/graphql/generated";
+import { fetcher } from "@/lib/graphql/client";
+
+const RAISE_DISPUTE_MUTATION = `
+ mutation RaiseDispute($input: CreateDisputeInput!) {
+ raiseDispute(input: $input) {
+ id
+ status
+ reason
+ description
+ createdAt
+ bountyId
+ }
+ }
+`;
+
+const RESOLVE_DISPUTE_MUTATION = `
+ mutation ResolveDispute($id: ID!, $input: AdminResolveDisputeDto!) {
+ resolveDispute(id: $id, input: $input) {
+ id
+ status
+ resolution
+ }
+ }
+`;
+
+export function useRaiseDisputeMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({ // Using generated type
+ mutationFn: (variables) => fetcher(RAISE_DISPUTE_MUTATION, variables)(),
+ onSuccess: () => {
+ // Invalidate relevant queries to refetch data after a dispute is raised
+ queryClient.invalidateQueries({ queryKey: ["Bounty"] }); // Invalidate bounty details
+ queryClient.invalidateQueries({ queryKey: ["disputes"] }); // Invalidate list of disputes
+ },
+ });
+}
+
+export function useResolveDisputeMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({ // Using generated type
+ mutationFn: (variables) => fetcher(RESOLVE_DISPUTE_MUTATION, variables)(),
+ onSuccess: () => {
+ // Invalidate relevant queries to refetch data after a dispute is resolved
+ queryClient.invalidateQueries({ queryKey: ["dispute"] }); // Invalidate specific dispute
+ queryClient.invalidateQueries({ queryKey: ["Bounty"] }); // Invalidate bounty details
+ },
+ });
+}
\ No newline at end of file
diff --git a/types/participation.ts b/types/participation.ts
index 184bda5f..046ba4a9 100644
--- a/types/participation.ts
+++ b/types/participation.ts
@@ -1,4 +1,8 @@
-export type ApplicationStatus = "pending" | "approved" | "rejected";
+export type ApplicationStatus =
+ | "pending"
+ | "approved"
+ | "rejected"
+ | "disputed";
export interface Application {
id: string;
@@ -13,7 +17,11 @@ export interface Application {
feedback?: string;
}
-export type SubmissionStatus = "pending" | "accepted" | "rejected";
+export type SubmissionStatus =
+ | "pending"
+ | "accepted"
+ | "rejected"
+ | "disputed";
export interface Submission {
id: string;