Skip to content
Open
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
10 changes: 8 additions & 2 deletions client/src/components/admin/CampaignAdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isValidContractSymbol,
} from '../../lib/soroban/symbol';
import type { AdminCampaignOverview } from '../../hooks/useAdminCampaigns';
import { presentationalCampaignStatus } from '../../lib/campaignStatus';
import type { DisputeResolutionTag } from '../../lib/soroban/types';

const cardClass =
Expand Down Expand Up @@ -583,6 +584,11 @@ export function CampaignAdminPanel({
const { id, campaign } = overview;
const status = campaign.status.tag;
const held = escrowHeld(campaign);
const productionProgress = { releasedAmount: campaign.released };
const displayStatus = presentationalCampaignStatus(
status,
productionProgress,
);

return (
<div className={cardClass}>
Expand All @@ -593,11 +599,11 @@ export function CampaignAdminPanel({
Farmer: {campaign.farmer}
</p>
</div>
<StatusBadge status={status} />
<StatusBadge status={displayStatus} />
</div>

<div className="mt-4">
<LifecycleStepper status={status} />
<LifecycleStepper status={status} releasedAmount={campaign.released} />
</div>

<dl className="mt-4 grid grid-cols-2 gap-3 text-body-sm sm:grid-cols-4">
Expand Down
20 changes: 17 additions & 3 deletions client/src/components/campaign/LifecycleStepper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { LIFECYCLE_STEPS, lifecycleStepIndex } from '../../lib/campaignStatus';
import {
LIFECYCLE_STEPS,
lifecycleStepIndex,
type ProductionProgress,
} from '../../lib/campaignStatus';
import type { CampaignStatusTag } from '../../lib/soroban/types';

const DERAILED_STATUSES: CampaignStatusTag[] = [
Expand All @@ -7,8 +11,18 @@ const DERAILED_STATUSES: CampaignStatusTag[] = [
'Failed',
];

export function LifecycleStepper({ status }: { status: CampaignStatusTag }) {
const currentIndex = lifecycleStepIndex(status);
export function LifecycleStepper({
status,
tranches,
releasedAmount,
}: {
status: CampaignStatusTag;
/** Tranche records from `get_tranches` — used to derive in-production. */
tranches?: ProductionProgress['tranches'];
/** `Campaign.released` — used to derive in-production when tranches aren't loaded. */
releasedAmount?: ProductionProgress['releasedAmount'];
}) {
const currentIndex = lifecycleStepIndex(status, { tranches, releasedAmount });
const derailed = DERAILED_STATUSES.includes(status);

return (
Expand Down
48 changes: 48 additions & 0 deletions client/src/components/campaign/__tests__/LifecycleStepper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LifecycleStepper } from '../LifecycleStepper';

function currentStepText(): string {
const current = document.querySelector('[aria-current="step"]');
expect(current).not.toBeNull();
return current!.parentElement?.textContent ?? '';
}

describe('LifecycleStepper', () => {
it('keeps a Funded campaign with no released tranches on the Funded step', () => {
render(
<LifecycleStepper
status="Funded"
tranches={[{ released: false }]}
releasedAmount={0n}
/>,
);

expect(currentStepText()).toMatch(/Funded/);
expect(currentStepText()).not.toMatch(/In Production/);
expect(screen.getByText('In Production')).toBeInTheDocument();
});

it('advances a Funded campaign with a released tranche to In Production', () => {
render(
<LifecycleStepper
status="Funded"
tranches={[{ released: true }, { released: false }]}
/>,
);

expect(currentStepText()).toMatch(/In Production/);
});

it('advances a Funded campaign with releasedAmount > 0 to In Production', () => {
render(<LifecycleStepper status="Funded" releasedAmount={250n} />);

expect(currentStepText()).toMatch(/In Production/);
});

it('uses the on-chain InProduction tag for the production step', () => {
render(<LifecycleStepper status="InProduction" />);

expect(currentStepText()).toMatch(/In Production/);
});
});
13 changes: 3 additions & 10 deletions client/src/hooks/useAdminCampaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
isEscrowConfigured,
} from '../lib/soroban/config';
import { contractQueryKeys } from './contract/queryKeys';
import type { Campaign, CampaignStatusTag } from '../lib/soroban/types';
import { ACTIONABLE_STATUSES } from '../lib/campaignStatus';
import type { Campaign } from '../lib/soroban/types';

const DEFAULT_LOOKBACK_LEDGERS = 120_000;

Expand All @@ -18,15 +19,7 @@ const LOOKBACK_LEDGERS = (() => {
: DEFAULT_LOOKBACK_LEDGERS;
})();

/** Statuses where at least one of the five admin actions is applicable. */
const ACTIONABLE_STATUSES: CampaignStatusTag[] = [
'Active',
'Funding',
'Funded',
'InProduction',
'Harvested',
'Disputed',
];
export { ACTIONABLE_STATUSES };

export interface AdminCampaignOverview {
id: string;
Expand Down
124 changes: 124 additions & 0 deletions client/src/lib/__tests__/campaignStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
ACTIONABLE_STATUSES,
isDerivedInProduction,
lifecycleStepIndex,
LIFECYCLE_STEPS,
presentationalCampaignStatus,
} from '../campaignStatus';
import { ON_CHAIN_CAMPAIGN_STATUS_TAGS } from '../soroban/types';
import type { CampaignStatusTag } from '../soroban/types';

const TYPES_RS = resolve(
dirname(fileURLToPath(import.meta.url)),
'../../../../contracts/production_escrow/src/types.rs',
);

function parseRustEnumVariants(source: string, enumName: string): string[] {
const match = source.match(
new RegExp(`pub enum ${enumName}\\s*\\{([^}]+)\\}`),
);
if (!match) {
throw new Error(`pub enum ${enumName} not found in ${TYPES_RS}`);
}
return [...match[1].matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*,?/gm)].map(
(m) => m[1],
);
}

describe('CampaignStatusTag vs production_escrow CampaignStatus', () => {
it('stays in lockstep with contracts/production_escrow/src/types.rs', () => {
const rustVariants = parseRustEnumVariants(
readFileSync(TYPES_RS, 'utf8'),
'CampaignStatus',
);

expect(rustVariants).toEqual([...ON_CHAIN_CAMPAIGN_STATUS_TAGS]);
});

it('only lists on-chain tags in ACTIONABLE_STATUSES', () => {
const onChain = new Set<string>(ON_CHAIN_CAMPAIGN_STATUS_TAGS);
for (const status of ACTIONABLE_STATUSES) {
expect(onChain.has(status)).toBe(true);
}
});
});

describe('derived in-production (Funded + released tranche)', () => {
it('is not derived from Funded alone', () => {
expect(isDerivedInProduction('Funded')).toBe(false);
expect(
isDerivedInProduction('Funded', {
tranches: [{ released: false }, { released: false }],
releasedAmount: 0n,
}),
).toBe(false);
expect(presentationalCampaignStatus('Funded')).toBe('Funded');
});

it('derives from Funded when some tranche is released', () => {
const progress = {
tranches: [{ released: false }, { released: true }],
};
expect(isDerivedInProduction('Funded', progress)).toBe(true);
expect(presentationalCampaignStatus('Funded', progress)).toBe(
'InProduction',
);
});

it('derives from Funded when campaign.released is greater than zero', () => {
expect(isDerivedInProduction('Funded', { releasedAmount: 500n })).toBe(
true,
);
expect(presentationalCampaignStatus('Funded', { releasedAmount: 1 })).toBe(
'InProduction',
);
});

it('does not override a later on-chain status', () => {
const later: CampaignStatusTag[] = [
'Harvested',
'Disputed',
'Resolved',
'Settled',
'Failed',
];
for (const status of later) {
expect(
isDerivedInProduction(status, {
tranches: [{ released: true }],
releasedAmount: 1n,
}),
).toBe(false);
expect(
presentationalCampaignStatus(status, {
tranches: [{ released: true }],
}),
).toBe(status);
}
});

it('advances the lifecycle stepper to In Production for Funded + released tranche', () => {
const productionIndex = LIFECYCLE_STEPS.findIndex(
(step) => step.key === 'production',
);
const fundedIndex = LIFECYCLE_STEPS.findIndex(
(step) => step.key === 'funded',
);

expect(lifecycleStepIndex('Funded')).toBe(fundedIndex);
expect(
lifecycleStepIndex('Funded', {
tranches: [{ released: true }],
}),
).toBe(productionIndex);
expect(lifecycleStepIndex('Funded', { releasedAmount: 100n })).toBe(
productionIndex,
);
expect(lifecycleStepIndex('InProduction')).toBe(productionIndex);
});
});
85 changes: 81 additions & 4 deletions client/src/lib/campaignStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ export interface StatusMeta {
border: string;
}

/**
* Signals used to derive the UI-only "in production" presentation from a
* still-`Funded` campaign. Not an on-chain status — see
* {@link isDerivedInProduction}.
*/
export interface ProductionProgress {
/** Tranche records from `get_tranches`. */
tranches?: ReadonlyArray<{ released: boolean }>;
/** `Campaign.released` from `get_campaign` (contract units). */
releasedAmount?: bigint | number;
}

/** Tailwind class sets per CampaignStatus, matching the palette in tailwind.config.ts. */
export const STATUS_META: Record<CampaignStatusTag, StatusMeta> = {
Active: {
Expand Down Expand Up @@ -75,11 +87,63 @@ export const STATUS_META: Record<CampaignStatusTag, StatusMeta> = {
},
};

/**
* Statuses where at least one of the five admin actions is applicable.
* Every entry is an on-chain `CampaignStatusTag` — never a UI-only derived
* label. `InProduction` is included because `release_tranche` writes that
* variant on-chain (see `contracts/production_escrow/src/lib.rs`).
*/
export const ACTIONABLE_STATUSES: readonly CampaignStatusTag[] = [
'Active',
'Funding',
'Funded',
'InProduction',
'Harvested',
'Disputed',
];

/**
* True when a campaign is still tagged `Funded` on-chain but production has
* already started — at least one tranche is released, or `released` on the
* campaign is > 0. This is a presentational state, not a chain tag.
*
* On-chain `InProduction` is handled separately (it is a real contract
* variant); this helper only covers the Funded + progress case so the
* stepper is not stuck on "Funded" after the first release.
*/
export function isDerivedInProduction(
status: CampaignStatusTag,
progress?: ProductionProgress,
): boolean {
if (status !== 'Funded') return false;
if (progress?.tranches?.some((tranche) => tranche.released)) return true;
const released = progress?.releasedAmount;
if (released === undefined) return false;
return typeof released === 'bigint' ? released > 0n : released > 0;
}

/**
* Status to show in badges. Maps Funded + released-tranche progress onto
* the existing on-chain `InProduction` presentation without inventing a
* new tag in `CampaignStatusTag`.
*/
export function presentationalCampaignStatus(
status: CampaignStatusTag,
progress?: ProductionProgress,
): CampaignStatusTag {
return isDerivedInProduction(status, progress) ? 'InProduction' : status;
}

/**
* The "happy path" lifecycle steps shown in the campaign stepper.
* `statuses` lists every CampaignStatus that maps onto that step — Active and
* Funding both represent the funding phase (a campaign starts Active and
* flips to Funding on its first contribution).
* `statuses` lists every *on-chain* CampaignStatus that maps onto that step
* — Active and Funding both represent the funding phase (a campaign starts
* Active and flips to Funding on its first contribution).
*
* The production step is reached by the on-chain `InProduction` tag *or* by
* a Funded campaign with released-tranche progress (see
* {@link isDerivedInProduction}); Funded is intentionally not listed here
* so an unreleased Funded campaign stays on the Funded step.
*/
export const LIFECYCLE_STEPS: {
key: string;
Expand All @@ -93,11 +157,24 @@ export const LIFECYCLE_STEPS: {
{ key: 'settled', label: 'Settled', statuses: ['Settled'] },
];

const PRODUCTION_STEP_INDEX = LIFECYCLE_STEPS.findIndex(
(step) => step.key === 'production',
);

/**
* Index into LIFECYCLE_STEPS the campaign is at (or most recently passed
* through) for statuses that branch off the happy path.
*
* Pass `progress` so a Funded campaign that has already released a tranche
* advances to the In Production step instead of remaining on Funded.
*/
export function lifecycleStepIndex(status: CampaignStatusTag): number {
export function lifecycleStepIndex(
status: CampaignStatusTag,
progress?: ProductionProgress,
): number {
if (PRODUCTION_STEP_INDEX !== -1 && isDerivedInProduction(status, progress)) {
return PRODUCTION_STEP_INDEX;
}
const direct = LIFECYCLE_STEPS.findIndex((step) =>
step.statuses.includes(status),
);
Expand Down
Loading