diff --git a/frontend/src/components/programme/ProgrammeHeader.css b/frontend/src/components/programme/ProgrammeHeader.css index 9f25cd4..85dc4e0 100644 --- a/frontend/src/components/programme/ProgrammeHeader.css +++ b/frontend/src/components/programme/ProgrammeHeader.css @@ -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; diff --git a/frontend/src/components/programme/ProgrammeHeader.test.tsx b/frontend/src/components/programme/ProgrammeHeader.test.tsx index cd75ff8..37bd27b 100644 --- a/frontend/src/components/programme/ProgrammeHeader.test.tsx +++ b/frontend/src/components/programme/ProgrammeHeader.test.tsx @@ -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 = { @@ -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' }); diff --git a/frontend/src/components/programme/ProgrammeHeader.tsx b/frontend/src/components/programme/ProgrammeHeader.tsx index 1781155..2336dc3 100644 --- a/frontend/src/components/programme/ProgrammeHeader.tsx +++ b/frontend/src/components/programme/ProgrammeHeader.tsx @@ -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'; @@ -42,6 +44,7 @@ export function ProgrammeHeader({ name, creator, createdLedger, + createdAt = null, phase, phaseError = false, verified, @@ -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; @@ -94,6 +103,24 @@ export function ProgrammeHeader({ Ledger {createdLedger?.toLocaleString() ?? '—'} + {createdAt && ( + + {createdAt.estimated ? 'about ' : ''} + + {createdAt.estimated && ( + estimated from the latest ledger + )} + + )} + {minimumAward > 0n && ( + + Minimum award:{' '} + + {formatAmount(minimumAward, { asset: 'USDC' })} + + . + + )} + {belowMinimum && ( + + This is below the programme’s minimum award of{' '} + {formatAmount(minimumAward, { asset: 'USDC' })}. + Your award is the median of reviewer votes, so a median below + the minimum cannot be finalized. You can still submit — votes + may land above it. + + )} {amountError} diff --git a/frontend/src/components/recipient/RecipientFlows.test.tsx b/frontend/src/components/recipient/RecipientFlows.test.tsx index 5eeacac..b640304 100644 --- a/frontend/src/components/recipient/RecipientFlows.test.tsx +++ b/frontend/src/components/recipient/RecipientFlows.test.tsx @@ -116,6 +116,10 @@ function makeProgramme(opts: { })), apply: vi.fn(async () => ({ signAndSend: applySignAndSend })), _applySignAndSend: applySignAndSend, + // ApplyForm reads the programme minimum before submitting. No minimum here. + get_config: vi.fn(async () => ({ + result: { unwrap: () => ({ minimum_award: 0n }) }, + })), release: vi.fn(async () => ({ signAndSend: releaseSignAndSend })), get_award: vi.fn(async () => ({ result: { unwrap: () => freshAward }, diff --git a/frontend/src/components/ui/AddressChip.test.tsx b/frontend/src/components/ui/AddressChip.test.tsx new file mode 100644 index 0000000..9a41e4a --- /dev/null +++ b/frontend/src/components/ui/AddressChip.test.tsx @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AddressChip } from './AddressChip'; +import { truncateAddress } from '../../lib/format'; + +/** + * Addresses are how people check who they are paying. The chip must show a + * shortened form that is still distinguishable, hand assistive technology the + * full value, and link to the correct explorer page — account or contract, + * testnet or mainnet. A bug here shows someone the wrong address or sends them + * to the wrong account page. + */ + +const ACCOUNT = 'GAH3D4RM45ETE4W7VDRCWZBPRPT63CJXAGXFYVBC2FGANBZTS4OTKXCA'; +const CONTRACT = 'CD236SGR4CHW3N5WA5REW7CDLCS4ZLDEX6JVEAIHZK7NSN4W7WD7YDAL'; + +describe('AddressChip', () => { + it('shows the shortened form and exposes the full address to assistive tech', () => { + render(); + + const shortened = screen.getByText(truncateAddress(ACCOUNT)); + expect(shortened.getAttribute('aria-hidden')).toBe('true'); + + const full = screen.getByText(ACCOUNT); + expect(full.className).toContain('visually-hidden'); + + // The title remains the full value for a mouse hover, too. + expect(screen.getByTitle(ACCOUNT)).toBeTruthy(); + }); + + it('honours custom lead and tail lengths', () => { + render(); + + expect(screen.getByText(truncateAddress(CONTRACT, 4, 3))).toBeTruthy(); + expect(truncateAddress(CONTRACT, 4, 3)).not.toBe(truncateAddress(CONTRACT)); + }); + + it('leaves a value shorter than the truncation untouched', () => { + render(); + + // Both the visible code and the assistive-tech copy carry the same short + // value, so exactly two nodes hold it. + expect(screen.getAllByText('GABC')).toHaveLength(2); + }); + + it('labels the copy button with the full address and a sensible default', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Copy address' })).toBeTruthy(); + + rerender(); + expect(screen.getByRole('button', { name: 'Copy treasury address' })).toBeTruthy(); + }); + + it('renders the registry verdict only when one is supplied', () => { + const { rerender } = render(); + expect(screen.queryByText('Registry-deployed')).toBeNull(); + expect(screen.queryByText('Not registry-deployed')).toBeNull(); + + rerender(); + expect(screen.getByText('Registry-deployed')).toBeTruthy(); + + rerender(); + expect(screen.getByText('Not registry-deployed')).toBeTruthy(); + }); + + it('links an account to its testnet page', () => { + render(); + + const link = screen.getByRole('link', { name: 'View account on Stellar Expert' }); + expect(link.getAttribute('href')).toBe( + `https://stellar.expert/explorer/testnet/account/${ACCOUNT}`, + ); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noreferrer'); + }); + + it('links a contract to its testnet page', () => { + render(); + + expect( + screen.getByRole('link', { name: 'View contract on Stellar Expert' }).getAttribute('href'), + ).toBe(`https://stellar.expert/explorer/testnet/contract/${CONTRACT}`); + }); + + it('links a contract to its mainnet page', () => { + render(); + + const link = screen.getByRole('link', { name: 'View contract on Stellar Expert' }); + expect(link.getAttribute('href')).toBe( + `https://stellar.expert/explorer/public/contract/${CONTRACT}`, + ); + }); + + it('treats a network string without "testnet" as mainnet', () => { + render(); + + expect( + screen.getByRole('link', { name: 'View account on Stellar Expert' }).getAttribute('href'), + ).toContain('/explorer/public/account/'); + }); + + it('renders no explorer link unless asked', () => { + render(); + + expect(screen.queryByRole('link')).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/AddressChip.tsx b/frontend/src/components/ui/AddressChip.tsx index 30eeb9e..3657861 100644 --- a/frontend/src/components/ui/AddressChip.tsx +++ b/frontend/src/components/ui/AddressChip.tsx @@ -46,7 +46,13 @@ export function AddressChip({ return ( - {display} + {/* The shortened form is what sighted users compare; assistive tech gets + the full value, because a truncated address is not enough to check + who you are paying. */} + + {address} {verification && ( {verified ?