Skip to content
Closed
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
145 changes: 145 additions & 0 deletions client/src/components/campaign/OpenDisputeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useWallet } from '../../context/WalletContext';
import {
useContribution,
useDispute,
useEscrowAdmin,
useOpenDispute,
} from '../../hooks/contract';
import { toUserFacingError } from '../../lib/soroban/userFacingError';

const cardClass =
'rounded-campaign border border-soil-200 bg-white p-6 shadow-campaign';
const primaryButtonClass =
'inline-flex items-center justify-center rounded-lg bg-leaf-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
const inputClass =
'w-full rounded-lg border border-soil-300 px-3 py-2 text-body-sm text-soil-900 focus:border-leaf-500 focus:outline-none focus:ring-1 focus:ring-leaf-500';
const labelClass = 'mb-1 block text-label text-soil-500';
const errorClass = 'mt-1 text-caption text-status-failed-dark';
const sectionTitleClass = 'text-h4 text-soil-900';

function ActionError({ message }: { message: string | null }) {
if (!message) return null;
return <p className={errorClass}>{message}</p>;
}

export interface OpenDisputeFormProps {
campaignId: string;
farmerAddress: string;
}

/**
* Lets a campaign's farmer, a contributing investor, or the escrow admin
* open a dispute. Eligibility is a UX convenience only — `open_dispute`
* enforces the same set on-chain via `.require_auth()`.
*/
export function OpenDisputeForm({
campaignId,
farmerAddress,
}: OpenDisputeFormProps) {
const { publicKey } = useWallet();
const openDispute = useOpenDispute();
const { data: contribution } = useContribution(
campaignId,
publicKey ?? undefined,
);
const { data: adminAddress } = useEscrowAdmin();
const { data: dispute } = useDispute(campaignId);

const [reason, setReason] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);

const hasOpenDispute = dispute?.status?.tag === 'Open';

if (!publicKey) {
return (
<div className={cardClass}>
<h3 className={sectionTitleClass}>Open a dispute</h3>
<p className="mt-2 text-body-sm text-soil-500">
Connect your wallet to open a dispute on this campaign.
</p>
</div>
);
}

const isFarmer = publicKey === farmerAddress;
const isContributor = (contribution ?? 0n) > 0n;
const isAdmin = publicKey === adminAddress;
const canOpenDispute = isFarmer || isContributor || isAdmin;

if (!canOpenDispute) return null;

if (hasOpenDispute) {
return (
<div className={cardClass}>
<h3 className={sectionTitleClass}>Open a dispute</h3>
<p className="mt-2 text-body-sm text-soil-500" role="status">
A dispute is already open on this campaign — an admin needs to resolve
it before a new one can be opened.
</p>
</div>
);
}

async function handleSubmit(event: FormEvent) {
event.preventDefault();
setFormError(null);
setSuccess(false);

if (reason.trim().length === 0) {
setFormError('Enter a reason for the dispute.');
return;
}

try {
await openDispute.mutateAsync({
campaignId,
opener: publicKey!,
reason: reason.trim(),
});
setSuccess(true);
setReason('');
} catch (err) {
setFormError(toUserFacingError(err));
}
}

return (
<div className={cardClass}>
<form onSubmit={handleSubmit} className="space-y-3">
<h3 className={sectionTitleClass}>Open a dispute</h3>
<p className="text-body-sm text-soil-500">
Raising a dispute pauses fund releases until an admin resolves it.
</p>
<div>
<label className={labelClass} htmlFor="dispute-reason">
Reason
</label>
<textarea
id="dispute-reason"
className={inputClass}
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Describe why you're opening this dispute…"
/>
</div>
<button
type="submit"
disabled={openDispute.isPending}
className={primaryButtonClass}
>
{openDispute.isPending ? 'Confirm in wallet…' : 'Open dispute'}
</button>
<ActionError message={formError} />
{success && (
<p className="text-caption text-status-active-dark">
Dispute opened.
</p>
)}
</form>
</div>
);
}
159 changes: 159 additions & 0 deletions client/src/components/campaign/__tests__/OpenDisputeForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OpenDisputeForm } from '../OpenDisputeForm';
import type { Dispute } from '../../../lib/soroban/types';

const FARMER = 'GFARMER0000000000000000000000000000000000000000000000000';
const CONTRIBUTOR = 'GINVESTOR000000000000000000000000000000000000000000000';
const ADMIN = 'GADMIN00000000000000000000000000000000000000000000000000';
const STRANGER = 'GSTRANGER00000000000000000000000000000000000000000000000';

const mockOpenDispute = vi.fn();
let mockPublicKey: string | null = FARMER;
let mockContribution = 0n;
let mockAdmin: string | undefined = ADMIN;
let mockDispute: Dispute | undefined;

vi.mock('../../../context/WalletContext', () => ({
useWallet: () => ({ publicKey: mockPublicKey }),
}));

vi.mock('../../../hooks/contract', () => ({
useOpenDispute: () => ({ mutateAsync: mockOpenDispute, isPending: false }),
useContribution: () => ({ data: mockContribution }),
useEscrowAdmin: () => ({ data: mockAdmin }),
useDispute: () => ({ data: mockDispute }),
}));

