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
74 changes: 74 additions & 0 deletions client/src/components/campaign/DisputeDetailsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { truncateAddress } from '../../context/WalletContext';

export interface DisputeSummary {
opener: string;
reason: string;
/** Unix seconds; omitted for an optimistic record awaiting indexing. */
timestamp?: number;
status: 'Open' | 'Resolved';
}

export interface DisputeDetailsCardProps {
dispute: DisputeSummary;
}

/**
* Surfaces the active dispute on a campaign — shown as soon as `open_dispute`
* succeeds, before the indexer has caught up.
*/
export const DisputeDetailsCard: React.FC<DisputeDetailsCardProps> = ({
dispute,
}) => (
<section
aria-labelledby="dispute-details-heading"
className="rounded-2xl border border-red-200 bg-red-50 p-6 dark:border-red-900 dark:bg-red-950/40"
>
<div className="flex items-center justify-between">
<h2
id="dispute-details-heading"
className="text-lg font-semibold text-red-900 dark:text-red-200"
>
Dispute {dispute.status === 'Open' ? 'open' : 'resolved'}
</h2>
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800 dark:bg-red-900 dark:text-red-200">
{dispute.status}
</span>
</div>

<dl className="mt-4 space-y-3 text-sm">
<div>
<dt className="font-medium text-red-900 dark:text-red-200">Reason</dt>
<dd className="mt-1 whitespace-pre-wrap text-red-800 dark:text-red-300">
{dispute.reason}
</dd>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div>
<dt className="font-medium text-red-900 dark:text-red-200">
Opened by
</dt>
<dd className="mt-1 font-mono text-red-800 dark:text-red-300">
{truncateAddress(dispute.opener)}
</dd>
</div>
{dispute.timestamp !== undefined && (
<div>
<dt className="font-medium text-red-900 dark:text-red-200">
Opened at
</dt>
<dd className="mt-1 text-red-800 dark:text-red-300">
{new Date(dispute.timestamp * 1000).toLocaleString()}
</dd>
</div>
)}
</div>
</dl>

<p className="mt-4 border-t border-red-200 pt-3 text-xs text-red-700 dark:border-red-900 dark:text-red-400">
Fund release is paused until an admin resolves this dispute.
</p>
</section>
);

export default DisputeDetailsCard;
148 changes: 148 additions & 0 deletions client/src/components/campaign/OpenDisputeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal/Modal';
import { useOpenDispute } from '../../hooks/contract/useEscrowMutations';
import {
validateDisputeReason,
DISPUTE_REASON_MAX_LENGTH,
type DisputeOpenerRole,
} from '../../lib/dispute/eligibility';
import { toUserFacingError } from '../../lib/soroban/userFacingError';

export interface OpenDisputeModalProps {
isOpen: boolean;
onClose: () => void;
campaignId: string;
campaignTitle: string;
/** Connected wallet opening the dispute. */
opener: string;
/** Which authorization rule granted access, shown as context in the form. */
role: DisputeOpenerRole;
/** Fired after the contract call succeeds, with the submitted reason. */
onSuccess?: (reason: string) => void;
}

const ROLE_LABEL: Record<DisputeOpenerRole, string> = {
farmer: 'campaign farmer',
contributor: 'contributing investor',
admin: 'platform admin',
};

export const OpenDisputeModal: React.FC<OpenDisputeModalProps> = ({
isOpen,
onClose,
campaignId,
campaignTitle,
opener,
role,
onSuccess,
}) => {
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(null);
const openDispute = useOpenDispute();

if (!isOpen) return null;

const handleClose = () => {
setReason('');
setError(null);
onClose();
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

const validation = validateDisputeReason(reason);
if (!validation.valid) {
setError(validation.error);
return;
}

setError(null);
const trimmed = reason.trim();

try {
await openDispute.mutateAsync({ campaignId, opener, reason: trimmed });
setReason('');
onSuccess?.(trimmed);
onClose();
} catch (err) {
setError(toUserFacingError(err));
}
};

const remaining = DISPUTE_REASON_MAX_LENGTH - reason.trim().length;

return (
<Modal isOpen={isOpen} onClose={handleClose} title="Open a dispute" size="md">

Check failure on line 76 in client/src/components/campaign/OpenDisputeModal.tsx

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test, build

Replace `·isOpen={isOpen}·onClose={handleClose}·title="Open·a·dispute"·size="md"` with `⏎······isOpen={isOpen}⏎······onClose={handleClose}⏎······title="Open·a·dispute"⏎······size="md"⏎····`
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-slate-600 dark:text-slate-300">
You are opening a dispute on{' '}
<span className="font-semibold text-slate-900 dark:text-white">
{campaignTitle}
</span>{' '}
as the {ROLE_LABEL[role]}. This moves the campaign to{' '}
<span className="font-semibold">Disputed</span> and pauses fund
release until an admin resolves it.
</p>

<div className="space-y-1">
<label
htmlFor="dispute-reason"
className="block text-sm font-medium text-slate-900 dark:text-white"
>
Reason <span className="text-red-600">*</span>
</label>
<textarea
id="dispute-reason"
required
rows={4}
value={reason}
maxLength={DISPUTE_REASON_MAX_LENGTH}
onChange={(e) => {
setReason(e.target.value);
if (error) setError(null);
}}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? 'dispute-reason-error' : undefined}
placeholder="Describe the issue with this campaign…"
className="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>Required — this is stored on-chain.</span>
<span>{remaining} characters left</span>
</div>
</div>

{error && (
<p
id="dispute-reason-error"
role="alert"
className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300"
>
{error}
</p>
)}

<div className="flex justify-end gap-3 border-t border-slate-100 pt-4 dark:border-slate-800">
<button
type="button"
onClick={handleClose}
disabled={openDispute.isPending}
className="rounded-xl px-4 py-2 font-medium text-slate-700 transition hover:bg-slate-100 disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
<button
type="submit"
disabled={openDispute.isPending}
className="rounded-xl bg-red-700 px-5 py-2 font-semibold text-white shadow-md transition hover:bg-red-800 disabled:opacity-50"
>
{openDispute.isPending ? 'Opening dispute…' : 'Open dispute'}
</button>
</div>
</form>
</Modal>
);
};

