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: 10 additions & 0 deletions frontend/src/components/programme/ProgrammeHeader.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@
color: var(--text);
}

/* The creation date sits beside the ledger. It may wrap independently on a
narrow screen, so the ledger number stays readable at 360px. */
.programme-header__created {
margin-left: var(--space-1);
}

.programme-header__created time {
font-variant-numeric: tabular-nums;
}

.programme-header__read {
display: inline-flex;
align-items: center;
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/programme/ProgrammeHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

import { formatDate } from '../../lib/format';
import { ProgrammeHeader } from './ProgrammeHeader';

const baseProps = {
Expand Down Expand Up @@ -45,6 +46,45 @@ describe('ProgrammeHeader', () => {
expect(within(badges).getByText('Sample data')).toBeTruthy();
});

it('shows an exact creation date beside the ledger when the RPC retains it', () => {
const seconds = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
renderHeader({
...baseProps,
phase: 'Open',
createdAt: { seconds, estimated: false },
});

const date = screen.getByText(formatDate(seconds));
const time = date.closest('time');
expect(time?.getAttribute('datetime')).toBe(new Date(seconds * 1000).toISOString());
expect(time?.getAttribute('title')).toMatch(/read from the ledger/i);
expect(time?.parentElement?.textContent).not.toContain('about');

// The ledger number stays visible alongside the date.
expect(screen.getByText(/4,811,204/)).toBeTruthy();
});

it('marks an estimated creation date as "about" beside the ledger', () => {
const seconds = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
renderHeader({
...baseProps,
phase: 'Open',
createdAt: { seconds, estimated: true },
});

const time = screen.getByText(formatDate(seconds)).closest('time');
expect(time?.parentElement?.textContent).toContain('about');
expect(time?.getAttribute('title')).toMatch(/estimated from the latest ledger/i);
expect(screen.getByText(/4,811,204/)).toBeTruthy();
});

it('shows only the ledger when no creation date can be resolved', () => {
renderHeader({ ...baseProps, phase: 'Open', createdAt: null });

expect(screen.getByText(/4,811,204/)).toBeTruthy();
expect(document.querySelector('time')).toBeNull();
});

it('returns to the directory the reader came from, filters included', () => {
renderHeader({ ...baseProps, phase: 'Open', backTo: '/directory?q=flood&phase=Open' });

Expand Down
27 changes: 27 additions & 0 deletions frontend/src/components/programme/ProgrammeHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Link } from 'react-router-dom';
import { formatDate } from '../../lib/format';
import type { LedgerTime } from '../../lib/ledgerTime';
import { AddressChip, ModePill } from '../ui';
import './phasePill.css';
import './ProgrammeHeader.css';
Expand Down Expand Up @@ -42,6 +44,7 @@ export function ProgrammeHeader({
name,
creator,
createdLedger,
createdAt = null,
phase,
phaseError = false,
verified,
Expand All @@ -53,6 +56,12 @@ export function ProgrammeHeader({
name: string;
creator: string | null;
createdLedger: number | null;
/**
* When the creation ledger closed. `estimated` is true when the RPC no
* longer retained the ledger and the date was counted back from the latest
* one, so the header can say "about".
*/
createdAt?: LedgerTime | null;
/** The on-chain phase, or null while it has not been read. */
phase: string | null;
phaseError?: boolean;
Expand Down Expand Up @@ -94,6 +103,24 @@ export function ProgrammeHeader({
</span>
<span className="programme-header__meta-item">
Ledger <span className="numeric">{createdLedger?.toLocaleString() ?? '—'}</span>
{createdAt && (
<span className="programme-header__created">
{createdAt.estimated ? 'about ' : ''}
<time
dateTime={new Date(createdAt.seconds * 1000).toISOString()}
title={
createdAt.estimated
? 'Estimated from the latest ledger close time'
: 'Read from the ledger close time'
}
>
{formatDate(createdAt.seconds)}
</time>
{createdAt.estimated && (
<span className="visually-hidden"> estimated from the latest ledger</span>
)}
</span>
)}
</span>
<span className="programme-header__read">
<span className="programme-header__read-dot" aria-hidden="true" />
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/programme/ProgrammePanels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,34 @@ describe('Issue #267 - TermsTab component', () => {
);
expect(screen.getByText('16 votes (max 16)')).toBeDefined();
});

it('shows the programme minimum award when one is set', () => {
render(
<MemoryRouter>
<TermsTab
programmeId="prog-xyz"
asset="XLM"
minimumAward={2_000_0000000n}
/>
</MemoryRouter>,
);

expect(screen.getByText('Minimum award')).toBeDefined();
expect(screen.getByText('2,000.00 XLM')).toBeDefined();
expect(screen.getByText(/BelowMinimumAward, error 38/)).toBeDefined();
expect(screen.getByText(/median of reviewer votes/i)).toBeDefined();
});

it('shows no minimum row when the programme has no minimum', () => {
render(
<MemoryRouter>
<TermsTab programmeId="prog-xyz" minimumAward={0n} />
</MemoryRouter>,
);

expect(screen.queryByText('Minimum award')).toBeNull();
expect(screen.queryByText(/BelowMinimumAward/)).toBeNull();
});
});

describe('ProgrammeTabs wrapper component', () => {
Expand All @@ -327,6 +355,7 @@ describe('ProgrammeTabs wrapper component', () => {
phase="Settled"
quorum={3}
asset="USDC"
minimumAward={1_000_0000000n}
isSample
/>
</MemoryRouter>,
Expand All @@ -347,5 +376,6 @@ describe('ProgrammeTabs wrapper component', () => {
fireEvent.click(termsBtn);
expect(termsBtn.getAttribute('aria-selected')).toBe('true');
expect(screen.getByText('Chosen per award')).toBeDefined();
expect(screen.getByText('1,000.00 USDC')).toBeDefined();
});
});
4 changes: 4 additions & 0 deletions frontend/src/components/programme/ProgrammeTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ProgrammeTabsProps {
programmeId: string;
phase?: string;
quorum?: number;
/** The programme's minimum award in stroops; 0n means none. */
minimumAward?: bigint;
isSample?: boolean;
asset?: string;
verifiers?: VerifierFixture[];
Expand All @@ -21,6 +23,7 @@ export const ProgrammeTabs: FC<ProgrammeTabsProps> = ({
programmeId,
phase = 'Open',
quorum = 1,
minimumAward = 0n,
isSample = false,
asset = 'USDC',
verifiers,
Expand Down Expand Up @@ -123,6 +126,7 @@ export const ProgrammeTabs: FC<ProgrammeTabsProps> = ({
programmeId={programmeId}
quorum={quorum}
asset={asset}
minimumAward={minimumAward}
/>
)}
</div>
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/components/programme/TermsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { formatAmount } from '../../lib/amount';
import './TermsTab.css';

/** The four payment modes, as `Mode` documents them in the program contract. */
Expand All @@ -26,17 +27,33 @@ export interface TermsTabProps {
programmeId: string;
quorum?: number;
asset?: string;
/**
* The programme's minimum award in stroops. `0n` means no minimum, so the
* row is omitted rather than pretending zero is a floor.
*/
minimumAward?: bigint;
}

export const TermsTab: FC<TermsTabProps> = ({
programmeId,
quorum = 1,
asset = 'USDC',
minimumAward = 0n,
}) => {
const cappedQuorum = Math.min(Math.max(1, quorum), 16);

const terms = [
const terms: Array<{ k: string; v: string; explanation?: string }> = [
{ k: 'Asset', v: asset },
...(minimumAward > 0n
? [
{
k: 'Minimum award',
v: formatAmount(minimumAward, { asset }),
explanation:
"The contract's finalize refuses any award below this amount (BelowMinimumAward, error 38). An award is the median of reviewer votes, so if the median lands below the minimum the application cannot be finalized.",
},
]
: []),
{
k: 'Payment mode',
v: 'Chosen per award',
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/recipient/ApplyForm.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,31 @@
min-height: 18px;
}

/* The programme's floor, stated before an amount is typed so it is not a
surprise only after the request comes in low. */
.apply-form__minimum {
font-size: var(--text-caption);
line-height: var(--leading-body);
color: var(--text-muted);
}

.apply-form__minimum strong {
color: var(--text);
font-weight: var(--weight-semibold);
}

/* A warning, not an error: submitting stays allowed because the median of
reviewer votes may still land above the programme's minimum. */
.apply-form__warning {
font-size: var(--text-caption);
line-height: var(--leading-body);
color: var(--warning-strong);
}

.apply-form__warning strong {
font-weight: var(--weight-bold);
}

.apply-form__submit-btn {
align-self: flex-start;
cursor: pointer;
Expand Down
Loading