beforeEach(() => {
mockOpenDispute.mockReset();
mockPublicKey = FARMER;
mockContribution = 0n;
mockAdmin = ADMIN;
mockDispute = undefined;
});

describe('OpenDisputeForm', () => {
it('prompts to connect a wallet when none is connected', () => {
mockPublicKey = null;
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

expect(
screen.getByText(/connect your wallet to open a dispute/i),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /open dispute/i })).toBeNull();
});

it('renders nothing for a connected wallet that is not the farmer, a contributor, or the admin', () => {
mockPublicKey = STRANGER;
mockContribution = 0n;
const { container } = render(
<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />,
);

expect(container).toBeEmptyDOMElement();
});

it('lets the farmer open a dispute', async () => {
mockPublicKey = FARMER;
const user = userEvent.setup();
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

await user.type(
screen.getByLabelText(/reason/i),
'Harvest outcome does not match the report.',
);
await user.click(screen.getByRole('button', { name: /open dispute/i }));

expect(mockOpenDispute).toHaveBeenCalledWith({
campaignId: '42',
opener: FARMER,
reason: 'Harvest outcome does not match the report.',
});
expect(await screen.findByText(/dispute opened/i)).toBeInTheDocument();
});

it('lets a contributing investor open a dispute', async () => {
mockPublicKey = CONTRIBUTOR;
mockContribution = 500n;
const user = userEvent.setup();
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

await user.type(screen.getByLabelText(/reason/i), 'Funds not released.');
await user.click(screen.getByRole('button', { name: /open dispute/i }));

expect(mockOpenDispute).toHaveBeenCalledWith({
campaignId: '42',
opener: CONTRIBUTOR,
reason: 'Funds not released.',
});
});

it('lets the admin open a dispute', async () => {
mockPublicKey = ADMIN;
mockContribution = 0n;
const user = userEvent.setup();
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

await user.type(
screen.getByLabelText(/reason/i),
'Investigating on behalf of a reporter.',
);
await user.click(screen.getByRole('button', { name: /open dispute/i }));

expect(mockOpenDispute).toHaveBeenCalledTimes(1);
});

it('rejects an empty reason client-side and does not call the mutation', async () => {
const user = userEvent.setup();
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

await user.click(screen.getByRole('button', { name: /open dispute/i }));

expect(
await screen.findByText(/enter a reason for the dispute/i),
).toBeInTheDocument();
expect(mockOpenDispute).not.toHaveBeenCalled();
});

it('surfaces a contract-level rejection as a readable error without clearing the form', async () => {
mockOpenDispute.mockRejectedValueOnce(
new Error(
'HostError: Error(Contract, #7)\nlong diagnostic dump that should be truncated',
),
);
const user = userEvent.setup();
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

const reasonInput = screen.getByLabelText(/reason/i);
await user.type(reasonInput, 'Disputing harvest outcome.');
await user.click(screen.getByRole('button', { name: /open dispute/i }));

expect(
await screen.findByText(/HostError: Error\(Contract, #7\)/),
).toBeInTheDocument();
expect(reasonInput).toHaveValue('Disputing harvest outcome.');
});

it('does not render the open-dispute form when a dispute is already Open', () => {
mockDispute = {
campaign_id: 42n,
opener: FARMER,
reason: 'Harvest_delay',
timestamp: 0n,
ledger_sequence: 0,
status: { tag: 'Open' },
resolution: { tag: 'Pending' },
};
render(<OpenDisputeForm campaignId="42" farmerAddress={FARMER} />);

expect(
screen.getByText(
/a dispute is already open on this campaign — an admin needs to resolve it/i,
),
).toBeInTheDocument();
expect(screen.queryByLabelText(/reason/i)).toBeNull();
expect(screen.queryByRole('button', { name: /open dispute/i })).toBeNull();
});
});
9 changes: 9 additions & 0 deletions client/src/pages/CampaignDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { FundCampaignModal } from '../components/campaign/FundCampaignModal';
import { StatusBadge } from '../components/campaign/StatusBadge';
import { ActivityFeed } from '../components/campaign/ActivityFeed';
import { OpenDisputeForm } from '../components/campaign/OpenDisputeForm';
import { useCampaignLiveUpdates } from '../hooks/useCampaignLiveUpdates';
import { DetailPageSkeleton } from '../components/ui/Skeleton/Skeleton';

Expand All @@ -12,6 +13,7 @@ export interface CampaignData {
totalTarget: number;
currentRaised: number;
status: 'Active' | 'Funding' | 'Resolved' | 'Failed' | 'Settled';
farmer: string;
}

export const CampaignDetailPage: React.FC = () => {
Expand All @@ -28,6 +30,8 @@ export const CampaignDetailPage: React.FC = () => {
totalTarget: 50000,
currentRaised: 32500,
status: 'Funding',
farmer:
'GDF4X5Y6Z7A8B9C0D1E2F3G4H5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3',
});
}, 0);
return () => window.clearTimeout(timer);
Expand Down Expand Up @@ -137,6 +141,11 @@ export const CampaignDetailPage: React.FC = () => {
refreshIntervalMs={30_000}
/>
</div>

<OpenDisputeForm
campaignId={campaign.id}
farmerAddress={campaign.farmer}
/>
</div>
);
};
Expand Down