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
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AnalyticsDashboardPage } from './pages/AnalyticsDashboardPage';
import { AdminDashboardPage } from './pages/AdminDashboardPage';
import { ActivityFeedPage } from './pages/ActivityFeedPage';
import { CampaignDetailPage } from './pages/CampaignDetailPage';
import { CampaignsPage } from './pages/CampaignsPage';
import { FarmerProfilePage } from './pages/FarmerProfilePage';
import './App.css';

Expand Down Expand Up @@ -53,6 +54,7 @@ export default function App() {
{/* AppLayout routes */}
<Route element={<AppLayout />}>
<Route path="/activity" element={<ActivityFeedPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
<Route path="/profile" element={<FarmerProfilePage />} />
<Route path="/farmers/:address" element={<FarmerProfilePage />} />
Expand Down
91 changes: 91 additions & 0 deletions client/src/__tests__/CampaignsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import type { Campaign } from '../lib/soroban/types';

vi.mock('../lib/soroban/config', () => ({
isEscrowConfigured: () => true,
}));

vi.mock('../hooks/useAllCampaigns', () => ({
useAllCampaigns: vi.fn(),
}));

import { useAllCampaigns } from '../hooks/useAllCampaigns';
import { CampaignsPage } from '../pages/CampaignsPage';

const mockUseAllCampaigns = vi.mocked(useAllCampaigns);

function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
return {
farmer: 'GFARMER1234567890FARMER1234567890FARMER1234567890FARMER12',
target_amount: 1000n,
token_address: 'CTOKEN',
deadline: 0n,
harvest_metadata: 'Organic maize',
total_funded: 250n,
released: 0n,
refundable: 0n,
returnable: 0n,
status: { tag: 'Funding' },
...overrides,
};
}

function mockQuery(partial: Record<string, unknown>) {
mockUseAllCampaigns.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
...partial,
} as ReturnType<typeof useAllCampaigns>);
}

function renderPage() {
return render(
<MemoryRouter>
<CampaignsPage />
</MemoryRouter>,
);
}

