diff --git a/app/api/disputes/route.ts b/app/api/disputes/route.ts new file mode 100644 index 00000000..6c17c015 --- /dev/null +++ b/app/api/disputes/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAccessToken } from "@/lib/auth-utils"; +import { getCurrentUser } from "@/lib/server-auth"; +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 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..bc0537a2 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Github, Copy, @@ -32,10 +33,9 @@ import { CompetitionSubmission } from "@/components/bounty/competition-submissio import { CompetitionStatus } from "@/components/bounty/competition-status"; import type { CancellationRecord } from "@/types/escrow"; import type { Bounty } from "@/types/bounty"; -import { - ApplicationDialog, -} from "@/components/bounty/application-dialog"; +import { ApplicationDialog } from "@/components/bounty/application-dialog"; import { useBountyCTAState } from "./use-bounty-cta-state"; +import { RaiseDisputeDialog } from "./raise-dispute-dialog"; type SidebarBounty = BountyFieldsFragment & Partial; @@ -45,6 +45,8 @@ interface SidebarCTAProps { } export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { + const [disputeDialogOpen, setDisputeDialogOpen] = useState(false); + const { walletAddress, hasJoined, @@ -251,10 +253,10 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { )} @@ -323,6 +325,13 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { )} + {/* Raise Dispute Dialog */} + + {/* Cancel Confirmation Dialog */} diff --git a/components/bounty-detail/raise-dispute-dialog.tsx b/components/bounty-detail/raise-dispute-dialog.tsx new file mode 100644 index 00000000..1c53cd94 --- /dev/null +++ b/components/bounty-detail/raise-dispute-dialog.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Gavel, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +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, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; + +import { DisputeReasonEnum } from "@/lib/graphql/generated"; +import { useRaiseDispute } from "@/hooks/use-bounty-application"; + +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", +}; + +interface RaiseDisputeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bountyId: string; +} + +export function RaiseDisputeDialog({ + open, + onOpenChange, + bountyId, +}: RaiseDisputeDialogProps) { + const router = useRouter(); + const raiseDisputeMutation = useRaiseDispute(); + + const [disputeReason, setDisputeReason] = useState(""); + const [disputeDescription, setDisputeDescription] = useState(""); + const [disputeReasonError, setDisputeReasonError] = useState(""); + const [disputeDescriptionError, setDisputeDescriptionError] = useState(""); + + const resetForm = () => { + setDisputeReason(""); + setDisputeDescription(""); + setDisputeReasonError(""); + setDisputeDescriptionError(""); + }; + + const handleRaiseDispute = async () => { + 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, + reason: disputeReason as DisputeReasonEnum, + description: disputeDescription.trim(), + }); + resetForm(); + onOpenChange(false); + toast.success("Dispute filed successfully."); + router.push(`/dispute/${result.id}`); + } catch { + toast.error("Failed to file dispute. Please try again."); + } + }; + + return ( + { + if (!raiseDisputeMutation.isPending) { + onOpenChange(nextOpen); + if (!nextOpen) { + resetForm(); + } + } + }} + > + + + + + Raise a Dispute + + + Describe the issue with this bounty. A moderator will review your + dispute and reach out to both parties. + + + +
+
+ + + {disputeReasonError && ( +

{disputeReasonError}

+ )} +
+ +
+ +