Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions src/components/disputes/RaiseDisputeTrigger.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="px-4 py-2 rounded-md border border-red-300 text-red-600 text-sm hover:bg-red-50 transition-colors"
>
Raise a dispute
</button>

<RaiseDisputeModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
adoptionId={adoptionId}
raisedBy={raisedBy}
/>
</>
);
}
84 changes: 84 additions & 0 deletions src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={qc}>
<RaiseDisputeTrigger
adoptionId="a-1"
adoptionStatus="CUSTODY_ACTIVE"
raisedBy="u-1"
isAdopter={true}
isShelter={false}
{...props}
/>
</QueryClientProvider>,
);
};

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();
});
});
176 changes: 176 additions & 0 deletions src/components/modals/RaiseDisputeModal.tsx
Original file line number Diff line number Diff line change
@@ -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<File[]>([]);
const [fileProgress, setFileProgress] = useState<number[]>([]);
const [inlineError, setInlineError] = useState<string | null>(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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40"
onClick={handleBackdropClick}
data-testid="dispute-backdrop"
>
<div
className="relative w-full max-w-lg bg-white rounded-2xl p-6"
onClick={(e) => e.stopPropagation()}
>
{!isPending && (
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-700"
aria-label="Close modal"
>
</button>
)}

<h2 className="text-lg font-bold mb-2">Raise a dispute</h2>
<p className="text-sm text-gray-500 mb-4">
Tell us why you're raising this dispute.
</p>

<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Reason</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={5}
className="w-full rounded-md border-gray-200 p-3"
aria-invalid={reason.length > 0 && reason.length < MIN_LEN}
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{reason.length < MIN_LEN ? `Minimum ${MIN_LEN} characters` : ""}</span>
<span>{reason.length} chars</span>
</div>
</div>

<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Evidence
</label>
<FileUploadZone
id="evidence-upload"
onChange={updateFiles}
selectedFiles={files}
maxFiles={MAX_FILES}
/>
{files.length > 0 && (
<div className="mt-3 space-y-2">
{files.map((file, idx) => (
<div key={idx}>
{typeof fileProgress[idx] === "number" && (
<div className="mt-2">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span className="truncate">{file.name}</span>
<span className="font-semibold">{fileProgress[idx]}%</span>
</div>
<div className="w-full bg-gray-200 h-1.5 rounded-full">
<div
className="h-1.5 bg-blue-600 rounded-full"
style={{ width: `${fileProgress[idx]}%` }}
/>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>

{inlineError && (
<div className="mb-3 text-sm text-red-600">{inlineError}</div>
)}

{error && (
<div className="mb-3 text-sm text-red-600">{error.message}</div>
)}

<div className="flex gap-3 justify-end">
<button
onClick={onClose}
disabled={isPending}
className="px-4 py-2 rounded-md border"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isPending || reason.trim().length < MIN_LEN}
className="px-4 py-2 rounded-md bg-slate-900 text-white disabled:opacity-60"
>
{isPending ? "Submitting..." : "Raise dispute"}
</button>
</div>
</div>
</div>
);
}
Loading
Loading