Skip to content
Draft
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
2 changes: 1 addition & 1 deletion client/src/__tests__/CampaignsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function mockQuery(partial: Record<string, unknown>) {
isError: false,
refetch: vi.fn(),
...partial,
} as ReturnType<typeof useAllCampaigns>);
} as unknown as ReturnType<typeof useAllCampaigns>);
}

function renderPage() {
Expand Down
24 changes: 10 additions & 14 deletions client/src/components/campaign/DisputeDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,50 +22,46 @@ export const DisputeDetailsCard: React.FC<DisputeDetailsCardProps> = ({
}) => (
<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"
className="rounded-campaign border border-status-disputed/30 bg-status-disputed-light p-6 shadow-campaign"
>
<div className="flex items-center justify-between">
<h2
id="dispute-details-heading"
className="text-lg font-semibold text-red-900 dark:text-red-200"
className="text-lg font-semibold text-status-disputed-dark"
>
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">
<span className="rounded-full border border-status-disputed/30 bg-status-disputed-light px-3 py-1 text-xs font-semibold text-status-disputed-dark">
{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">
<dt className="font-medium text-status-disputed-dark">Reason</dt>
<dd className="mt-1 whitespace-pre-wrap text-status-disputed-dark">
{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">
<dt className="font-medium text-status-disputed-dark">Opened by</dt>
<dd className="mt-1 font-mono text-status-disputed-dark">
{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">
<dt className="font-medium text-status-disputed-dark">Opened at</dt>
<dd className="mt-1 text-status-disputed-dark">
{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">
<p className="mt-4 border-t border-status-disputed/20 pt-3 text-xs text-status-disputed-dark">
Fund release is paused until an admin resolves this dispute.
</p>
</section>
Expand Down
33 changes: 18 additions & 15 deletions client/src/components/campaign/OpenDisputeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,24 +73,27 @@ export const OpenDisputeModal: React.FC<OpenDisputeModalProps> = ({
const remaining = DISPUTE_REASON_MAX_LENGTH - reason.trim().length;

return (
<Modal isOpen={isOpen} onClose={handleClose} title="Open a dispute" size="md">
<Modal
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">
<p className="text-sm text-soil-600">
You are opening a dispute on{' '}
<span className="font-semibold text-slate-900 dark:text-white">
{campaignTitle}
</span>{' '}
<span className="font-semibold text-soil-900">{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.
<span className="font-semibold text-soil-900">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"
className="block text-sm font-medium text-soil-900"
>
Reason <span className="text-red-600">*</span>
Reason <span className="text-status-failed-dark">*</span>
</label>
<textarea
id="dispute-reason"
Expand All @@ -105,9 +108,9 @@ export const OpenDisputeModal: React.FC<OpenDisputeModalProps> = ({
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"
className="w-full rounded-xl border border-soil-300 bg-white px-3 py-2 text-soil-900 shadow-sm focus:border-leaf-500 focus:outline-none focus:ring-1 focus:ring-leaf-500"
/>
<div className="flex justify-between text-xs text-slate-500">
<div className="flex justify-between text-xs text-soil-500">
<span>Required — this is stored on-chain.</span>
<span>{remaining} characters left</span>
</div>
Expand All @@ -117,25 +120,25 @@ export const OpenDisputeModal: React.FC<OpenDisputeModalProps> = ({
<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"
className="rounded-xl border border-status-failed bg-status-failed-light p-3 text-sm text-status-failed-dark"
>
{error}
</p>
)}

<div className="flex justify-end gap-3 border-t border-slate-100 pt-4 dark:border-slate-800">
<div className="flex justify-end gap-3 border-t border-soil-100 pt-4">
<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"
className="rounded-xl border border-soil-300 px-4 py-2 font-medium text-soil-700 transition hover:bg-soil-50 disabled:opacity-50"
>
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"
className="rounded-xl bg-status-failed px-5 py-2 font-semibold text-white shadow-md transition hover:bg-status-failed-dark disabled:opacity-50"
>
{openDispute.isPending ? 'Opening dispute…' : 'Open dispute'}
</button>
Expand Down
4 changes: 3 additions & 1 deletion client/src/lib/dispute/__tests__/eligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ describe('validateDisputeReason', () => {
});

it('rejects a reason over the length cap', () => {
const result = validateDisputeReason('x'.repeat(DISPUTE_REASON_MAX_LENGTH + 1));
const result = validateDisputeReason(
'x'.repeat(DISPUTE_REASON_MAX_LENGTH + 1),
);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/characters or fewer/i);
});
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/ActivityFeedPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function ActivityFeedPage() {
}}
>
Enter one or more campaign IDs above to view activity, or configure{' '}
<code className="rounded bg-slate-100 px-1 py-0.5 text-xs">
<code className="rounded bg-soil-100 px-1 py-0.5 text-xs text-soil-800">
VITE_KNOWN_CAMPAIGN_IDS
</code>{' '}
to preload the global feed.
Expand Down
32 changes: 1 addition & 31 deletions client/src/pages/CampaignDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { FundCampaignModal } from '../components/campaign/FundCampaignModal';
import { OpenDisputeModal } from '../components/campaign/OpenDisputeModal';
import {
DisputeDetailsCard,
type DisputeSummary,
} from '../components/campaign/DisputeDetailsCard';
import { StatusBadge } from '../components/campaign/StatusBadge';
import { ActivityFeed } from '../components/campaign/ActivityFeed';
import { OpenDisputeForm } from '../components/campaign/OpenDisputeForm';
import { useCampaignLiveUpdates } from '../hooks/useCampaignLiveUpdates';
import { useCampaign } from '../hooks/contract/useEscrowQueries';
import { DetailPageSkeleton } from '../components/ui/Skeleton/Skeleton';
import { useWallet } from '../context/WalletContext';
import { useContribution, useEscrowAdmin } from '../hooks/contract';
import { evaluateDisputeEligibility } from '../lib/dispute/eligibility';
import type { CampaignStatusTag } from '../lib/soroban/types';

export const CampaignDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDisputeModalOpen, setIsDisputeModalOpen] = useState(false);
const [dispute, setDispute] = useState<DisputeSummary | null>(null);

const { publicKey } = useWallet();

const { data: campaign, isLoading, isError, refetch } = useCampaign(id);

Expand Down Expand Up @@ -76,18 +63,6 @@ export const CampaignDetailPage: React.FC = () => {
void refetch();
};

// Reflect the new state immediately rather than waiting for the indexer:
// the contract has already accepted the dispute at this point.
const handleDisputeSuccess = (reason: string) => {
setCampaign((prev) => (prev ? { ...prev, status: 'Disputed' } : prev));
setDispute({
opener: publicKey!,
reason,
timestamp: Math.floor(Date.now() / 1000),
status: 'Open',
});
};

return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="rounded-campaign border border-soil-200 bg-white p-6 shadow-campaign">
Expand Down Expand Up @@ -140,8 +115,6 @@ export const CampaignDetailPage: React.FC = () => {
</div>
</div>

{dispute && <DisputeDetailsCard dispute={dispute} />}

<FundCampaignModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
Expand All @@ -160,10 +133,7 @@ export const CampaignDetailPage: React.FC = () => {
/>
</div>

<OpenDisputeForm
campaignId={campaign.id}
farmerAddress={campaign.farmer}
/>
<OpenDisputeForm campaignId={id} farmerAddress={campaign.farmer} />
</div>
);
};
Expand Down
6 changes: 6 additions & 0 deletions client/src/pages/__tests__/CampaignDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ vi.mock('../../components/campaign/FundCampaignModal', () => ({
),
}));

vi.mock('../../components/campaign/OpenDisputeForm', () => ({
OpenDisputeForm: () => (
<div data-testid="open-dispute-form">Open Dispute Form Component</div>
),
}));

describe('CampaignDetailPage', () => {
let queryClient: QueryClient;

Expand Down