diff --git a/package-lock.json b/package-lock.json index 1fd70b3..18d6b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3381,20 +3381,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", diff --git a/src/components/disputes/RaiseDisputeTrigger.tsx b/src/components/disputes/RaiseDisputeTrigger.tsx new file mode 100644 index 0000000..5e5fbf6 --- /dev/null +++ b/src/components/disputes/RaiseDisputeTrigger.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { RaiseDisputeModal } from "../modals/RaiseDisputeModal"; + +interface Props { + adoptionId: string; + adoptionStatus: string; + raisedBy: string; + isAdopter: boolean; + isShelter: boolean; +} + +export function RaiseDisputeTrigger({ + adoptionId, + adoptionStatus, + raisedBy, + isAdopter, + isShelter, +}: Props) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const canRaiseDispute = + adoptionStatus === "CUSTODY_ACTIVE" && (isAdopter || isShelter); + + if (!canRaiseDispute) return null; + + return ( + <> + + + setIsModalOpen(false)} + adoptionId={adoptionId} + raisedBy={raisedBy} + /> + + ); +} \ No newline at end of file diff --git a/src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx b/src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx new file mode 100644 index 0000000..98b6012 --- /dev/null +++ b/src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { RaiseDisputeTrigger } from "../RaiseDisputeTrigger"; +import * as disputeHookModule from "../../../hooks/useMutateRaiseDispute"; + +vi.mock("../../../hooks/useMutateRaiseDispute", () => ({ + useMutateRaiseDispute: vi.fn(), +})); + +vi.mock("react-hot-toast", () => ({ + default: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + }), +})); + +const mockHook = () => { + vi.mocked(disputeHookModule.useMutateRaiseDispute).mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + error: null, + } as any); +}; + +const renderTrigger = (props?: any) => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +}; + +describe("RaiseDisputeTrigger", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHook(); + }); + + it("renders the trigger button when status is CUSTODY_ACTIVE and isAdopter", () => { + renderTrigger(); + expect(screen.getByRole("button", { name: /raise a dispute/i })).toBeInTheDocument(); + }); + + it("renders the trigger button when status is CUSTODY_ACTIVE and isShelter", () => { + renderTrigger({ isAdopter: false, isShelter: true }); + expect(screen.getByRole("button", { name: /raise a dispute/i })).toBeInTheDocument(); + }); + + it("does not render when status is not CUSTODY_ACTIVE", () => { + renderTrigger({ adoptionStatus: "PENDING" }); + expect(screen.queryByRole("button", { name: /raise a dispute/i })).not.toBeInTheDocument(); + }); + + it("does not render when neither isAdopter nor isShelter", () => { + renderTrigger({ isAdopter: false, isShelter: false }); + expect(screen.queryByRole("button", { name: /raise a dispute/i })).not.toBeInTheDocument(); + }); + + it("opens the modal when trigger button is clicked", () => { + renderTrigger(); + fireEvent.click(screen.getByRole("button", { name: /raise a dispute/i })); + expect(screen.getByText("Tell us why you're raising this dispute.")).toBeInTheDocument(); + }); + + it("closes the modal when Escape is pressed", () => { + renderTrigger(); + fireEvent.click(screen.getByRole("button", { name: /raise a dispute/i })); + expect(screen.getByText("Tell us why you're raising this dispute.")).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByText("Tell us why you're raising this dispute.")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/modals/RaiseDisputeModal.tsx b/src/components/modals/RaiseDisputeModal.tsx new file mode 100644 index 0000000..beff931 --- /dev/null +++ b/src/components/modals/RaiseDisputeModal.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect, useCallback } from "react"; +import { FileUploadZone } from "../ui/FileUploadZone"; +import { useMutateRaiseDispute } from "../../hooks/useMutateRaiseDispute"; +import toast from "react-hot-toast"; + +interface Props { + isOpen: boolean; + onClose: () => void; + adoptionId: string; + raisedBy: string; +} + +const MIN_LEN = 30; +const MAX_FILES = 5; + +export function RaiseDisputeModal({ isOpen, onClose, adoptionId, raisedBy }: Props) { + const [reason, setReason] = useState(""); + const [files, setFiles] = useState([]); + const [fileProgress, setFileProgress] = useState([]); + const [inlineError, setInlineError] = useState(null); + + const handleFileProgress = (index: number, pct: number) => { + setFileProgress((prev) => { + const next = [...prev]; + next[index] = pct; + return next; + }); + }; + + const { mutateAsync, isPending, error } = useMutateRaiseDispute({ + onProgress: handleFileProgress, + }); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && !isPending) onClose(); + }, + [isPending, onClose], + ); + + useEffect(() => { + if (!isOpen) return; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, handleKeyDown]); + + if (!isOpen) return null; + + const updateFiles = (newFiles: File[]) => { + setFiles(newFiles); + }; + + const handleBackdropClick = () => { + if (!isPending) onClose(); + }; + + const handleSubmit = async () => { + setInlineError(null); + + if (reason.trim().length < MIN_LEN) { + setInlineError(`Reason must be at least ${MIN_LEN} characters.`); + return; + } + + try { + await mutateAsync({ adoptionId, raisedBy, reason, files }); + toast.success("Dispute raised. Escrow paused."); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to raise dispute."; + setInlineError(msg); + } + }; + + return ( +
+
e.stopPropagation()} + > + {!isPending && ( + + )} + +

Raise a dispute

+

+ Tell us why you're raising this dispute. +

+ +
+ +