diff --git a/client/src/App.tsx b/client/src/App.tsx index 80cd042..f828126 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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'; @@ -53,6 +54,7 @@ export default function App() { {/* AppLayout routes */} }> } /> + } /> } /> } /> } /> diff --git a/client/src/__tests__/CampaignsPage.test.tsx b/client/src/__tests__/CampaignsPage.test.tsx new file mode 100644 index 0000000..2aa9893 --- /dev/null +++ b/client/src/__tests__/CampaignsPage.test.tsx @@ -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 { + 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) { + mockUseAllCampaigns.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + ...partial, + } as ReturnType); +} + +function renderPage() { + return render( + + + , + ); +} + +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(); + }); +}); diff --git a/client/src/hooks/contract/queryKeys.ts b/client/src/hooks/contract/queryKeys.ts index 2792d19..2e44175 100644 --- a/client/src/hooks/contract/queryKeys.ts +++ b/client/src/hooks/contract/queryKeys.ts @@ -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, }; diff --git a/client/src/hooks/useAllCampaigns.ts b/client/src/hooks/useAllCampaigns.ts new file mode 100644 index 0000000..dd2f4e1 --- /dev/null +++ b/client/src/hooks/useAllCampaigns.ts @@ -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 => { + 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 => { + try { + const tx = await contractMethod( + 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)); + }, + }); +} diff --git a/client/src/pages/CampaignsPage.tsx b/client/src/pages/CampaignsPage.tsx new file mode 100644 index 0000000..64b13c4 --- /dev/null +++ b/client/src/pages/CampaignsPage.tsx @@ -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 ( + +
+ + #{id} +
+ +

+ {title} +

+

+ Farmer: {campaign.farmer} +

+ +
+
+ + ${raised.toLocaleString()}{' '} + raised + + + ${target.toLocaleString()} ({pct}%) + +
+
+
+
+
+ + ); +} + +export function CampaignsPage() { + const { data, isLoading, isError, refetch } = useAllCampaigns(); + const [filter, setFilter] = useState('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 ( +
+
+
+

Marketplace

+

Campaigns

+

+ Browse live on-chain agricultural funding campaigns and back the + ones you believe in. +

+
+ + Create a campaign + +
+ + {!isEscrowConfigured() ? ( +
+

Soroban RPC not configured

+

+ Set VITE_SOROBAN_RPC_URL and{' '} + + VITE_PRODUCTION_ESCROW_CONTRACT_ID + {' '} + to load campaigns from the network. +

+
+ ) : isLoading ? ( + + ) : isError ? ( +
+

+ Couldn't load campaigns +

+

+ The Soroban RPC request failed. Check your connection and try again. +

+ +
+ ) : campaigns.length === 0 ? ( +
+

No campaigns yet

+

+ No campaigns have been created on this contract yet. Be the first to + launch one. +

+ + Create a campaign + +
+ ) : ( + <> + {availableStatuses.length > 1 && ( +
+ {(['All', ...availableStatuses] as const).map((status) => { + const active = filter === status; + const label = + status === 'All' ? 'All' : STATUS_META[status].label; + return ( + + ); + })} +
+ )} + + {visible.length === 0 ? ( +
+

+ No campaigns match the selected filter. +

+
+ ) : ( +
    + {visible.map((overview) => ( +
  • + +
  • + ))} +
+ )} + + )} +
+ ); +} + +export default CampaignsPage; diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index d3a74ec..a64c373 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router-dom'; export { CampaignDetailPage } from './CampaignDetailPage'; +export { CampaignsPage } from './CampaignsPage'; export { CreateCampaignPage } from './CreateCampaignPage'; export { AdminDashboardPage } from './AdminDashboardPage'; export { ActivityFeedPage } from './ActivityFeedPage'; @@ -50,21 +51,6 @@ export function HomePage() { ); } -export function CampaignsPage() { - return ( - - Create a campaign - - } - /> - ); -} - export function FarmerDashboardPage() { return (