export default OpenDisputeModal;
155 changes: 155 additions & 0 deletions client/src/lib/dispute/__tests__/eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest';
import {
evaluateDisputeEligibility,
validateDisputeReason,
DISPUTE_REASON_MAX_LENGTH,
} from '../eligibility';
import type { CampaignStatusTag } from '../../soroban/types';

const FARMER = 'GFARMER00000000000000000000000000000000000000000000000AA';
const ADMIN = 'GADMIN000000000000000000000000000000000000000000000000BB';
const INVESTOR = 'GINVESTOR0000000000000000000000000000000000000000000000CC';
const STRANGER = 'GSTRANGER0000000000000000000000000000000000000000000000DD';

const base = {
status: 'Funding' as CampaignStatusTag,
walletAddress: STRANGER,
farmer: FARMER,
admin: ADMIN,
contribution: 0n,
};

describe('evaluateDisputeEligibility', () => {
it('allows the campaign farmer', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: FARMER,
});
expect(result.eligible).toBe(true);
expect(result.role).toBe('farmer');
});

it('allows the escrow admin', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: ADMIN,
});
expect(result.eligible).toBe(true);
expect(result.role).toBe('admin');
});

it('allows a wallet with a non-zero contribution', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: INVESTOR,
contribution: 1n,
});
expect(result.eligible).toBe(true);
expect(result.role).toBe('contributor');
});

it('rejects a wallet with a zero contribution', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: INVESTOR,
contribution: 0n,
});
expect(result.eligible).toBe(false);
expect(result.role).toBeNull();
});

it('rejects a wallet whose contribution is still unknown', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: INVESTOR,
contribution: undefined,
});
expect(result.eligible).toBe(false);
});

it('rejects when no wallet is connected, even for a disputable status', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: null,
});
expect(result.eligible).toBe(false);
expect(result.reason).toMatch(/connect your wallet/i);
});

it.each<CampaignStatusTag>(['Active', 'Funding', 'Funded'])(
'allows an eligible caller while status is %s',
(status) => {
const result = evaluateDisputeEligibility({
...base,
status,
walletAddress: FARMER,
});
expect(result.eligible).toBe(true);
},
);

it.each<CampaignStatusTag>([
'InProduction',
'Harvested',
'Disputed',
'Resolved',
'Settled',
'Failed',
])('blocks even the farmer once status is %s', (status) => {
const result = evaluateDisputeEligibility({
...base,
status,
walletAddress: FARMER,
});
expect(result.eligible).toBe(false);
expect(result.reason).toMatch(/only be opened/i);
});

it('blocks while the campaign status is still loading', () => {
const result = evaluateDisputeEligibility({
...base,
status: undefined,
walletAddress: FARMER,
});
expect(result.eligible).toBe(false);
});

it('does not grant admin rights when the admin address is unknown', () => {
const result = evaluateDisputeEligibility({
...base,
walletAddress: ADMIN,
admin: undefined,
});
expect(result.eligible).toBe(false);
});
});

describe('validateDisputeReason', () => {
it('rejects an empty reason', () => {
expect(validateDisputeReason('').valid).toBe(false);
});

it('rejects a whitespace-only reason', () => {
const result = validateDisputeReason(' \n\t ');
expect(result.valid).toBe(false);
expect(result.error).toMatch(/required/i);
});

it('accepts a non-empty reason', () => {
expect(validateDisputeReason('Harvest was never delivered.').valid).toBe(
true,
);
});

it('rejects a reason over the length cap', () => {
const result = validateDisputeReason('x'.repeat(DISPUTE_REASON_MAX_LENGTH + 1));

Check failure on line 145 in client/src/lib/dispute/__tests__/eligibility.test.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test, build

Replace `'x'.repeat(DISPUTE_REASON_MAX_LENGTH·+·1)` with `⏎······'x'.repeat(DISPUTE_REASON_MAX_LENGTH·+·1),⏎····`
expect(result.valid).toBe(false);
expect(result.error).toMatch(/characters or fewer/i);
});

it('accepts a reason exactly at the length cap', () => {
expect(
validateDisputeReason('x'.repeat(DISPUTE_REASON_MAX_LENGTH)).valid,
).toBe(true);
});
});
Loading
Loading