describe('CampaignsPage', () => {
beforeEach(() => vi.clearAllMocks());

it('renders a live list of campaigns, each linking to its detail page', () => {
mockQuery({
data: [
{ id: '2', campaign: makeCampaign({ harvest_metadata: 'Coffee lot' }) },
{ id: '1', campaign: makeCampaign() },
],
});
renderPage();

expect(screen.getByText('Coffee lot')).toBeInTheDocument();
expect(screen.getByText('Organic maize')).toBeInTheDocument();

const link = screen.getByRole('link', { name: /Coffee lot/i });
expect(link).toHaveAttribute('href', '/campaigns/2');
});

it('handles the empty state when no campaigns exist yet', () => {
mockQuery({ data: [] });
renderPage();
expect(screen.getByText(/no campaigns yet/i)).toBeInTheDocument();
});

it('handles the error state on RPC/backend failure', () => {
mockQuery({ isError: true });
renderPage();
expect(screen.getByText(/couldn.t load campaigns/i)).toBeInTheDocument();
});

it('shows a loading skeleton while fetching', () => {
mockQuery({ isLoading: true });
renderPage();
expect(
screen.getByLabelText(/loading campaign cards/i),
).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions client/src/hooks/contract/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export const contractQueryKeys = {
activity: (campaignId: string) => ['activity', campaignId] as const,
escrowAdmin: () => ['escrowAdmin'] as const,
adminCampaignsOverview: () => ['adminCampaignsOverview'] as const,
allCampaigns: () => ['allCampaigns'] as const,
};
71 changes: 71 additions & 0 deletions client/src/hooks/useAllCampaigns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useQuery } from '@tanstack/react-query';
import { loadRecentEscrowEvents } from '../lib/soroban/events';
import { contractMethod, getEscrowClient } from '../lib/soroban/contractClient';
import {
ESCROW_CONTRACT_ID,
RPC_URL,
isEscrowConfigured,
} from '../lib/soroban/config';
import { contractQueryKeys } from './contract/queryKeys';
import type { Campaign } from '../lib/soroban/types';

const DEFAULT_LOOKBACK_LEDGERS = 120_000;

const LOOKBACK_LEDGERS = (() => {
const parsed = Number(import.meta.env.VITE_SOROBAN_EVENTS_LOOKBACK_LEDGERS);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_LOOKBACK_LEDGERS;
})();

export interface CampaignOverview {
id: string;
campaign: Campaign;
}

/**
* Discovers every campaign from ProductionEscrowContract event history (there
* is no on-chain "list all campaigns" getter) and fetches its current state.
*
* Shares the event-scanning approach of hooks/useAdminCampaigns.ts but applies
* no status filter — this backs the public `/campaigns` marketplace list, so
* investors see campaigns in every lifecycle stage, newest id first.
*/
export function useAllCampaigns() {
return useQuery({
queryKey: contractQueryKeys.allCampaigns(),
enabled: isEscrowConfigured(),
queryFn: async (): Promise<CampaignOverview[]> => {
const events = await loadRecentEscrowEvents({
rpcUrl: RPC_URL!,
contractId: ESCROW_CONTRACT_ID!,
lookbackLedgers: LOOKBACK_LEDGERS,
});

const campaignIds = Array.from(
new Set(events.map((event) => event.campaignId).filter(Boolean)),
);

const client = await getEscrowClient();
const overviews = await Promise.all(
campaignIds.map(async (id): Promise<CampaignOverview | null> => {
try {
const tx = await contractMethod<Campaign>(
client,
'get_campaign',
)({ campaign_id: BigInt(id) });
return { id, campaign: tx.result };
} catch {
// Campaign may no longer resolve (e.g. stale/malformed event) —
// skip it rather than failing the whole list load.
return null;
}
}),
);

return overviews
.filter((o): o is CampaignOverview => o !== null)
.sort((a, b) => Number(b.id) - Number(a.id));
},
});
}
197 changes: 197 additions & 0 deletions client/src/pages/CampaignsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { StatusBadge } from '../components/campaign/StatusBadge';
import { CampaignCardsSkeleton } from '../components/ui/Skeleton/Skeleton';
import { isEscrowConfigured } from '../lib/soroban/config';
import { STATUS_META } from '../lib/campaignStatus';
import type { CampaignStatusTag } from '../lib/soroban/types';
import {
useAllCampaigns,
type CampaignOverview,
} from '../hooks/useAllCampaigns';

const sectionClass = 'mx-auto max-w-7xl px-4 py-12 sm:px-6 sm:py-16 lg:px-8';
const cardClass =
'rounded-campaign border border-soil-200 bg-white p-8 shadow-campaign sm:p-12';
const primaryLinkClass =
'inline-flex rounded-lg bg-leaf-700 px-5 py-3 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';

function CampaignCard({ id, campaign }: CampaignOverview) {
const target = Number(campaign.target_amount);
const raised = Number(campaign.total_funded);
const pct =
target > 0 ? Math.min(100, Math.round((raised / target) * 100)) : 0;
const title = campaign.harvest_metadata || `Campaign #${id}`;

return (
<Link
to={`/campaigns/${id}`}
className="group flex w-full flex-col rounded-campaign border border-soil-200 bg-white p-6 shadow-campaign transition hover:border-soil-300 hover:shadow-campaign-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500"
>
<div className="flex items-center justify-between">
<StatusBadge status={campaign.status.tag} />
<span className="font-mono text-caption text-soil-500">#{id}</span>
</div>

<h2 className="mt-3 text-h4 text-soil-950 group-hover:text-leaf-800">
{title}
</h2>
<p className="mt-1 truncate font-mono text-caption text-soil-500">
Farmer: {campaign.farmer}
</p>

<div className="mt-4 space-y-1.5">
<div className="flex justify-between text-body-sm">
<span className="font-semibold text-soil-900">
${raised.toLocaleString()}{' '}
<span className="font-normal text-soil-500">raised</span>
</span>
<span className="font-medium text-soil-500">
${target.toLocaleString()} ({pct}%)
</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-soil-100"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${title} funding progress: ${pct}% of target raised`}
>
<div
className="h-full rounded-full bg-leaf-600 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</Link>
);
}

export function CampaignsPage() {
const { data, isLoading, isError, refetch } = useAllCampaigns();
const [filter, setFilter] = useState<CampaignStatusTag | 'All'>('All');

const campaigns = useMemo(() => data ?? [], [data]);
const availableStatuses = useMemo(
() =>
Array.from(
new Set(campaigns.map((c) => c.campaign.status.tag)),
).sort() as CampaignStatusTag[],
[campaigns],
);
const visible =
filter === 'All'
? campaigns
: campaigns.filter((c) => c.campaign.status.tag === filter);

return (
<section className={sectionClass}>
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-label text-leaf-700">Marketplace</p>
<h1 className="mt-1 text-soil-950">Campaigns</h1>
<p className="mt-2 max-w-2xl text-body text-soil-600">
Browse live on-chain agricultural funding campaigns and back the
ones you believe in.
</p>
</div>
<Link to="/campaigns/new" className={primaryLinkClass}>
Create a campaign
</Link>
</div>

{!isEscrowConfigured() ? (
<div className={cardClass}>
<h2 className="text-h3 text-soil-950">Soroban RPC not configured</h2>
<p className="mt-2 text-body-sm text-soil-500">
Set <code className="font-mono">VITE_SOROBAN_RPC_URL</code> and{' '}
<code className="font-mono">
VITE_PRODUCTION_ESCROW_CONTRACT_ID
</code>{' '}
to load campaigns from the network.
</p>
</div>
) : isLoading ? (
<CampaignCardsSkeleton count={6} />
) : isError ? (
<div className={cardClass}>
<h2 className="text-h4 text-soil-900">
Couldn&apos;t load campaigns
</h2>
<p className="mt-2 text-body-sm text-status-failed-dark">
The Soroban RPC request failed. Check your connection and try again.
</p>
<button
type="button"
onClick={() => void refetch()}
className={`${primaryLinkClass} mt-4`}
>
Retry
</button>
</div>
) : campaigns.length === 0 ? (
<div className={cardClass}>
<h2 className="text-h4 text-soil-900">No campaigns yet</h2>
<p className="mt-2 text-body-sm text-soil-500">
No campaigns have been created on this contract yet. Be the first to
launch one.
</p>
<Link to="/campaigns/new" className={`${primaryLinkClass} mt-4`}>
Create a campaign
</Link>
</div>
) : (
<>
{availableStatuses.length > 1 && (
<div
className="mb-6 flex flex-wrap gap-2"
role="group"
aria-label="Filter campaigns by status"
>
{(['All', ...availableStatuses] as const).map((status) => {
const active = filter === status;
const label =
status === 'All' ? 'All' : STATUS_META[status].label;
return (
<button
key={status}
type="button"
aria-pressed={active}
onClick={() => setFilter(status)}
className={[
'rounded-full border px-3 py-1 text-body-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500',
active
? 'border-leaf-700 bg-leaf-700 text-white'
: 'border-soil-200 bg-white text-soil-600 hover:bg-soil-100',
].join(' ')}
>
{label}
</button>
);
})}
</div>
)}

{visible.length === 0 ? (
<div className={cardClass}>
<p className="text-body-sm text-soil-500">
No campaigns match the selected filter.
</p>
</div>
) : (
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((overview) => (
<li key={overview.id} className="flex">
<CampaignCard {...overview} />
</li>
))}
</ul>
)}
</>
)}
</section>
);
}

export default CampaignsPage;
Loading
Loading