From 9ba0b9fcfc72067f6ff654dd701b247c50945121 Mon Sep 17 00:00:00 2001 From: Od-hunter Date: Fri, 29 May 2026 13:25:11 +0100 Subject: [PATCH 1/2] feat: implement raise dispute flow with redirect to dispute review page - Add useRaiseDispute mutation hook in hooks/use-bounty-application.ts that POSTs to /api/disputes with bountyId, reason, and description, then invalidates the bounty detail and list queries on success - Create app/api/disputes/route.ts Next.js API route handler that authenticates the user, validates input, and proxies the request to the backend REST endpoint with the auth token - Enable the Raise Dispute button in bounty-detail-sidebar-cta.tsx (removes disabled + Coming Soon label), wires up an AlertDialog with a reason Select (DisputeReasonEnum) and description Textarea, inline validation that keeps the dialog open on empty fields, a loading spinner during submission, and on success closes the dialog, shows a toast, and redirects to /dispute/{newDisputeId} - Document the RaiseDispute operation in admin-dispute.graphql --- app/api/disputes/route.ts | 97 ++++++++++ .../bounty-detail-sidebar-cta.tsx | 179 +++++++++++++++++- hooks/use-bounty-application.ts | 46 +++++ lib/graphql/operations/admin-dispute.graphql | 14 ++ 4 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 app/api/disputes/route.ts diff --git a/app/api/disputes/route.ts b/app/api/disputes/route.ts new file mode 100644 index 00000000..1faa64c0 --- /dev/null +++ b/app/api/disputes/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/server-auth"; +import { graphqlRequest } from "@/lib/server-graphql"; +import type { AdminDisputeDto, DisputeReasonEnum } from "@/lib/graphql/generated"; + +/** + * POST /api/disputes + * + * Creates a new dispute for a bounty. Forwards the request to the backend + * GraphQL API once a raiseDispute mutation is available, or to the REST + * endpoint in the interim. + * + * Body: + * campaignId – ID of the bounty being disputed + * reason – DisputeReasonEnum value + * description – Free-text explanation from the filer + * + * Returns the created AdminDisputeDto (including its `id`). + */ +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { campaignId, reason, description } = body as { + campaignId?: string; + reason?: DisputeReasonEnum; + description?: string; + }; + + // Input validation + if (!campaignId || typeof campaignId !== "string") { + return NextResponse.json( + { error: "campaignId is required" }, + { status: 400 }, + ); + } + if (!reason || typeof reason !== "string") { + return NextResponse.json( + { error: "reason is required" }, + { status: 400 }, + ); + } + if (!description || typeof description !== "string" || !description.trim()) { + return NextResponse.json( + { error: "description is required" }, + { status: 400 }, + ); + } + + // Forward to the backend REST API + const backendUrl = process.env.NEXT_PUBLIC_API_URL; + if (!backendUrl) { + return NextResponse.json( + { error: "Backend API URL not configured" }, + { status: 500 }, + ); + } + + const { getAccessToken } = await import("@/lib/auth-utils"); + const token = await getAccessToken(); + + const backendResponse = await fetch(`${backendUrl}/disputes`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + campaignId, + reason, + description, + }), + }); + + if (!backendResponse.ok) { + const errorText = await backendResponse.text(); + console.error("Backend dispute creation failed:", errorText); + return NextResponse.json( + { error: "Failed to create dispute" }, + { status: backendResponse.status }, + ); + } + + const dispute = (await backendResponse.json()) as AdminDisputeDto; + return NextResponse.json(dispute, { status: 201 }); + } catch (error) { + console.error("Error creating dispute:", error); + return NextResponse.json( + { error: "Failed to create dispute" }, + { status: 500 }, + ); + } +} diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index d8f37c31..49cd803b 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; import { Github, Copy, @@ -15,6 +17,13 @@ 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { AlertDialog, AlertDialogContent, @@ -24,8 +33,9 @@ import { AlertDialogFooter, AlertDialogCancel, } from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; -import { BountyFieldsFragment } from "@/lib/graphql/generated"; +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"; @@ -36,6 +46,18 @@ import { ApplicationDialog, } from "@/components/bounty/application-dialog"; import { useBountyCTAState } from "./use-bounty-cta-state"; +import { useRaiseDispute } from "@/hooks/use-bounty-application"; + +// Human-readable labels for each dispute reason +const DISPUTE_REASON_LABELS: Record = { + [DisputeReasonEnum.MilestoneNotDelivered]: "Milestone Not Delivered", + [DisputeReasonEnum.PoorQualityWork]: "Poor Quality Work", + [DisputeReasonEnum.DeadlineMissed]: "Deadline Missed", + [DisputeReasonEnum.ScopeChange]: "Scope Change", + [DisputeReasonEnum.MisuseOfFunds]: "Misuse of Funds", + [DisputeReasonEnum.CommunicationIssues]: "Communication Issues", + [DisputeReasonEnum.Other]: "Other", +}; type SidebarBounty = BountyFieldsFragment & Partial; @@ -45,6 +67,52 @@ interface SidebarCTAProps { } export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { + const router = useRouter(); + const raiseDisputeMutation = useRaiseDispute(); + + // Dispute dialog state + const [disputeDialogOpen, setDisputeDialogOpen] = useState(false); + const [disputeReason, setDisputeReason] = useState(""); + const [disputeDescription, setDisputeDescription] = useState(""); + const [disputeReasonError, setDisputeReasonError] = useState(""); + const [disputeDescriptionError, setDisputeDescriptionError] = useState(""); + + const handleRaiseDispute = async () => { + // Inline validation + let valid = true; + if (!disputeReason) { + setDisputeReasonError("Please select a reason."); + valid = false; + } else { + setDisputeReasonError(""); + } + if (!disputeDescription.trim()) { + setDisputeDescriptionError("Please describe the dispute."); + valid = false; + } else { + setDisputeDescriptionError(""); + } + if (!valid) return; + + try { + const result = await raiseDisputeMutation.mutateAsync({ + bountyId: bounty.id, + reason: disputeReason as DisputeReasonEnum, + description: disputeDescription.trim(), + }); + // Reset form state before closing so onOpenChange doesn't double-reset + setDisputeReason(""); + setDisputeDescription(""); + setDisputeReasonError(""); + setDisputeDescriptionError(""); + setDisputeDialogOpen(false); + toast.success("Dispute filed successfully."); + router.push(`/dispute/${result.id}`); + } catch { + toast.error("Failed to file dispute. Please try again."); + } + }; + const { walletAddress, hasJoined, @@ -251,10 +319,10 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { )} @@ -323,6 +391,111 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { )} + {/* Raise Dispute Dialog */} + { + if (!raiseDisputeMutation.isPending) { + setDisputeDialogOpen(open); + if (!open) { + setDisputeReason(""); + setDisputeDescription(""); + setDisputeReasonError(""); + setDisputeDescriptionError(""); + } + } + }} + > + + + + + Raise a Dispute + + + Describe the issue with this bounty. A moderator will review your + dispute and reach out to both parties. + + + +
+ {/* Reason */} +
+ + + {disputeReasonError && ( +

{disputeReasonError}

+ )} +
+ + {/* Description */} +
+ +