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 ca92c6b..322bb0f 100644 --- a/frontend/src/components/common/ExplorerChrome.tsx +++ b/frontend/src/components/common/ExplorerChrome.tsx @@ -2,25 +2,28 @@ import type { ReactNode } from 'react' import Navbar from './Navbar' import Footer from './Footer' import ExplorerAgentTool from './ExplorerAgentTool' +import { DisplayCurrencyProvider } from './DisplayCurrencyContext' import { UiModeProvider } from './UiModeContext' 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 474174b..fc9d153 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -12,8 +12,10 @@ import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' import GruStandardsCard from '@/components/common/GruStandardsCard' 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 { formatUsdValue, getSecondaryDisplayValue } from '@/utils/displayCurrency' function isValidAddress(value: string) { return /^0x[a-fA-F0-9]{40}$/.test(value) @@ -31,15 +33,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) @@ -177,6 +176,28 @@ export default function TokenDetailPage() { [address, token?.address, token?.symbol], ) + 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', @@ -188,7 +209,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), }, ] @@ -238,7 +259,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', @@ -316,9 +337,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/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) +}