Compare commits
2 Commits
master
...
feat/explo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a27d2c0e8 | ||
|
|
cc70283171 |
49
frontend/src/components/common/DisplayCurrencyContext.tsx
Normal file
49
frontend/src/components/common/DisplayCurrencyContext.tsx
Normal file
@@ -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<DisplayCurrency>('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 <DisplayCurrencyContext.Provider value={value}>{children}</DisplayCurrencyContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisplayCurrency() {
|
||||||
|
const context = useContext(DisplayCurrencyContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDisplayCurrency must be used within a DisplayCurrencyProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -2,25 +2,28 @@ import type { ReactNode } from 'react'
|
|||||||
import Navbar from './Navbar'
|
import Navbar from './Navbar'
|
||||||
import Footer from './Footer'
|
import Footer from './Footer'
|
||||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||||
|
import { DisplayCurrencyProvider } from './DisplayCurrencyContext'
|
||||||
import { UiModeProvider } from './UiModeContext'
|
import { UiModeProvider } from './UiModeContext'
|
||||||
|
|
||||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<UiModeProvider>
|
<UiModeProvider>
|
||||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
<DisplayCurrencyProvider>
|
||||||
<a
|
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||||
href="#main-content"
|
<a
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
href="#main-content"
|
||||||
>
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
||||||
Skip to content
|
>
|
||||||
</a>
|
Skip to content
|
||||||
<Navbar />
|
</a>
|
||||||
<div id="main-content" className="flex-1">
|
<Navbar />
|
||||||
{children}
|
<div id="main-content" className="flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<ExplorerAgentTool />
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
<ExplorerAgentTool />
|
</DisplayCurrencyProvider>
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</UiModeProvider>
|
</UiModeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { DetailRow } from '@/components/common/DetailRow'
|
|||||||
import EntityBadge from '@/components/common/EntityBadge'
|
import EntityBadge from '@/components/common/EntityBadge'
|
||||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||||
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||||
|
import { useDisplayCurrency } from '@/components/common/DisplayCurrencyContext'
|
||||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||||
|
import { formatUsdValue, getSecondaryDisplayValue } from '@/utils/displayCurrency'
|
||||||
|
|
||||||
function isValidAddress(value: string) {
|
function isValidAddress(value: string) {
|
||||||
return /^0x[a-fA-F0-9]{40}$/.test(value)
|
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 {
|
function formatUsd(value: string | number | null | undefined): string {
|
||||||
const numeric = toNumeric(value)
|
const numeric = toNumeric(value)
|
||||||
if (numeric == null) return 'Unavailable'
|
if (numeric == null) return 'Unavailable'
|
||||||
return new Intl.NumberFormat('en-US', {
|
return formatUsdValue(numeric)
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
maximumFractionDigits: numeric >= 100 ? 0 : 2,
|
|
||||||
}).format(numeric)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TokenDetailPage() {
|
export default function TokenDetailPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { currency, setCurrency } = useDisplayCurrency()
|
||||||
const address = typeof router.query.address === 'string' ? router.query.address : ''
|
const address = typeof router.query.address === 'string' ? router.query.address : ''
|
||||||
const isValidTokenAddress = address !== '' && isValidAddress(address)
|
const isValidTokenAddress = address !== '' && isValidAddress(address)
|
||||||
|
|
||||||
@@ -177,6 +176,28 @@ export default function TokenDetailPage() {
|
|||||||
[address, token?.address, token?.symbol],
|
[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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>{primaryAmount}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Approx. {secondaryAmount}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[currency, token?.exchange_rate],
|
||||||
|
)
|
||||||
|
|
||||||
const holderColumns = [
|
const holderColumns = [
|
||||||
{
|
{
|
||||||
header: 'Holder',
|
header: 'Holder',
|
||||||
@@ -188,7 +209,7 @@ export default function TokenDetailPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Balance',
|
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',
|
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',
|
header: 'When',
|
||||||
@@ -316,9 +337,27 @@ export default function TokenDetailPage() {
|
|||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Type">{token.type || 'Unknown'}</DetailRow>
|
<DetailRow label="Type">{token.type || 'Unknown'}</DetailRow>
|
||||||
<DetailRow label="Decimals">{token.decimals}</DetailRow>
|
<DetailRow label="Decimals">{token.decimals}</DetailRow>
|
||||||
|
<DetailRow label="Display Currency">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span className="sr-only">Display currency</span>
|
||||||
|
<select
|
||||||
|
value={currency}
|
||||||
|
onChange={(event) => setCurrency(event.target.value === 'usd' ? 'usd' : 'native')}
|
||||||
|
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:ring-primary-900"
|
||||||
|
>
|
||||||
|
<option value="native">Native token amounts</option>
|
||||||
|
<option value="usd">USD estimate</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
USD estimates use the explorer's current indicative token price and appear as a secondary line when available.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DetailRow>
|
||||||
{token.total_supply && (
|
{token.total_supply && (
|
||||||
<DetailRow label="Total Supply">
|
<DetailRow label="Total Supply">
|
||||||
{formatTokenAmount(token.total_supply, token.decimals, token.symbol)}
|
{renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
{token.holders != null && (
|
{token.holders != null && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { Block } from '@/services/api/blocks'
|
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'
|
import { loadDashboardData } from './dashboard'
|
||||||
|
|
||||||
const sampleStats: ExplorerStats = {
|
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', () => {
|
describe('loadDashboardData', () => {
|
||||||
it('returns both stats and recent blocks when both loaders succeed', async () => {
|
it('returns both stats and recent blocks when both loaders succeed', async () => {
|
||||||
const result = await loadDashboardData({
|
const result = await loadDashboardData({
|
||||||
@@ -33,6 +44,7 @@ describe('loadDashboardData', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
stats: sampleStats,
|
stats: sampleStats,
|
||||||
recentBlocks: sampleBlocks,
|
recentBlocks: sampleBlocks,
|
||||||
|
recentTransactionTrend: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -50,6 +62,7 @@ describe('loadDashboardData', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
stats: null,
|
stats: null,
|
||||||
recentBlocks: sampleBlocks,
|
recentBlocks: sampleBlocks,
|
||||||
|
recentTransactionTrend: [],
|
||||||
})
|
})
|
||||||
expect(onError).toHaveBeenCalledTimes(1)
|
expect(onError).toHaveBeenCalledTimes(1)
|
||||||
expect(onError).toHaveBeenCalledWith('stats', expect.any(Error))
|
expect(onError).toHaveBeenCalledWith('stats', expect.any(Error))
|
||||||
@@ -69,8 +82,44 @@ describe('loadDashboardData', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
stats: sampleStats,
|
stats: sampleStats,
|
||||||
recentBlocks: [],
|
recentBlocks: [],
|
||||||
|
recentTransactionTrend: [],
|
||||||
})
|
})
|
||||||
expect(onError).toHaveBeenCalledTimes(1)
|
expect(onError).toHaveBeenCalledTimes(1)
|
||||||
expect(onError).toHaveBeenCalledWith('blocks', expect.any(Error))
|
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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
47
frontend/src/utils/displayCurrency.test.ts
Normal file
47
frontend/src/utils/displayCurrency.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
35
frontend/src/utils/displayCurrency.ts
Normal file
35
frontend/src/utils/displayCurrency.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user