diff --git a/frontend/src/components/common/DisplayCurrencyContext.tsx b/frontend/src/components/common/DisplayCurrencyContext.tsx new file mode 100644 index 0000000..45b21a3 --- /dev/null +++ b/frontend/src/components/common/DisplayCurrencyContext.tsx @@ -0,0 +1,49 @@ +'use client' + +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' + +export type DisplayCurrency = 'native' | 'usd' + +const DISPLAY_CURRENCY_STORAGE_KEY = 'explorer_display_currency' + +const DisplayCurrencyContext = createContext<{ + currency: DisplayCurrency + setCurrency: (currency: DisplayCurrency) => void +} | null>(null) + +export function DisplayCurrencyProvider({ children }: { children: ReactNode }) { + const [currency, setCurrencyState] = useState('native') + + useEffect(() => { + if (typeof window === 'undefined') return + const stored = window.localStorage.getItem(DISPLAY_CURRENCY_STORAGE_KEY) + if (stored === 'native' || stored === 'usd') { + setCurrencyState(stored) + } + }, []) + + const setCurrency = (nextCurrency: DisplayCurrency) => { + setCurrencyState(nextCurrency) + if (typeof window !== 'undefined') { + window.localStorage.setItem(DISPLAY_CURRENCY_STORAGE_KEY, nextCurrency) + } + } + + const value = useMemo( + () => ({ + currency, + setCurrency, + }), + [currency], + ) + + return {children} +} + +export function useDisplayCurrency() { + const context = useContext(DisplayCurrencyContext) + if (!context) { + throw new Error('useDisplayCurrency must be used within a DisplayCurrencyProvider') + } + return context +} diff --git a/frontend/src/components/common/ExplorerChrome.tsx b/frontend/src/components/common/ExplorerChrome.tsx index 56eb5f8..37cc5fa 100644 --- a/frontend/src/components/common/ExplorerChrome.tsx +++ b/frontend/src/components/common/ExplorerChrome.tsx @@ -3,29 +3,32 @@ import Navbar from './Navbar' import Footer from './Footer' import ExplorerAgentTool from './ExplorerAgentTool' import ExplorerDocumentHead from './ExplorerDocumentHead' +import { DisplayCurrencyProvider } from './DisplayCurrencyContext' import { UiModeProvider } from './UiModeContext' import { PostureGlossaryProvider } from './PostureGlossaryProvider' export default function ExplorerChrome({ children }: { children: ReactNode }) { return ( - - -
- - Skip to content - - -
- {children} -
- -
-
-
+ + + +
+ + Skip to content + + +
+ {children} +
+ +
+
+
+
) } diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index 65bda79..0d98f7a 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -20,9 +20,11 @@ import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const TOKEN_DETAIL_TABS_ID = 'token-detail' import { formatTokenAmount, formatTimestamp } from '@/utils/format' +import { useDisplayCurrency } from '@/components/common/DisplayCurrencyContext' import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru' import { getGruExplorerMetadata } from '@/services/api/gruExplorerData' import { contractsApi, type ContractProfile } from '@/services/api/contracts' +import { formatUsdValue, getSecondaryDisplayValue } from '@/utils/displayCurrency' function isValidAddress(value: string) { return /^0x[a-fA-F0-9]{40}$/.test(value) @@ -40,15 +42,12 @@ function toNumeric(value: string | number | null | undefined): number | null { function formatUsd(value: string | number | null | undefined): string { const numeric = toNumeric(value) if (numeric == null) return 'Unavailable' - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: numeric >= 100 ? 0 : 2, - }).format(numeric) + return formatUsdValue(numeric) } export default function TokenDetailPage() { const router = useRouter() + const { currency, setCurrency } = useDisplayCurrency() const address = typeof router.query.address === 'string' ? router.query.address : '' const isValidTokenAddress = address !== '' && isValidAddress(address) @@ -233,6 +232,28 @@ export default function TokenDetailPage() { setPoolPage(1) }, [address]) + const renderAmountWithDisplayCurrency = useCallback( + (rawAmount: string | number | null | undefined, decimals: number, symbol?: string | null) => { + const primaryAmount = formatTokenAmount(rawAmount, decimals, symbol) + const secondaryAmount = getSecondaryDisplayValue({ + rawAmount, + decimals, + exchangeRate: token?.exchange_rate, + displayCurrency: currency, + }) + + if (!secondaryAmount) return primaryAmount + + return ( +
+
{primaryAmount}
+
Approx. {secondaryAmount}
+
+ ) + }, + [currency, token?.exchange_rate], + ) + const holderColumns = [ { header: 'Holder', @@ -244,7 +265,7 @@ export default function TokenDetailPage() { }, { header: 'Balance', - accessor: (holder: TokenHolder) => formatTokenAmount(holder.value, token?.decimals || holder.token_decimals, token?.symbol), + accessor: (holder: TokenHolder) => renderAmountWithDisplayCurrency(holder.value, token?.decimals || holder.token_decimals, token?.symbol), }, ] @@ -294,7 +315,7 @@ export default function TokenDetailPage() { }, { header: 'Amount', - accessor: (transfer: AddressTokenTransfer) => formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol), + accessor: (transfer: AddressTokenTransfer) => renderAmountWithDisplayCurrency(transfer.value, transfer.token_decimals, transfer.token_symbol), }, { header: 'When', @@ -389,9 +410,27 @@ export default function TokenDetailPage() { {token.type || 'Unknown'} {token.decimals} + +
+ +
+ USD estimates use the explorer's current indicative token price and appear as a secondary line when available. +
+
+
{token.total_supply && ( - {formatTokenAmount(token.total_supply, token.decimals, token.symbol)} + {renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)} )} {token.holders != null && ( diff --git a/frontend/src/utils/dashboard.test.ts b/frontend/src/utils/dashboard.test.ts index 539d28a..9bab6be 100644 --- a/frontend/src/utils/dashboard.test.ts +++ b/frontend/src/utils/dashboard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Block } from '@/services/api/blocks' -import type { ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats, ExplorerTransactionTrendPoint } from '@/services/api/stats' import { loadDashboardData } from './dashboard' const sampleStats: ExplorerStats = { @@ -23,6 +23,17 @@ const sampleBlocks: Block[] = [ }, ] +const sampleTrend: ExplorerTransactionTrendPoint[] = [ + { + date: '2026-04-03', + count: 11, + }, + { + date: '2026-04-04', + count: 17, + }, +] + describe('loadDashboardData', () => { it('returns both stats and recent blocks when both loaders succeed', async () => { const result = await loadDashboardData({ @@ -76,4 +87,39 @@ describe('loadDashboardData', () => { expect(onError).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith('blocks', expect.any(Error)) }) + + it('returns the recent transaction trend when the optional loader succeeds', async () => { + const result = await loadDashboardData({ + loadStats: async () => sampleStats, + loadRecentBlocks: async () => sampleBlocks, + loadRecentTransactionTrend: async () => sampleTrend, + }) + + expect(result).toEqual({ + stats: sampleStats, + recentBlocks: sampleBlocks, + recentTransactionTrend: sampleTrend, + }) + }) + + it('falls back to an empty recent transaction trend when the optional loader fails', async () => { + const onError = vi.fn() + + const result = await loadDashboardData({ + loadStats: async () => sampleStats, + loadRecentBlocks: async () => sampleBlocks, + loadRecentTransactionTrend: async () => { + throw new Error('trend unavailable') + }, + onError, + }) + + expect(result).toEqual({ + stats: sampleStats, + recentBlocks: sampleBlocks, + recentTransactionTrend: [], + }) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith('trend', expect.any(Error)) + }) }) diff --git a/frontend/src/utils/displayCurrency.test.ts b/frontend/src/utils/displayCurrency.test.ts new file mode 100644 index 0000000..a77df53 --- /dev/null +++ b/frontend/src/utils/displayCurrency.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { formatUsdValue, getSecondaryDisplayValue } from './displayCurrency' + +describe('formatUsdValue', () => { + it('keeps cents for smaller values', () => { + expect(formatUsdValue(4.5)).toBe('$4.50') + }) + + it('drops cents for larger rounded values', () => { + expect(formatUsdValue(1250)).toBe('$1,250') + }) +}) + +describe('getSecondaryDisplayValue', () => { + it('returns null when the user prefers native display', () => { + expect( + getSecondaryDisplayValue({ + rawAmount: '4500000', + decimals: 6, + exchangeRate: 1, + displayCurrency: 'native', + }), + ).toBeNull() + }) + + it('formats a USD secondary value from token units and exchange rate', () => { + expect( + getSecondaryDisplayValue({ + rawAmount: '4500000', + decimals: 6, + exchangeRate: 1, + displayCurrency: 'usd', + }), + ).toBe('$4.50') + }) + + it('returns null when no usable exchange rate is available', () => { + expect( + getSecondaryDisplayValue({ + rawAmount: '4500000', + decimals: 6, + exchangeRate: null, + displayCurrency: 'usd', + }), + ).toBeNull() + }) +}) diff --git a/frontend/src/utils/displayCurrency.ts b/frontend/src/utils/displayCurrency.ts new file mode 100644 index 0000000..bf3c69b --- /dev/null +++ b/frontend/src/utils/displayCurrency.ts @@ -0,0 +1,35 @@ +import { formatUnits } from './format' + +function toFiniteNumber(value: string | number | null | undefined): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +export function formatUsdValue(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: Math.abs(value) >= 100 ? 0 : 2, + }).format(value) +} + +export function getSecondaryDisplayValue(input: { + rawAmount: string | number | null | undefined + decimals?: number + exchangeRate?: string | number | null + displayCurrency: 'native' | 'usd' +}): string | null { + if (input.displayCurrency !== 'usd') return null + + const exchangeRate = toFiniteNumber(input.exchangeRate) + if (exchangeRate == null || exchangeRate < 0) return null + + const normalizedAmount = Number(formatUnits(input.rawAmount, input.decimals ?? 18, 8)) + if (!Number.isFinite(normalizedAmount)) return null + + return formatUsdValue(normalizedAmount * exchangeRate) +}