diff --git a/frontend/src/__tests__/lib.amount.test.ts b/frontend/src/__tests__/lib.amount.test.ts deleted file mode 100644 index 063698bf..00000000 --- a/frontend/src/__tests__/lib.amount.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - formatAmount, - parseAmount, - formatRate, - hasValidPrecision, - truncateAmount, - formatCompactAmount, - toStroops, - fromStroops, -} from '../lib/amount'; - -describe('lib/amount.ts - formatAmount', () => { - it('converts raw i128 stroops to token units', () => { - expect(formatAmount(10000000n, 7)).toBe('1'); - expect(formatAmount(50000000n, 7)).toBe('5'); - expect(formatAmount(0n, 7)).toBe('0'); - }); - - it('handles fractional results', () => { - expect(formatAmount(5000000n, 7)).toBe('0.5'); - expect(formatAmount(1n, 7)).toBe('0.0000001'); - }); - - it('removes trailing zeros from fractional part', () => { - expect(formatAmount(10000000n, 7)).toBe('1'); // Not 1.0000000 - expect(formatAmount(15000000n, 7)).toBe('1.5'); // Not 1.5000000 - }); - - it('handles different decimal places', () => { - expect(formatAmount(1000000n, 6)).toBe('1'); - expect(formatAmount(1000n, 3)).toBe('1'); - expect(formatAmount(100n, 2)).toBe('1'); - }); - - it('handles large amounts', () => { - expect(formatAmount(1000000000000n, 7)).toBe('100000'); - }); -}); - -describe('lib/amount.ts - parseAmount', () => { - it('converts token units back to raw i128 bigint', () => { - expect(parseAmount('1', 7)).toBe(10000000n); - expect(parseAmount('5', 7)).toBe(50000000n); - expect(parseAmount('0', 7)).toBe(0n); - }); - - it('handles fractional inputs', () => { - expect(parseAmount('0.5', 7)).toBe(5000000n); - expect(parseAmount('0.0000001', 7)).toBe(1n); - }); - - it('truncates excess decimals', () => { - expect(parseAmount('1.123456789', 7)).toBe(11234567n); - }); - - it('handles different decimal places', () => { - expect(parseAmount('1', 6)).toBe(1000000n); - expect(parseAmount('1', 3)).toBe(1000n); - expect(parseAmount('1', 2)).toBe(100n); - }); - - it('returns 0 for empty or invalid input', () => { - expect(parseAmount('', 7)).toBe(0n); - expect(parseAmount(' ', 7)).toBe(0n); - }); -}); - -describe('lib/amount.ts - formatAmount/parseAmount round-trip', () => { - it('round-trips correctly with formatAmount', () => { - const original = 12345000n; - const formatted = formatAmount(original, 7); - expect(parseAmount(formatted, 7)).toBe(original); - }); - - it('round-trips various amounts', () => { - const testCases = [1n, 100n, 1000000n, 10000000n, 123456789n, 1000000000000n]; - testCases.forEach(amount => { - const formatted = formatAmount(amount, 7); - expect(parseAmount(formatted, 7)).toBe(amount); - }); - }); -}); - -describe('lib/amount.ts - formatRate', () => { - it('formats rate per second with per-day calculation', () => { - // 1 token/sec = 86400 tokens/day - expect(formatRate(10000000n, 7, 'XLM')).toBe('1 XLM/sec (86400 XLM/day)'); - }); - - it('handles fractional rates', () => { - // 0.5 token/sec = 43200 tokens/day - expect(formatRate(5000000n, 7, 'USDC')).toBe('0.5 USDC/sec (43200 USDC/day)'); - }); - - it('returns 0 format for zero rate', () => { - expect(formatRate(0n, 7)).toBe('0'); - }); - - it('works without symbol', () => { - expect(formatRate(10000000n, 7)).toBe('1/sec (86400/day)'); - }); - - it('handles very small rates', () => { - expect(formatRate(1n, 7, 'USDC')).toBe('0.0000001 USDC/sec (0.00864 USDC/day)'); - }); -}); - -describe('lib/amount.ts - hasValidPrecision', () => { - it('accepts whole numbers', () => { - expect(hasValidPrecision('100', 7)).toBe(true); - expect(hasValidPrecision('0', 7)).toBe(true); - }); - - it('accepts values within the decimal limit', () => { - expect(hasValidPrecision('1.234', 7)).toBe(true); - expect(hasValidPrecision('1.1234567', 7)).toBe(true); - }); - - it('rejects values exceeding the decimal limit', () => { - expect(hasValidPrecision('1.12345678', 7)).toBe(false); - }); - - it('respects a custom maxDecimals argument', () => { - expect(hasValidPrecision('1.12', 2)).toBe(true); - expect(hasValidPrecision('1.123', 2)).toBe(false); - }); - - it('returns true for empty strings', () => { - expect(hasValidPrecision('', 7)).toBe(true); - expect(hasValidPrecision(' ', 7)).toBe(true); - }); - - it('rejects invalid number formats', () => { - expect(hasValidPrecision('abc', 7)).toBe(false); - expect(hasValidPrecision('1.2.3', 7)).toBe(false); - }); -}); - -describe('lib/amount.ts - truncateAmount', () => { - it('truncates to specified decimal places without rounding', () => { - // 1.23456789 truncated to 4 decimals = 1.2345 - expect(truncateAmount(123456789n, 8, 4)).toBe('1.2345'); - }); - - it('removes trailing zeros after truncation', () => { - expect(truncateAmount(1200000n, 7, 4)).toBe('0.12'); - }); - - it('returns whole number when no fractional part', () => { - expect(truncateAmount(10000000n, 7, 4)).toBe('1'); - }); - - it('handles zero amount', () => { - expect(truncateAmount(0n, 7, 4)).toBe('0'); - }); - - it('truncates to 1 decimal place', () => { - expect(truncateAmount(123456789n, 8, 1)).toBe('1.2'); - }); -}); - -describe('lib/amount.ts - formatCompactAmount', () => { - it('displays whole numbers as-is', () => { - expect(formatCompactAmount(100n, 0)).toBe('100'); - expect(formatCompactAmount(999n, 0)).toBe('999'); - }); - - it('formats thousands with K', () => { - expect(formatCompactAmount(1500n, 0)).toBe('1.5K'); - expect(formatCompactAmount(1000n, 0)).toBe('1.0K'); - }); - - it('formats millions with M', () => { - expect(formatCompactAmount(1500000n, 0)).toBe('1.5M'); - expect(formatCompactAmount(1000000n, 0)).toBe('1.0M'); - }); - - it('formats billions with B', () => { - expect(formatCompactAmount(1500000000n, 0)).toBe('1.5B'); - }); - - it('respects token decimals', () => { - // 1000 XLM (1000 * 10^7) - expect(formatCompactAmount(10000000000n, 7)).toBe('1.0K'); - }); - - it('returns 0 for zero amount', () => { - expect(formatCompactAmount(0n, 7)).toBe('0'); - }); -}); - -describe('lib/amount.ts - toStroops and fromStroops', () => { - it('toStroops converts XLM to stroops (7 decimal places)', () => { - expect(toStroops('1')).toBe(10000000n); - expect(toStroops('0.5')).toBe(5000000n); - }); - - it('fromStroops converts stroops to XLM', () => { - expect(fromStroops(10000000n)).toBe('1'); - expect(fromStroops(5000000n)).toBe('0.5'); - }); - - it('toStroops and fromStroops round-trip', () => { - const xlm = '123.4567890'; - const stroops = toStroops(xlm); - const restored = fromStroops(stroops); - expect(restored).toBe('123.456789'); - }); -}); - -describe('lib/amount.ts - Regression tests for wizard validation', () => { - it('hasValidPrecision rejects amounts with too many decimals for 7-decimal token', () => { - expect(hasValidPrecision('0.12345678', 7)).toBe(false); - expect(hasValidPrecision('100.99999999', 7)).toBe(false); - }); - - it('validates amount input as wizard uses it', () => { - // Simulating the wizard validation flow - const userInput = '1000.5'; - const decimals = 7; - - expect(hasValidPrecision(userInput, decimals)).toBe(true); - const parsed = parseAmount(userInput, decimals); - const formatted = formatAmount(parsed, decimals); - expect(formatted).toBe(userInput); - }); - - it('formatRate provides correct daily/second breakdown for stream amounts', () => { - // Test case: 100 USDC over 30 days - const totalAmount = parseAmount('100', 7); // 100 * 10^7 - const totalSeconds = 30 * 24 * 3600; // 30 days in seconds - const ratePerSecond = totalAmount / BigInt(totalSeconds); - - const formatted = formatRate(ratePerSecond, 7, 'USDC'); - expect(formatted).toContain('USDC/sec'); - expect(formatted).toContain('USDC/day'); - }); -}); diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 599da2ed..672133d7 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -8,6 +8,10 @@ import { formatRate, hasValidPrecision, validateAmountInput, + truncateAmount, + formatCompactAmount, + toStroops, + fromStroops, getDefaultTokenDecimals, setCachedTokenDecimals, getCachedTokenDecimals, @@ -95,9 +99,18 @@ describe('parseAmount', () => { it('returns 0 for empty or invalid input', () => { expect(parseAmount('', 7)).toBe(0n); + expect(parseAmount(' ', 7)).toBe(0n); expect(parseAmount('abc', 7)).toBe(0n); expect(parseAmount('1.2.3', 7)).toBe(0n); }); + + it('round-trips various amounts through formatAmount', () => { + const testCases = [1n, 100n, 1000000n, 10000000n, 123456789n, 1000000000000n]; + testCases.forEach(amount => { + const formatted = formatAmount(amount, 7); + expect(parseAmount(formatted, 7)).toBe(amount); + }); + }); }); describe('formatRate', () => { @@ -117,6 +130,100 @@ describe('formatRate', () => { it('works without symbol', () => { expect(formatRate(10000000n, 7)).toBe('1/sec (86400/day)'); + expect(formatRate(0n, 7)).toBe('0/sec'); + }); + + it('handles very small rates', () => { + expect(formatRate(1n, 7, 'USDC')).toBe('0.0000001 USDC/sec (0.00864 USDC/day)'); + }); + + it('provides a daily/second breakdown for stream amounts', () => { + // 100 USDC over 30 days + const totalAmount = parseAmount('100', 7); + const ratePerSecond = totalAmount / BigInt(30 * 24 * 3600); + + const formatted = formatRate(ratePerSecond, 7, 'USDC'); + expect(formatted).toContain('USDC/sec'); + expect(formatted).toContain('USDC/day'); + }); +}); + +describe('truncateAmount', () => { + it('truncates to the requested decimal places without rounding', () => { + // 1.23456789 truncated to 4 decimals = 1.2345 (not 1.2346) + expect(truncateAmount(123456789n, 8, 4)).toBe('1.2345'); + expect(truncateAmount(123456789n, 8, 1)).toBe('1.2'); + }); + + it('removes trailing zeros after truncation', () => { + expect(truncateAmount(1200000n, 7, 4)).toBe('0.12'); + }); + + it('returns a whole number when there is no fractional part', () => { + expect(truncateAmount(10000000n, 7, 4)).toBe('1'); + }); + + it('handles zero amount', () => { + expect(truncateAmount(0n, 7, 4)).toBe('0'); + }); +}); + +describe('formatCompactAmount', () => { + it('displays sub-thousand values as-is', () => { + expect(formatCompactAmount(100n, 0)).toBe('100'); + expect(formatCompactAmount(999n, 0)).toBe('999'); + }); + + it('formats thousands, millions and billions', () => { + expect(formatCompactAmount(1500n, 0)).toBe('1.5K'); + expect(formatCompactAmount(1000n, 0)).toBe('1.0K'); + expect(formatCompactAmount(1500000n, 0)).toBe('1.5M'); + expect(formatCompactAmount(1000000n, 0)).toBe('1.0M'); + expect(formatCompactAmount(1500000000n, 0)).toBe('1.5B'); + }); + + it('respects token decimals', () => { + // 1000 XLM (1000 * 10^7) + expect(formatCompactAmount(10000000000n, 7)).toBe('1.0K'); + }); + + it('returns 0 for zero amount', () => { + expect(formatCompactAmount(0n, 7)).toBe('0'); + }); +}); + +describe('toStroops / fromStroops', () => { + it('defaults to 7 decimals (XLM stroops) when decimals are omitted', () => { + expect(toStroops('1')).toBe(10000000n); + expect(toStroops('0.5')).toBe(5000000n); + expect(fromStroops(10000000n)).toBe('1'); + expect(fromStroops(5000000n)).toBe('0.5'); + }); + + it('still accepts an explicit decimals argument', () => { + expect(toStroops('1', 6)).toBe(1000000n); + expect(fromStroops(1000000n, 6)).toBe('1'); + }); + + it('round-trips, dropping the trailing zero', () => { + expect(fromStroops(toStroops('123.4567890'))).toBe('123.456789'); + }); +}); + +// Regression coverage for the create-stream wizard, which previously imported a +// second, parallel copy of these helpers from lib/amount.ts (issue #1124). +describe('amount helpers as the create-stream wizard uses them', () => { + it('rejects amounts with too many decimals for a 7-decimal token', () => { + expect(hasValidPrecision('0.12345678', 7)).toBe(false); + expect(hasValidPrecision('100.99999999', 7)).toBe(false); + }); + + it('parses and re-formats a user-entered amount losslessly', () => { + const userInput = '1000.5'; + const decimals = 7; + + expect(hasValidPrecision(userInput, decimals)).toBe(true); + expect(formatAmount(parseAmount(userInput, decimals), decimals)).toBe(userInput); }); }); diff --git a/frontend/src/app/activity/activity-content.tsx b/frontend/src/app/activity/activity-content.tsx index eec09471..2cd7492c 100644 --- a/frontend/src/app/activity/activity-content.tsx +++ b/frontend/src/app/activity/activity-content.tsx @@ -6,7 +6,7 @@ import { ActivityHistory } from "@/components/dashboard/ActivityHistory"; import { BackendStreamEvent } from "@/lib/api-types"; import { Button } from "@/components/ui/Button"; import { Loader2, Download } from "lucide-react"; -import { formatAmount } from "@/lib/amount"; +import { formatAmount } from "@/utils/amount"; import { downloadCSV } from "@/utils/csvExport"; import { getApiBaseUrl } from "@/lib/api/_shared"; import { logger } from "@/lib/logger"; diff --git a/frontend/src/components/HowItWorks.test.tsx b/frontend/src/components/HowItWorks.test.tsx new file mode 100644 index 00000000..b6027a91 --- /dev/null +++ b/frontend/src/components/HowItWorks.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HowItWorks } from './HowItWorks'; + +/** + * Locks the rendered output after the step copy was extracted into a typed + * `STEPS` array (issue #1123) — the extraction must be purely structural. + */ +describe('HowItWorks', () => { + it('renders each step number, title and description', () => { + render(); + + expect(screen.getByText('01')).toBeInTheDocument(); + expect(screen.getByText('Connect & Configure')).toBeInTheDocument(); + expect( + screen.getByText(/Link your treasury wallet and select the assets you want to stream/), + ).toBeInTheDocument(); + + expect(screen.getByText('02')).toBeInTheDocument(); + expect(screen.getByText('Define Parameters')).toBeInTheDocument(); + + expect(screen.getByText('03')).toBeInTheDocument(); + expect(screen.getByText('Stream is Live')).toBeInTheDocument(); + }); + + it('renders exactly three steps under the section anchor', () => { + const { container } = render(); + + expect(container.querySelector('#how-it-works')).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(3); + }); + + it('draws a connector after every step except the last', () => { + const { container } = render(); + + // Three step SVG connectors minus the trailing one. + expect(container.querySelectorAll('svg')).toHaveLength(2); + }); +}); diff --git a/frontend/src/components/HowItWorks.tsx b/frontend/src/components/HowItWorks.tsx index 0f4f80ab..e8a87092 100644 --- a/frontend/src/components/HowItWorks.tsx +++ b/frontend/src/components/HowItWorks.tsx @@ -1,6 +1,17 @@ +/** + * Marketing copy for the "how it works" section lives in the typed `STEPS` + * array below rather than inline in the JSX. Keeping the content separate from + * the layout means copy edits (or a future i18n pass, where each field becomes + * a translation key lookup) only touch the data array — never the component. + */ +interface HowItWorksStep { + /** Display-only ordinal shown next to the step title, e.g. "01". */ + number: string; + title: string; + description: string; +} - -const steps = [ +const STEPS: readonly HowItWorksStep[] = [ { number: '01', title: 'Connect & Configure', @@ -18,6 +29,14 @@ const steps = [ }, ]; +/** Section heading copy, extracted alongside `STEPS` for the same reason. */ +const SECTION_COPY = { + heading: 'Stream Management', + headingLineTwo: 'Made Simple', + subheading: + 'FlowFi abstracts away the complexity of smart contracts, providing a seamless dashboard for all your streaming needs.', +}; + export const HowItWorks = () => { return (
@@ -26,17 +45,17 @@ export const HowItWorks = () => {
-

Stream Management
Made Simple

+

{SECTION_COPY.heading}
{SECTION_COPY.headingLineTwo}

- FlowFi abstracts away the complexity of smart contracts, providing a seamless dashboard for all your streaming needs. + {SECTION_COPY.subheading}

- {steps.map((step, i) => ( -
+ {STEPS.map((step, i) => ( +
{step.number} @@ -46,7 +65,7 @@ export const HowItWorks = () => {

{step.description}

- {i < steps.length - 1 && ( + {i < STEPS.length - 1 && (
diff --git a/frontend/src/components/NotificationDropdown.test.tsx b/frontend/src/components/NotificationDropdown.test.tsx new file mode 100644 index 00000000..2a8f02b2 --- /dev/null +++ b/frontend/src/components/NotificationDropdown.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const pushMock = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock }), +})); + +const useStreamEventsMock = vi.fn(); +vi.mock('@/hooks/useStreamEvents', () => ({ + useStreamEvents: (...args: unknown[]) => useStreamEventsMock(...args), +})); + +// Imported after the mock registrations above. +import { NotificationDropdown } from './NotificationDropdown'; + +const PUBLIC_KEY = 'GDQERNIEDLE6SCKEAPO3ULKK5QQKFM3UIJMJQNBMKXPQR6HDYQTM2WO'; + +const mockStream = (overrides: { events?: unknown[]; connected?: boolean } = {}) => { + useStreamEventsMock.mockReturnValue({ + events: overrides.events ?? [], + connected: overrides.connected ?? true, + error: null, + reconnecting: false, + clearEvents: vi.fn(), + }); +}; + +const openDropdown = async () => { + await userEvent.click(screen.getByRole('button', { name: /notifications/i })); + return screen.getByRole('dialog', { name: 'Notifications' }); +}; + +describe('NotificationDropdown empty state', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('explains that there is nothing to show when the list is empty', async () => { + mockStream(); + render(); + + await openDropdown(); + + expect(screen.getByTestId('notifications-empty-state')).toBeInTheDocument(); + expect(screen.getByText('No notifications yet')).toBeInTheDocument(); + expect( + screen.getByText(/You're all caught up\. Stream activity will show up here\./), + ).toBeInTheDocument(); + }); + + it('does not leave the dropdown body blank', async () => { + mockStream(); + render(); + + const dialog = await openDropdown(); + + // The header and footer always render; the point of this assertion is + // that the scrollable list region itself carries explanatory copy. + expect(screen.getByTestId('notifications-empty-state').textContent?.trim()).not.toBe(''); + expect(dialog).toHaveTextContent('Notifications'); + expect(dialog).toHaveTextContent('View All Activity'); + }); + + it('disables the bell while the event stream is disconnected', () => { + mockStream({ connected: false }); + render(); + + expect(screen.getByRole('button', { name: /notifications/i })).toBeDisabled(); + }); + + it('renders notifications instead of the empty state once events arrive', async () => { + mockStream({ + events: [ + { + type: 'created', + data: { streamId: 7 }, + timestamp: 1_700_000_000_000, + }, + ], + }); + render(); + + await openDropdown(); + + expect(await screen.findByText('New stream #7 created')).toBeInTheDocument(); + expect(screen.queryByTestId('notifications-empty-state')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx index dca167cd..68178168 100644 --- a/frontend/src/components/NotificationDropdown.tsx +++ b/frontend/src/components/NotificationDropdown.tsx @@ -192,8 +192,33 @@ export const NotificationDropdown: React.FC = ({ publ ))}
) : ( -
- {connected ? "No new notifications" : "Connecting to live updates..."} +
+ + +

+ {connected ? "No notifications yet" : "Connecting to live updates..."} +

+

+ {connected + ? "You're all caught up. Stream activity will show up here." + : "We'll show your stream activity as soon as the connection is back."} +

)}
diff --git a/frontend/src/components/Stats.test.tsx b/frontend/src/components/Stats.test.tsx new file mode 100644 index 00000000..98193c86 --- /dev/null +++ b/frontend/src/components/Stats.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Stats, formatStatValue, STAT_FALLBACK } from './Stats'; + +/** + * Guards the marketing homepage against the stats endpoint returning zero, + * null or an error: none of those may surface as "NaN"/"undefined" text or + * collapse the three-column layout (issue #1125). + */ + +const renderedValues = (container: HTMLElement) => + Array.from(container.querySelectorAll('.tracking-tighter')).map((el) => el.textContent); + +const expectNoBrokenText = () => { + expect(document.body.textContent).not.toContain('NaN'); + expect(document.body.textContent).not.toContain('undefined'); + expect(document.body.textContent).not.toContain('null'); +}; + +describe('formatStatValue', () => { + it('falls back for null and undefined', () => { + expect(formatStatValue(null)).toBe(STAT_FALLBACK); + expect(formatStatValue(undefined)).toBe(STAT_FALLBACK); + }); + + it('falls back for NaN and non-finite numbers', () => { + expect(formatStatValue(Number.NaN)).toBe(STAT_FALLBACK); + expect(formatStatValue(Number.POSITIVE_INFINITY)).toBe(STAT_FALLBACK); + expect(formatStatValue(Number.NEGATIVE_INFINITY)).toBe(STAT_FALLBACK); + }); + + it('falls back for blank strings', () => { + expect(formatStatValue('')).toBe(STAT_FALLBACK); + expect(formatStatValue(' ')).toBe(STAT_FALLBACK); + }); + + it('renders zero as "0" rather than falling back', () => { + expect(formatStatValue(0)).toBe('0'); + }); + + it('formats larger numbers compactly', () => { + expect(formatStatValue(1500)).toBe('1.5K'); + expect(formatStatValue(2_400_000)).toBe('2.4M'); + }); + + it('passes strings through, trimmed', () => { + expect(formatStatValue('Stellar Testnet')).toBe('Stellar Testnet'); + expect(formatStatValue(' Real-time ')).toBe('Real-time'); + }); +}); + +describe('Stats', () => { + it('renders the default copy when no data is supplied', () => { + const { container } = render(); + + expect(screen.getByText('Stellar Testnet')).toBeInTheDocument(); + expect(screen.getByText('Early Access')).toBeInTheDocument(); + expect(screen.getByText('Real-time')).toBeInTheDocument(); + expect(renderedValues(container)).toHaveLength(3); + expectNoBrokenText(); + }); + + it('keeps labels and layout, showing the fallback, when the endpoint returns null', () => { + const { container } = render(); + + expect(renderedValues(container)).toEqual([ + STAT_FALLBACK, + STAT_FALLBACK, + STAT_FALLBACK, + ]); + expect(screen.getByText('Network')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Settlement')).toBeInTheDocument(); + expectNoBrokenText(); + }); + + it('shows the fallback when the endpoint returns an empty list', () => { + const { container } = render(); + + expect(renderedValues(container)).toEqual([ + STAT_FALLBACK, + STAT_FALLBACK, + STAT_FALLBACK, + ]); + expectNoBrokenText(); + }); + + it('renders zero values as "0" instead of a fallback', () => { + render( + , + ); + + expect(screen.getAllByText('0')).toHaveLength(2); + expect(screen.queryByText(STAT_FALLBACK)).not.toBeInTheDocument(); + expectNoBrokenText(); + }); + + it('falls back per-stat when an error response omits or corrupts values', () => { + const { container } = render( + , + ); + + expect(renderedValues(container)).toEqual([ + STAT_FALLBACK, + STAT_FALLBACK, + 'Real-time', + ]); + expect(screen.getByText('Total Value Locked')).toBeInTheDocument(); + expectNoBrokenText(); + }); +}); diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index d7d6e205..c5d77617 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -1,21 +1,79 @@ +/** + * Marketing stats strip. + * + * The values below are still static copy — wiring them to the real stats + * endpoint is tracked separately. What this file *does* guarantee is that when + * that wiring lands, absent or malformed data degrades gracefully: every value + * is rendered through `formatStatValue`, which never emits "NaN", "undefined" + * or an empty cell. Callers can already pass a `stats` prop (as the fetching + * layer eventually will) and the placeholder layout holds its shape. + */ +/** Placeholder shown when a stat value is missing or not renderable. */ +export const STAT_FALLBACK = '--'; -const stats = [ +export type StatValue = string | number | null | undefined; + +export interface StatItem { + label: string; + value: StatValue; + /** Optional text-gradient utility class; falls back to plain white. */ + gradient?: string; +} + +const DEFAULT_STATS: readonly StatItem[] = [ { label: 'Network', value: 'Stellar Testnet', gradient: 'text-gradient' }, { label: 'Status', value: 'Early Access', gradient: 'text-white' }, { label: 'Settlement', value: 'Real-time', gradient: 'text-gradient-secondary' }, - ]; -export const Stats = () => { +/** + * Convert a raw stat value into something safe to render. + * + * Returns {@link STAT_FALLBACK} for null, undefined, NaN, Infinity and + * blank strings so the marketing page never shows "NaN" or "undefined". + * Zero is a legitimate value and renders as "0". + */ +export function formatStatValue(value: StatValue): string { + if (value === null || value === undefined) return STAT_FALLBACK; + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return STAT_FALLBACK; + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); + } + + const trimmed = value.trim(); + return trimmed === '' ? STAT_FALLBACK : trimmed; +} + +interface StatsProps { + /** + * Stats to display. `undefined` (the default) renders the static copy; + * `null` or an empty array — i.e. the endpoint returned nothing or errored — + * keeps the labels and layout but shows {@link STAT_FALLBACK} for each value. + */ + stats?: readonly StatItem[] | null; +} + +export const Stats = ({ stats }: StatsProps) => { + const items: readonly StatItem[] = + stats === undefined + ? DEFAULT_STATS + : stats && stats.length > 0 + ? stats + : DEFAULT_STATS.map((stat) => ({ ...stat, value: null })); + return (
- {stats.map((stat, i) => ( -
- - {stat.value} + {items.map((stat, i) => ( +
+ + {formatStatValue(stat.value)} {stat.label} diff --git a/frontend/src/components/dashboard/ActivityHistory.tsx b/frontend/src/components/dashboard/ActivityHistory.tsx index 31664345..f40b8e46 100644 --- a/frontend/src/components/dashboard/ActivityHistory.tsx +++ b/frontend/src/components/dashboard/ActivityHistory.tsx @@ -3,7 +3,7 @@ import React from "react"; import Link from "next/link"; import { BackendStreamEvent } from "@/lib/api-types"; -import { formatAmount } from "@/lib/amount"; +import { formatAmount } from "@/utils/amount"; import TransactionTracker from "@/components/TransactionTracker"; import { Download, ExternalLink, Clock } from "lucide-react"; import { Button } from "../ui/Button"; diff --git a/frontend/src/components/stream-creation/ScheduleStep.tsx b/frontend/src/components/stream-creation/ScheduleStep.tsx index ade09dc7..9dee4cc5 100644 --- a/frontend/src/components/stream-creation/ScheduleStep.tsx +++ b/frontend/src/components/stream-creation/ScheduleStep.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useMemo, useRef, useEffect } from "react"; -import { hasValidPrecision } from "@/lib/amount"; +import { hasValidPrecision } from "@/utils/amount"; import { SECONDS_PER_UNIT, DurationUnit } from "@/lib/soroban"; interface ScheduleStepProps { diff --git a/frontend/src/components/stream-creation/StreamCreationWizard.tsx b/frontend/src/components/stream-creation/StreamCreationWizard.tsx index f16e9d94..50aee5b6 100644 --- a/frontend/src/components/stream-creation/StreamCreationWizard.tsx +++ b/frontend/src/components/stream-creation/StreamCreationWizard.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useState } from "react"; -import { hasValidPrecision } from "@/lib/amount"; +import { hasValidPrecision } from "@/utils/amount"; import { useModalDialog } from "@/hooks/useModalDialog"; import { logger } from "@/lib/logger"; import { Stepper } from "../ui/Stepper"; diff --git a/frontend/src/lib/amount.ts b/frontend/src/lib/amount.ts deleted file mode 100644 index efc22c19..00000000 --- a/frontend/src/lib/amount.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Shared utilities for formatting and parsing token amounts - * Handles conversion between raw on-chain amounts (i128) and display values - */ - -/** - * Format raw amount (bigint) to display string with proper decimal places - * @param raw - Raw amount as bigint - * @param decimals - Number of decimal places for the token - * @returns Formatted string - */ -export function formatAmount(raw: bigint, decimals: number): string { - if (raw === 0n) return '0'; - - const divisor = 10n ** BigInt(decimals); - const whole = raw / divisor; - const fractional = raw % divisor; - - if (fractional === 0n) { - return whole.toString(); - } - - // Pad fractional part with leading zeros - const fractionalStr = fractional.toString().padStart(decimals, '0'); - - // Remove trailing zeros - const trimmedFractional = fractionalStr.replace(/0+$/, ''); - - return `${whole}.${trimmedFractional}`; -} - -/** - * Parse display string back to raw amount (bigint) - * @param display - Display string (e.g., "1.234") - * @param decimals - Number of decimal places for the token - * @returns Raw amount as bigint - */ -export function parseAmount(display: string, decimals: number): bigint { - if (!display || display.trim() === '') return 0n; - - const cleanDisplay = display.trim(); - const divisor = 10n ** BigInt(decimals); - - if (cleanDisplay.includes('.')) { - const [wholePart, fractionalPart] = cleanDisplay.split('.'); - const whole = BigInt(wholePart || '0'); - - // Handle fractional part - pad or truncate to correct length - let fractional = fractionalPart || ''; - if (fractional.length > decimals) { - // Truncate if too long - fractional = fractional.slice(0, decimals); - } else { - // Pad with zeros if too short - fractional = fractional.padEnd(decimals, '0'); - } - - const fractionalBig = BigInt(fractional || '0'); - return whole * divisor + fractionalBig; - } else { - return BigInt(cleanDisplay) * divisor; - } -} - -/** - * Format rate per second to human-readable string - * @param ratePerSec - Rate per second as bigint - * @param decimals - Number of decimal places for the token - * @param symbol - Token symbol (optional) - * @returns Formatted rate string - */ -export function formatRate(ratePerSec: bigint, decimals: number, symbol = ''): string { - if (ratePerSec === 0n) return '0'; - - const ratePerSecond = formatAmount(ratePerSec, decimals); - const ratePerDay = formatAmount(ratePerSec * 86400n, decimals); // 86400 seconds in a day - - const symbolStr = symbol ? ` ${symbol}` : ''; - return `${ratePerSecond}${symbolStr}/sec (${ratePerDay}${symbolStr}/day)`; -} - -/** - * Check if input string has valid precision for the given decimals - * @param input - Input string to validate - * @param decimals - Maximum allowed decimal places - * @returns True if valid precision - */ -export function hasValidPrecision(input: string, decimals: number): boolean { - if (!input || input.trim() === '') return true; // Empty is valid (will be parsed as 0) - - const cleanInput = input.trim(); - - // Check if it's a valid number format - if (!/^\d*\.?\d*$/.test(cleanInput)) return false; - - if (cleanInput.includes('.')) { - const fractionalPart = cleanInput.split('.')[1] ?? ''; - return fractionalPart.length <= decimals; - } - - return true; -} - -/** - * Convert value to stroops (smallest unit, 7 decimal places for XLM) - * @param value - String value in XLM - * @returns Value in stroops as bigint - */ -export function toStroops(value: string): bigint { - return parseAmount(value, 7); // XLM uses 7 decimal places -} - -/** - * Convert stroops back to XLM string - * @param stroops - Value in stroops as bigint - * @returns XLM string - */ -export function fromStroops(stroops: bigint): string { - return formatAmount(stroops, 7); -} - -/** - * Truncate amount to specified decimal places without rounding - * @param amount - Amount as bigint - * @param decimals - Token decimals - * @param maxDisplayDecimals - Maximum decimal places to display - * @returns Truncated string - */ -export function truncateAmount(amount: bigint, decimals: number, maxDisplayDecimals: number): string { - if (amount === 0n) return '0'; - - const divisor = 10n ** BigInt(decimals); - const whole = amount / divisor; - const fractional = amount % divisor; - - if (fractional === 0n) { - return whole.toString(); - } - - // Convert fractional to string and truncate - const fractionalStr = fractional.toString().padStart(decimals, '0'); - const truncatedFractional = fractionalStr.slice(0, maxDisplayDecimals); - - // Remove trailing zeros from truncated part - const trimmedFractional = truncatedFractional.replace(/0+$/, ''); - - if (trimmedFractional === '') { - return whole.toString(); - } - - return `${whole}.${trimmedFractional}`; -} - -/** - * Format amount with compact notation (K, M, B) for large numbers - * @param amount - Amount as bigint - * @param decimals - Token decimals - * @returns Compact formatted string - */ -export function formatCompactAmount(amount: bigint, decimals: number): string { - const displayAmount = formatAmount(amount, decimals); - const num = parseFloat(displayAmount); - - if (num === 0) return '0'; - if (num < 1000) return displayAmount; - if (num < 1000000) return `${(num / 1000).toFixed(1)}K`; - if (num < 1000000000) return `${(num / 1000000).toFixed(1)}M`; - return `${(num / 1000000000).toFixed(1)}B`; -} diff --git a/frontend/src/utils/amount.ts b/frontend/src/utils/amount.ts index 73675ba4..5a461671 100644 --- a/frontend/src/utils/amount.ts +++ b/frontend/src/utils/amount.ts @@ -37,9 +37,11 @@ export function streamProgressPercent(withdrawn: bigint, deposited: bigint): num /** * Alias for formatAmount - convert raw on-chain amount to human-readable string + * @param amount - Raw amount as bigint + * @param decimals - Token decimals; defaults to 7 (XLM stroops) * @deprecated Use formatAmount instead */ -export function fromStroops(amount: bigint, decimals: number): string { +export function fromStroops(amount: bigint, decimals = 7): string { return formatAmount(amount, decimals); } @@ -72,9 +74,11 @@ export function parseAmount(display: string, decimals: number): bigint { /** * Alias for parseAmount - convert human-readable to raw on-chain amount + * @param amount - Display string (e.g., "1.234") + * @param decimals - Token decimals; defaults to 7 (XLM stroops) * @deprecated Use parseAmount instead */ -export function toStroops(amount: string, decimals: number): bigint { +export function toStroops(amount: string, decimals = 7): bigint { return parseAmount(amount, decimals); } @@ -111,6 +115,51 @@ export function formatStreamRate( return formatRate(ratePerSecond, decimals, tokenSymbol); } +/** + * Truncate amount to specified decimal places without rounding + * @param amount - Amount as bigint + * @param decimals - Token decimals + * @param maxDisplayDecimals - Maximum decimal places to display + * @returns Truncated string + */ +export function truncateAmount( + amount: bigint, + decimals: number, + maxDisplayDecimals: number +): string { + if (amount === 0n) return "0"; + + const factor = 10n ** BigInt(decimals); + const integerPart = amount / factor; + const fractionalPart = amount % factor; + + if (fractionalPart === 0n) return integerPart.toString(); + + const fractionalStr = fractionalPart.toString().padStart(decimals, "0"); + const truncatedFractional = fractionalStr.slice(0, maxDisplayDecimals); + const trimmedFractional = truncatedFractional.replace(/0+$/, ""); + + if (!trimmedFractional) return integerPart.toString(); + return `${integerPart}.${trimmedFractional}`; +} + +/** + * Format amount with compact notation (K, M, B) for large numbers + * @param amount - Amount as bigint + * @param decimals - Token decimals + * @returns Compact formatted string e.g., "1.5K" + */ +export function formatCompactAmount(amount: bigint, decimals: number): string { + const displayAmount = formatAmount(amount, decimals); + const num = parseFloat(displayAmount); + + if (num === 0) return "0"; + if (num < 1_000) return displayAmount; + if (num < 1_000_000) return `${(num / 1_000).toFixed(1)}K`; + if (num < 1_000_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + return `${(num / 1_000_000_000).toFixed(1)}B`; +} + /** * Check if input string has valid precision for the given decimals * @param input - Input string to validate