diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx
index e1e33314..029a2449 100644
--- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx
+++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx
@@ -10,6 +10,7 @@ import {
Loader2,
Users,
Clock,
+ Gavel,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -24,8 +25,15 @@ import {
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";
@@ -34,6 +42,7 @@ import { authClient } from "@/lib/auth-client";
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
@@ -59,11 +68,24 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
handleCancel,
} = useCancelBountyDialog(bounty.id, onCancelled);
+ const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
+ const [disputeReason, setDisputeReason] = useState("");
+ 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");
@@ -87,6 +109,17 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
}
};
+ 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);
+ };
+
const ctaLabel = () => {
if (!canAct) {
switch (bounty.status) {
@@ -235,6 +268,21 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
)}
+ {/* Raise Dispute */}
+ {canRaiseDispute && (
+ <>
+
+
+ >
+ )}
+
{/* Cancel Bounty */}
{canCancel && (
<>
@@ -361,6 +409,61 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) {
+
+ {/* Raise Dispute Dialog */}
+
+
+
+
+
+ Raise a Dispute
+
+
+ Please select a category and describe the disagreement.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
);
}
@@ -387,6 +490,13 @@ export function MobileCTA({ bounty, onCancelled }: MobileCTAProps) {
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");
diff --git a/components/bounty-detail/page.tsx b/components/bounty-detail/page.tsx
new file mode 100644
index 00000000..ca816d3f
--- /dev/null
+++ b/components/bounty-detail/page.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (error || !data?.adminDisputeDetail) {
+ return (
+
+
Dispute not found
+
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+
+ Dispute Resolution
+
+
Case #{disputeId}
+
+
+ {dispute.status}
+
+
+
+
+
+
+ Dispute Statement
+ Filed by participant
+
+
+
+
+
{dispute.reason.replace(/_/g, ' ')}
+
+
+
+
+ {dispute.description}
+
+
+
+
+
+
+
+ Reference Context
+ Targeted campaign data
+
+
+
+
+
{dispute.campaignId}
+
+ {dispute.milestoneId && (
+
+
+
{dispute.milestoneId}
+
+ )}
+
+
+
+
+
+
+ Arbitration Decision
+ Select a resolution and provide justification.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/lib/graphql/generated.ts b/lib/graphql/generated.ts
index cf647efe..6c5ce494 100644
--- a/lib/graphql/generated.ts
+++ b/lib/graphql/generated.ts
@@ -2257,6 +2257,45 @@ export type MarkSubmissionPaidMutation = {
};
};
+export type AdminDisputeDetailQueryVariables = Exact<{
+ id: Scalars["ID"]["input"];
+}>;
+
+export type AdminDisputeDetailQuery = {
+ __typename?: "Query";
+ adminDisputeDetail: {
+ __typename?: "AdminDisputeDto";
+ id: string;
+ campaignId: string;
+ description: string;
+ reason: DisputeReasonEnum;
+ status: DisputeStatusEnum;
+ resolution?: string | null;
+ milestoneId?: string | null;
+ createdAt: string;
+ };
+};
+
+export type ResolveDisputeMutationVariables = Exact<{
+ id: Scalars["ID"]["input"];
+ input: AdminResolveDisputeDto;
+}>;
+
+export type ResolveDisputeMutation = {
+ __typename?: "Mutation";
+ resolveDispute: {
+ __typename?: "AdminDisputeDto";
+ id: string;
+ campaignId: string;
+ description: string;
+ reason: DisputeReasonEnum;
+ status: DisputeStatusEnum;
+ resolution?: string | null;
+ milestoneId?: string | null;
+ createdAt: string;
+ };
+};
+
export const BountyFieldsFragmentDoc = new TypedDocumentString(
`
fragment BountyFields on Bounty {
@@ -2434,6 +2473,89 @@ export const useBookmarksQuery = (
});
};
+export const AdminDisputeDetailDocument = new TypedDocumentString(`
+ query AdminDisputeDetail($id: ID!) {
+ adminDisputeDetail(id: $id) {
+ id
+ campaignId
+ description
+ reason
+ status
+ resolution
+ milestoneId
+ createdAt
+ }
+}
+ `);
+
+export const useAdminDisputeDetailQuery = <
+ TData = AdminDisputeDetailQuery,
+ TError = unknown,
+>(
+ variables: AdminDisputeDetailQueryVariables,
+ options?: Omit<
+ UseQueryOptions,
+ "queryKey"
+ > & {
+ queryKey?: UseQueryOptions["queryKey"];
+ },
+) => {
+ return useQuery({
+ queryKey: ["AdminDisputeDetail", variables],
+ queryFn: fetcher(
+ AdminDisputeDetailDocument,
+ variables,
+ ),
+ ...options,
+ });
+};
+
+useAdminDisputeDetailQuery.getKey = (
+ variables: AdminDisputeDetailQueryVariables,
+) => ["AdminDisputeDetail", variables];
+
+export const ResolveDisputeDocument = new TypedDocumentString(`
+ mutation ResolveDispute($id: ID!, $input: AdminResolveDisputeDto!) {
+ resolveDispute(id: $id, input: $input) {
+ id
+ campaignId
+ description
+ reason
+ status
+ resolution
+ milestoneId
+ createdAt
+ }
+}
+ `);
+
+export const useResolveDisputeMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ ResolveDisputeMutation,
+ TError,
+ ResolveDisputeMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ ResolveDisputeMutation,
+ TError,
+ ResolveDisputeMutationVariables,
+ TContext
+ >({
+ mutationKey: ["ResolveDispute"],
+ mutationFn: (variables?: ResolveDisputeMutationVariables) =>
+ fetcher(
+ ResolveDisputeDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+
useBookmarksQuery.getKey = (variables?: BookmarksQueryVariables) =>
variables === undefined ? ["Bookmarks"] : ["Bookmarks", variables];
@@ -3387,6 +3509,372 @@ export const MarkSubmissionPaidDocument = new TypedDocumentString(`
rewardTransactionHash
}`);
+export const useMarkSubmissionPaidMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ MarkSubmissionPaidMutation,
+ TError,
+ MarkSubmissionPaidMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ MarkSubmissionPaidMutation,
+ TError,
+ MarkSubmissionPaidMutationVariables,
+ TContext
+ >({
+ mutationKey: ["MarkSubmissionPaid"],
+ mutationFn: (variables?: MarkSubmissionPaidMutationVariables) =>
+ fetcher(
+ MarkSubmissionPaidDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+ ...options,
+ });
+};
+
+export const ReviewSubmissionDocument = new TypedDocumentString(`
+ mutation ReviewSubmission($input: ReviewSubmissionInput!) {
+ reviewSubmission(input: $input) {
+ ...SubmissionFields
+ }
+}
+ fragment SubmissionFields on BountySubmissionType {
+ id
+ bountyId
+ submittedBy
+ submittedByUser {
+ id
+ name
+ image
+ }
+ githubPullRequestUrl
+ status
+ createdAt
+ updatedAt
+ reviewedAt
+ reviewedBy
+ reviewedByUser {
+ id
+ name
+ image
+ }
+ reviewComments
+ paidAt
+ rewardTransactionHash
+}`);
+
+export const useReviewSubmissionMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ ReviewSubmissionMutation,
+ TError,
+ ReviewSubmissionMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ ReviewSubmissionMutation,
+ TError,
+ ReviewSubmissionMutationVariables,
+ TContext
+ >({
+ mutationKey: ["ReviewSubmission"],
+ mutationFn: (variables?: ReviewSubmissionMutationVariables) =>
+ fetcher(
+ ReviewSubmissionDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+
+export const MarkSubmissionPaidDocument = new TypedDocumentString(`
+ mutation MarkSubmissionPaid($submissionId: ID!, $transactionHash: String!) {
+ markSubmissionPaid(
+ submissionId: $submissionId
+ transactionHash: $transactionHash
+ ) {
+ ...SubmissionFields
+ }
+}
+ fragment SubmissionFields on BountySubmissionType {
+ id
+ bountyId
+ submittedBy
+ submittedByUser {
+ id
+ name
+ image
+ }
+ githubPullRequestUrl
+ status
+ createdAt
+ updatedAt
+ reviewedAt
+ reviewedBy
+ reviewedByUser {
+ id
+ name
+ image
+ }
+ reviewComments
+ paidAt
+ rewardTransactionHash
+}`);
+
+export const useMarkSubmissionPaidMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ MarkSubmissionPaidMutation,
+ TError,
+ MarkSubmissionPaidMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ MarkSubmissionPaidMutation,
+ TError,
+ MarkSubmissionPaidMutationVariables,
+ TContext
+ >({
+ mutationKey: ["MarkSubmissionPaid"],
+ mutationFn: (variables?: MarkSubmissionPaidMutationVariables) =>
+ fetcher(
+ MarkSubmissionPaidDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+ ...options,
+ });
+};
+
+export const ReviewSubmissionDocument = new TypedDocumentString(`
+ mutation ReviewSubmission($input: ReviewSubmissionInput!) {
+ reviewSubmission(input: $input) {
+ ...SubmissionFields
+ }
+}
+ fragment SubmissionFields on BountySubmissionType {
+ id
+ bountyId
+ submittedBy
+ submittedByUser {
+ id
+ name
+ image
+ }
+ githubPullRequestUrl
+ status
+ createdAt
+ updatedAt
+ reviewedAt
+ reviewedBy
+ reviewedByUser {
+ id
+ name
+ image
+ }
+ reviewComments
+ paidAt
+ rewardTransactionHash
+}`);
+
+export const useReviewSubmissionMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ ReviewSubmissionMutation,
+ TError,
+ ReviewSubmissionMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ ReviewSubmissionMutation,
+ TError,
+ ReviewSubmissionMutationVariables,
+ TContext
+ >({
+ mutationKey: ["ReviewSubmission"],
+ mutationFn: (variables?: ReviewSubmissionMutationVariables) =>
+ fetcher(
+ ReviewSubmissionDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+
+export const MarkSubmissionPaidDocument = new TypedDocumentString(`
+ mutation MarkSubmissionPaid($submissionId: ID!, $transactionHash: String!) {
+ markSubmissionPaid(
+ submissionId: $submissionId
+ transactionHash: $transactionHash
+ ) {
+ ...SubmissionFields
+ }
+}
+ fragment SubmissionFields on BountySubmissionType {
+ id
+ bountyId
+ submittedBy
+ submittedByUser {
+ id
+ name
+ image
+ }
+ githubPullRequestUrl
+ status
+ createdAt
+ updatedAt
+ reviewedAt
+ reviewedBy
+ reviewedByUser {
+ id
+ name
+ image
+ }
+ reviewComments
+ paidAt
+ rewardTransactionHash
+}`);
+
+export const useMarkSubmissionPaidMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ MarkSubmissionPaidMutation,
+ TError,
+ MarkSubmissionPaidMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ MarkSubmissionPaidMutation,
+ TError,
+ MarkSubmissionPaidMutationVariables,
+ TContext
+ >({
+ mutationKey: ["MarkSubmissionPaid"],
+ mutationFn: (variables?: MarkSubmissionPaidMutationVariables) =>
+ fetcher(
+ MarkSubmissionPaidDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+ ...options,
+ });
+};
+
+export const ReviewSubmissionDocument = new TypedDocumentString(`
+ mutation ReviewSubmission($input: ReviewSubmissionInput!) {
+ reviewSubmission(input: $input) {
+ ...SubmissionFields
+ }
+}
+ fragment SubmissionFields on BountySubmissionType {
+ id
+ bountyId
+ submittedBy
+ submittedByUser {
+ id
+ name
+ image
+ }
+ githubPullRequestUrl
+ status
+ createdAt
+ updatedAt
+ reviewedAt
+ reviewedBy
+ reviewedByUser {
+ id
+ name
+ image
+ }
+ reviewComments
+ paidAt
+ rewardTransactionHash
+}`);
+
+export const useReviewSubmissionMutation = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: UseMutationOptions<
+ ReviewSubmissionMutation,
+ TError,
+ ReviewSubmissionMutationVariables,
+ TContext
+ >,
+) => {
+ return useMutation<
+ ReviewSubmissionMutation,
+ TError,
+ ReviewSubmissionMutationVariables,
+ TContext
+ >({
+ mutationKey: ["ReviewSubmission"],
+ mutationFn: (variables?: ReviewSubmissionMutationVariables) =>
+ fetcher(
+ ReviewSubmissionDocument,
+ variables,
+ )(),
+ ...options,
+ });
+};
+
+export const MarkSubmissionPaidDocument = new TypedDocumentString(`
+ mutation MarkSubmissionPaid($submissionId: ID!, $transactionHash: String!) {
+ markSubmissionPaid(
+ submissionId: $submissionId
+ transactionHash: $transactionHash
+ ) {
+ ...SubmissionFields
+ }
+}
+ fragment SubmissionFields on BountySubmissionType {
+ id
+ bountyId
+ submittedBy
+ submittedByUser {
+ id
+ name
+ image
+ }
+ githubPullRequestUrl
+ status
+ createdAt
+ updatedAt
+ reviewedAt
+ reviewedBy
+ reviewedByUser {
+ id
+ name
+ image
+ }
+ reviewComments
+ paidAt
+ rewardTransactionHash
+}`);
+
export const useMarkSubmissionPaidMutation = <
TError = unknown,
TContext = unknown,
diff --git a/lib/graphql/operations/admin-dispute.graphql b/lib/graphql/operations/admin-dispute.graphql
new file mode 100644
index 00000000..39cc75a3
--- /dev/null
+++ b/lib/graphql/operations/admin-dispute.graphql
@@ -0,0 +1,17 @@
+query AdminDisputeDetail($id: String!) {
+ adminDisputeDetail(id: $id) {
+ id
+ status
+ reason
+ description
+ campaignId
+ milestoneId
+ }
+}
+
+mutation ResolveDispute($id: String!, $input: AdminResolveDisputeDto!) {
+ resolveDispute(id: $id, input: $input) {
+ id
+ status
+ }
+}
diff --git a/page.tsx b/page.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/types/participation.ts b/types/participation.ts
index 184bda5f..a19da9c6 100644
--- a/types/participation.ts
+++ b/types/participation.ts
@@ -1,4 +1,4 @@
-export type ApplicationStatus = "pending" | "approved" | "rejected";
+export type ApplicationStatus = "pending" | "approved" | "rejected" | "disputed";
export interface Application {
id: string;
@@ -13,7 +13,7 @@ export interface Application {
feedback?: string;
}
-export type SubmissionStatus = "pending" | "accepted" | "rejected";
+export type SubmissionStatus = "pending" | "accepted" | "rejected" | "disputed";
export interface Submission {
id: string;