From 0cb31cfa9d114065677eb34dac947377ee58eacb Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 22 Jun 2026 15:52:47 -0700 Subject: [PATCH] Improve explorer SSR, hydration, compaction, and smoke coverage. Defer heavy getServerSideProps on home, operator, addresses, and wallet to cut TTFB; centralize locale-safe formatters with client-only relative times; add compact ops UX, bridge/operator relay pagination, and Playwright route/scroll smoke in deploy. Co-authored-by: Cursor --- frontend/package-lock.json | 48 ++ frontend/package.json | 2 + .../public/chain138-command-center.meta.json | 4 +- frontend/public/explorer-spa.js | 76 ++- frontend/scripts/smoke-routes.mjs | 33 +- frontend/scripts/smoke-scroll-height.mjs | 90 +++ .../access/AccessManagementPage.tsx | 311 +++++----- .../common/ActivityContextPanel.tsx | 24 +- .../components/common/ClientRelativeTime.tsx | 57 ++ .../components/common/CompactMetricBar.tsx | 29 + .../components/common/DisclosureSection.tsx | 70 +++ .../common/ExplorerFreshnessDisclosure.tsx | 43 ++ frontend/src/components/common/Footer.tsx | 105 ++-- .../components/common/FooterLinkGroups.tsx | 46 ++ .../common/FooterPublicApiLinks.tsx | 18 +- .../src/components/common/FreshnessDetail.tsx | 68 +++ .../components/common/FreshnessTrustNote.tsx | 48 +- .../src/components/common/ListFilterBar.tsx | 45 ++ .../components/common/MarketEvidenceNote.tsx | 34 +- .../common/MissionDeliveryModePanel.tsx | 6 +- frontend/src/components/common/Navbar.tsx | 5 +- frontend/src/components/common/PageIntro.tsx | 50 +- .../src/components/common/SectionTabs.tsx | 44 +- .../common/SubsystemPosturePanel.tsx | 10 +- .../explorer/AnalyticsOperationsPage.tsx | 213 ++++--- .../explorer/BridgeMonitoringPage.tsx | 186 +++--- .../explorer/FeatureLandingPage.tsx | 51 +- .../explorer/LiquidityOperationsPage.tsx | 137 +++-- .../explorer/OperationsActionGrid.tsx | 6 +- .../components/explorer/OperationsHubPage.tsx | 154 ++--- .../explorer/OperationsPageShell.tsx | 47 +- .../explorer/OperatorOperationsPage.tsx | 98 ++-- .../explorer/PoolsOperationsPage.tsx | 290 +++++++--- .../explorer/RoutesMonitoringPage.tsx | 361 +++++------- .../explorer/SystemOperationsPage.tsx | 69 +-- .../explorer/WethOperationsPage.tsx | 93 ++- frontend/src/components/home/HomePage.tsx | 412 +++++++------ .../src/components/pools/PoolDetailPage.tsx | 68 ++- .../src/components/wallet/AddToMetaMask.tsx | 542 ++++++++++-------- .../wallet/AddTokenToWalletButton.tsx | 201 +++++++ .../wallet/MultiChainWalletImport.tsx | 94 ++- frontend/src/components/wallet/WalletPage.tsx | 170 +++--- frontend/src/data/footerNav.ts | 60 ++ frontend/src/pages/addresses/[address].tsx | 57 +- frontend/src/pages/addresses/index.tsx | 202 ++++--- frontend/src/pages/analytics/index.tsx | 93 +-- frontend/src/pages/blocks/[number].tsx | 99 ++-- frontend/src/pages/blocks/index.tsx | 205 ++++--- frontend/src/pages/docs/gru.tsx | 3 +- frontend/src/pages/docs/index.tsx | 122 ++-- frontend/src/pages/docs/posture-glossary.tsx | 3 +- frontend/src/pages/docs/public-api-access.tsx | 3 +- .../src/pages/docs/transaction-compliance.tsx | 3 +- frontend/src/pages/index.tsx | 82 +-- frontend/src/pages/liquidity/index.tsx | 77 +-- frontend/src/pages/operator/index.tsx | 27 +- frontend/src/pages/protocols/[id].tsx | 68 ++- frontend/src/pages/protocols/index.tsx | 15 +- frontend/src/pages/search/index.tsx | 467 ++++++++------- frontend/src/pages/system/index.tsx | 35 +- frontend/src/pages/tokens/[address].tsx | 59 +- frontend/src/pages/tokens/index.tsx | 307 ++++++---- frontend/src/pages/transactions/[hash].tsx | 73 +-- frontend/src/pages/transactions/index.tsx | 276 +++++---- frontend/src/pages/wallet/index.tsx | 29 +- frontend/src/pages/watchlist/index.tsx | 94 +-- frontend/src/services/api/missionControl.ts | 23 +- frontend/src/utils/activityContext.ts | 13 +- frontend/src/utils/format.test.ts | 37 +- frontend/src/utils/format.ts | 55 +- frontend/src/utils/walletTokenBalances.ts | 8 +- frontend/src/utils/walletWatchToken.test.ts | 70 +++ frontend/src/utils/walletWatchToken.ts | 64 +++ scripts/deploy-next-frontend-to-vmid5000.sh | 21 + 74 files changed, 4274 insertions(+), 2934 deletions(-) create mode 100644 frontend/scripts/smoke-scroll-height.mjs create mode 100644 frontend/src/components/common/ClientRelativeTime.tsx create mode 100644 frontend/src/components/common/CompactMetricBar.tsx create mode 100644 frontend/src/components/common/DisclosureSection.tsx create mode 100644 frontend/src/components/common/ExplorerFreshnessDisclosure.tsx create mode 100644 frontend/src/components/common/FooterLinkGroups.tsx create mode 100644 frontend/src/components/common/FreshnessDetail.tsx create mode 100644 frontend/src/components/common/ListFilterBar.tsx create mode 100644 frontend/src/components/wallet/AddTokenToWalletButton.tsx create mode 100644 frontend/src/data/footerNav.ts create mode 100644 frontend/src/utils/walletWatchToken.test.ts create mode 100644 frontend/src/utils/walletWatchToken.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9805da2..acf4ee3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,7 @@ "@types/react-dom": "^18.2.18", "eslint": "^8.56.0", "eslint-config-next": "^15.5.15", + "playwright": "1.51.0", "typescript": "^5.3.3", "vitest": "^4.1.5" }, @@ -14578,6 +14579,53 @@ "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz", + "integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz", + "integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cb4c35f..6219ba2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "build": "next build", "build:check": "npm run lint && npm run type-check && npm run build", "smoke:routes": "node ./scripts/smoke-routes.mjs", + "smoke:scroll": "node ./scripts/smoke-scroll-height.mjs", "start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs", "start:next": "next start", "lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx", @@ -41,6 +42,7 @@ "@types/react-dom": "^18.2.18", "eslint": "^8.56.0", "eslint-config-next": "^15.5.15", + "playwright": "1.51.0", "typescript": "^5.3.3", "vitest": "^4.1.5" }, diff --git a/frontend/public/chain138-command-center.meta.json b/frontend/public/chain138-command-center.meta.json index 0ba3f3b..7782ce5 100644 --- a/frontend/public/chain138-command-center.meta.json +++ b/frontend/public/chain138-command-center.meta.json @@ -1,6 +1,6 @@ { - "bundleVersion": "2026-06-17", - "updatedAt": "2026-06-17T10:57:10Z", + "bundleVersion": "2026-06-22", + "updatedAt": "2026-06-22T22:33:55Z", "sourceDoc": "explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md", "routeMatrixUpdated": null } diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index d0e11b1..69e7c1c 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -2200,10 +2200,31 @@ return []; } + async function fetchChain138WatchAssets() { + if (window._chain138WatchAssetsCache) return window._chain138WatchAssetsCache; + try { + var res = await fetch('/api/v1/config/metamask?chainId=138', { cache: 'no-store' }); + if (res.ok) { + var data = await res.json(); + window._chain138WatchAssetsCache = data && Array.isArray(data.watchAssets) ? data.watchAssets : []; + return window._chain138WatchAssetsCache; + } + } catch (e) {} + return []; + } + async function resolveTokenLogoUri(address) { if (!address) return undefined; - var tokens = await fetchChain138ReportTokenList(); var normalized = String(address).toLowerCase(); + var watchAssets = await fetchChain138WatchAssets(); + for (var w = 0; w < watchAssets.length; w++) { + var entry = watchAssets[w]; + var watchAddr = entry && entry.options && entry.options.address ? String(entry.options.address).toLowerCase() : ''; + if (watchAddr === normalized && entry.options.image) { + return entry.options.image; + } + } + var tokens = await fetchChain138ReportTokenList(); for (var i = 0; i < tokens.length; i++) { var token = tokens[i]; if (token && token.address && String(token.address).toLowerCase() === normalized) { @@ -2223,12 +2244,13 @@ return; } try { + await switchToChain138(); var meta = await fetchTokenMetadataFromChain(address); if (!meta) { - if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to the correct network and try again.', 'error'); + if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to DeFi Oracle Meta Mainnet and try again.', 'error'); return; } - var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : 'TOKEN'; + var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : (symbol || 'TOKEN'); var useName = (meta.name !== undefined && meta.name !== null) ? meta.name : (name || ''); var useDecimals = (typeof meta.decimals === 'number') ? meta.decimals : (typeof decimals === 'number' ? decimals : 18); if (useSymbol === '' || (typeof useSymbol === 'string' && useSymbol.trim() === '')) { @@ -2236,22 +2258,24 @@ return; } var logoURI = await resolveTokenLogoUri(address); + var watchOptions = { + address: address, + symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN', + decimals: useDecimals, + chainId: '0x8a' + }; + if (useName) watchOptions.name = useName; + if (logoURI) watchOptions.image = logoURI; var added = await window.ethereum.request({ method: 'wallet_watchAsset', params: { type: 'ERC20', - options: { - address: address, - symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN', - decimals: useDecimals, - name: useName || undefined, - image: logoURI - } + options: watchOptions } }); if (typeof showToast === 'function') { var displaySym = useSymbol || symbol || 'Token'; - showToast(added ? (displaySym ? displaySym + ' added to wallet' : 'Token added to wallet') : 'Add token was cancelled', added ? 'success' : 'info'); + showToast(added ? (displaySym ? displaySym + ' added to wallet on Chain 138' : 'Token added to wallet on Chain 138') : 'Add token was cancelled', added ? 'success' : 'info'); } } catch (e) { var msg = (e && e.message) ? String(e.message) : ''; @@ -2356,7 +2380,7 @@ } async function switchToChain138() { - const chainId = '0x8A'; // 138 in hex + const chainId = '0x8a'; // 138 in hex — lowercase matches MetaMask/EIP-3085 payloads try { await window.ethereum.request({ method: 'wallet_switchEthereumChain', @@ -2367,19 +2391,25 @@ if (switchError.code === 4902) { try { var chainPayload = await fetchChain138AddEthereumChainPayload(); + var fallbackPayload = { + chainId: chainId, + chainName: 'DeFi Oracle Meta Mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18 + }, + rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL], + blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'], + iconUrls: [ + 'https://explorer.d-bis.org/api/v1/report/logo/chain-138', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico' + ] + }; await window.ethereum.request({ method: 'wallet_addEthereumChain', - params: [chainPayload || { - chainId, - chainName: 'DeFi Oracle Meta Mainnet', - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18 - }, - rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL], - blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'] - }], + params: [chainPayload || fallbackPayload], }); } catch (addError) { throw addError; diff --git a/frontend/scripts/smoke-routes.mjs b/frontend/scripts/smoke-routes.mjs index f3e1aca..ba4e168 100644 --- a/frontend/scripts/smoke-routes.mjs +++ b/frontend/scripts/smoke-routes.mjs @@ -4,15 +4,22 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/ const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506' const checks = [ - { path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Network overview'] }, + { path: '/', expectTexts: ['DBIS Explorer', 'Institutional quick paths'] }, + { path: '/bridge', expectTexts: ['Bridge'] }, + { path: '/routes', expectTexts: ['Routes'] }, + { path: '/operations', expectTexts: ['Operations'] }, + { path: '/system', expectTexts: ['System'] }, + { path: '/operator', expectTexts: ['Operator'] }, + { path: '/analytics', expectTexts: ['Analytics'] }, + { path: '/liquidity', expectTexts: ['Liquidity'] }, { path: '/blocks', expectTexts: ['Blocks'] }, { path: '/transactions', expectTexts: ['Transactions'] }, - { path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] }, - { path: '/watchlist', expectTexts: ['Watchlist', 'Saved Addresses'] }, - { path: '/pools', expectTexts: ['Pools', 'Pool operation shortcuts'] }, - { path: '/liquidity', expectTexts: ['Liquidity', 'Explorer Access Points'] }, - { path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect v2 posture'] }, - { path: '/tokens', expectTexts: ['Tokens', 'Find a token'] }, + { path: '/addresses', expectTexts: ['Addresses', 'Open address'] }, + { path: '/watchlist', expectTexts: ['Watchlist', 'Saved addresses'] }, + { path: '/pools', expectTexts: ['Pools'] }, + { path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect'] }, + { path: '/access', expectTexts: ['Wallet Login', 'RPC Access'] }, + { path: '/tokens', expectTexts: ['Tokens'] }, { path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' }, { path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] }, ] @@ -26,11 +33,15 @@ async function hasShell(page) { .getByRole('link', { name: /Go to DBIS Explorer home|Go to explorer home/i }) .isVisible() .catch(() => false) - const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false) - return homeLink && supportText + const dbisTitle = await page + .getByText(/DBIS Explorer/i) + .first() + .isVisible() + .catch(() => false) + return homeLink || dbisTitle } -async function waitForBodyText(page, snippets, timeoutMs = 15000) { +async function waitForBodyText(page, snippets, timeoutMs = 20000) { if (!snippets || snippets.length === 0) { return bodyText(page) } @@ -59,7 +70,7 @@ async function main() { try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}) if (!response || !response.ok()) { console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`) diff --git a/frontend/scripts/smoke-scroll-height.mjs b/frontend/scripts/smoke-scroll-height.mjs new file mode 100644 index 0000000..6acd15d --- /dev/null +++ b/frontend/scripts/smoke-scroll-height.mjs @@ -0,0 +1,90 @@ +import { chromium } from 'playwright' + +const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, '') +const viewportHeight = Number(process.env.SMOKE_VIEWPORT_HEIGHT || 720) +const maxViewportHeights = Number(process.env.SMOKE_MAX_VIEWPORT_HEIGHTS || 2.5) + +const routeLimits = new Map( + (process.env.SMOKE_SCROLL_ROUTE_LIMITS || ' /=4,/operator=4,/transactions=3') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [path, limit] = entry.split('=') + return [path, Number(limit)] + }), +) + +function limitForRoute(path) { + return routeLimits.get(path) ?? maxViewportHeights +} + +const routes = [ + '/', + '/bridge', + '/routes', + '/operations', + '/system', + '/operator', + '/analytics', + '/liquidity', + '/blocks', + '/transactions', + '/addresses', + '/wallet', + '/access', +] + +async function main() { + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage({ + viewport: { width: 1280, height: viewportHeight }, + }) + let failures = 0 + + for (const path of routes) { + const url = `${baseUrl}${path}` + + try { + const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + + if (!response || !response.ok()) { + console.error(`FAIL ${path}: HTTP ${response ? response.status() : 'no-response'}`) + failures += 1 + continue + } + + const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight) + const routeLimit = limitForRoute(path) + const limit = viewportHeight * routeLimit + + if (scrollHeight > limit) { + console.error( + `FAIL ${path}: scrollHeight ${scrollHeight}px exceeds ${routeLimit}× viewport (${Math.round(limit)}px)`, + ) + failures += 1 + continue + } + + console.log(`OK ${path} (${scrollHeight}px)`) + } catch (error) { + console.error(`FAIL ${path}: ${error instanceof Error ? error.message : String(error)}`) + failures += 1 + } + } + + await browser.close() + + if (failures > 0) { + process.exitCode = 1 + return + } + + console.log(`Scroll-height smoke passed for ${routes.length} routes at ${viewportHeight}px viewport`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exitCode = 1 +}) diff --git a/frontend/src/components/access/AccessManagementPage.tsx b/frontend/src/components/access/AccessManagementPage.tsx index aef6afc..bc4cec6 100644 --- a/frontend/src/components/access/AccessManagementPage.tsx +++ b/frontend/src/components/access/AccessManagementPage.tsx @@ -1,7 +1,9 @@ -import { FormEvent, useCallback, useEffect, useState } from 'react' +import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import { accessApi, @@ -13,6 +15,7 @@ import { type AccessUser, type WalletAccessSession, } from '@/services/api/access' +import { formatDate, formatInteger, formatTimestamp } from '@/utils/format' const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const const OPERATOR_IDENTITIES = [ @@ -88,6 +91,18 @@ export default function AccessManagementPage() { const [createdKey, setCreatedKey] = useState('') const [message, setMessage] = useState('') const [error, setError] = useState('') + const [accessTab, setAccessTab] = useState<'products' | 'account' | 'admin'>('products') + + const accessTabs = useMemo((): SectionTab<'products' | 'account' | 'admin'>[] => { + const tabs: SectionTab<'products' | 'account' | 'admin'>[] = [ + { id: 'products', label: 'Products', count: products.length }, + { id: 'account', label: 'Account & keys' }, + ] + if (user?.is_admin) { + tabs.push({ id: 'admin', label: 'Admin' }) + } + return tabs + }, [products.length, user?.is_admin]) const clearSessionState = useCallback(() => { setUser(null) @@ -349,15 +364,15 @@ export default function AccessManagementPage() { OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug) return ( -
+
@@ -372,7 +387,17 @@ export default function AccessManagementPage() { ) : null} -
+ + + +
{products.map((product) => (
@@ -446,10 +471,13 @@ export default function AccessManagementPage() { ))}
+ -
-
- + +
+
+ +

Use a connected wallet for standard account sign-in, then access subscriptions, API keys, and managed RPC controls with the same authenticated session. @@ -467,7 +495,7 @@ export default function AccessManagementPage() { Your wallet address remains private within the access console. This session is treated as account sign-in, not a public identifier.

- Session expires {new Date(walletSession.expiresAt).toLocaleString()} + Session expires {formatTimestamp(walletSession.expiresAt)}
- - -
-
-
- ))} -
- ) : ( -

No subscriptions match the current review filter.

- )} -
- ) : null} - - {user?.is_admin ? ( - -
- - -
- {adminAuditEntries.length > 0 ? ( -
- {adminAuditEntries.map((entry) => ( -
-
- - - -
-
{entry.keyName || entry.apiKeyId}
-
- {new Date(entry.createdAt).toLocaleString()} - {entry.lastIp ? ` · ${entry.lastIp}` : ''} -
-
- ))} -
- ) : ( -

No recent validated RPC traffic matches the current filter.

- )} -
- ) : null}
@@ -758,11 +657,11 @@ export default function AccessManagementPage() { {key.approved ? : } {key.revoked ? : } - {key.expiresAt ? : } + {key.expiresAt ? : }
- Created {new Date(key.createdAt).toLocaleString()} - {key.lastUsedAt ? ` · Last used ${new Date(key.lastUsedAt).toLocaleString()}` : ' · Not used yet'} + Created {formatTimestamp(key.createdAt)} + {key.lastUsedAt ? ` · Last used ${formatTimestamp(key.lastUsedAt)}` : ' · Not used yet'}
{!key.revoked ? ( @@ -816,7 +715,7 @@ export default function AccessManagementPage() {
- {item.requests_used.toLocaleString()} requests used / {item.monthly_quota.toLocaleString()} monthly quota + {formatInteger(item.requests_used)} requests used / {formatInteger(item.monthly_quota)} monthly quota
))} @@ -851,7 +750,7 @@ export default function AccessManagementPage() {
{entry.keyName || entry.apiKeyId}
- {new Date(entry.createdAt).toLocaleString()} + {formatTimestamp(entry.createdAt)} {entry.lastIp ? ` · ${entry.lastIp}` : ''}
@@ -868,6 +767,114 @@ export default function AccessManagementPage() { + + + {user?.is_admin ? ( + +
+ +
+ +
+ {adminSubscriptions.length > 0 ? ( +
+ {adminSubscriptions.map((subscription) => ( +
+
+
+
{subscription.productSlug}
+
+ + + + {subscription.requiresApproval ? : null} +
+ +
+
+ + + +
+
+
+ ))} +
+ ) : ( +

No subscriptions match the current review filter.

+ )} +
+ +
+ + +
+ {adminAuditEntries.length > 0 ? ( +
+ {adminAuditEntries.map((entry) => ( +
+
+ + + +
+
{entry.keyName || entry.apiKeyId}
+
+ {formatTimestamp(entry.createdAt)} + {entry.lastIp ? ` · ${entry.lastIp}` : ''} +
+
+ ))} +
+ ) : ( +

No recent validated RPC traffic matches the current filter.

+ )} +
+
+
+ ) : null}
) } diff --git a/frontend/src/components/common/ActivityContextPanel.tsx b/frontend/src/components/common/ActivityContextPanel.tsx index ff6c867..84c4466 100644 --- a/frontend/src/components/common/ActivityContextPanel.tsx +++ b/frontend/src/components/common/ActivityContextPanel.tsx @@ -1,8 +1,9 @@ import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import EntityBadge from '@/components/common/EntityBadge' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import type { ChainActivityContext } from '@/utils/activityContext' -import { formatRelativeAge, formatTimestamp } from '@/utils/format' +import { formatInteger, formatTimestamp } from '@/utils/format' import { Explain, useUiMode } from './UiModeContext' function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' { @@ -58,9 +59,14 @@ export default function ActivityContextPanel({ const { mode } = useUiMode() const tone = resolveTone(context.state) const dualTimelineLabel = - context.latest_block_timestamp && context.latest_transaction_timestamp - ? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx` - : 'Dual timeline unavailable' + context.latest_block_timestamp && context.latest_transaction_timestamp ? ( + <> + ·{' '} + + + ) : ( + 'Dual timeline unavailable' + ) return ( @@ -86,11 +92,11 @@ export default function ActivityContextPanel({ {context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'} {' '} - was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '} + was . Latest visible transaction was{' '} {context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'} {' '} - {formatRelativeAge(context.latest_transaction_timestamp)}.{' '} + .{' '} {mode === 'guided' ? 'Use the block gap and last non-empty block above to tell low activity apart from an indexing issue.' : dualTimelineLabel} @@ -104,11 +110,11 @@ export default function ActivityContextPanel({ {context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'} {' '} - was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '} + was . Latest visible transaction was{' '} {context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'} {' '} - {formatRelativeAge(context.latest_transaction_timestamp)}. + .
@@ -135,7 +141,7 @@ export default function ActivityContextPanel({ ) : null} {context.block_gap_to_latest_transaction != null ? ( - Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()} + Block gap to latest visible transaction: {formatInteger(context.block_gap_to_latest_transaction)} ) : null} {context.latest_transaction_timestamp ? ( diff --git a/frontend/src/components/common/ClientRelativeTime.tsx b/frontend/src/components/common/ClientRelativeTime.tsx new file mode 100644 index 0000000..6b8b67c --- /dev/null +++ b/frontend/src/components/common/ClientRelativeTime.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useState } from 'react' +import { formatRelativeAgeAt } from '@/utils/format' + +const PLACEHOLDER = '…' + +export default function ClientRelativeTime({ + value, + prefix = '', + suffix = '', + fallback = 'Unknown', + className, +}: { + value?: string | null + prefix?: string + suffix?: string + fallback?: string + className?: string +}) { + const [label, setLabel] = useState(PLACEHOLDER) + + useEffect(() => { + if (!value) { + setLabel(fallback) + return + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) { + setLabel(fallback) + return + } + const update = () => setLabel(formatRelativeAgeAt(Date.now(), value)) + update() + const id = window.setInterval(update, 30_000) + return () => window.clearInterval(id) + }, [value, fallback]) + + const content = label === PLACEHOLDER ? PLACEHOLDER : label + if (className) { + return ( + + {prefix} + {content} + {suffix} + + ) + } + + return ( + + {prefix} + {content} + {suffix} + + ) +} diff --git a/frontend/src/components/common/CompactMetricBar.tsx b/frontend/src/components/common/CompactMetricBar.tsx new file mode 100644 index 0000000..56578bf --- /dev/null +++ b/frontend/src/components/common/CompactMetricBar.tsx @@ -0,0 +1,29 @@ +'use client' + +export type CompactMetric = { + label: string + value: string +} + +export default function CompactMetricBar({ + metrics, + className = 'mb-4', +}: { + metrics: CompactMetric[] + className?: string +}) { + if (metrics.length === 0) return null + + return ( +
+ {metrics.map((metric) => ( + + {metric.label}:{' '} + {metric.value} + + ))} +
+ ) +} diff --git a/frontend/src/components/common/DisclosureSection.tsx b/frontend/src/components/common/DisclosureSection.tsx new file mode 100644 index 0000000..ae18cbb --- /dev/null +++ b/frontend/src/components/common/DisclosureSection.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useId, useState, type ReactNode } from 'react' + +interface DisclosureSectionProps { + title: string + children: ReactNode + defaultOpen?: boolean + className?: string + headingClassName?: string + /** When true, content is always visible and the toggle is hidden (desktop layout). */ + forceOpen?: boolean + /** When true, keep accordion behavior on all breakpoints (no md:auto-expand). */ + alwaysCollapsible?: boolean +} + +export default function DisclosureSection({ + title, + children, + defaultOpen = false, + className = '', + headingClassName = '', + forceOpen = false, + alwaysCollapsible = false, +}: DisclosureSectionProps) { + const [open, setOpen] = useState(defaultOpen || forceOpen) + const panelId = useId().replace(/:/g, '') + const isOpen = forceOpen || open + + return ( +
+ {forceOpen ? ( +
+ {title} +
+ ) : ( + + )} + +
+ {!forceOpen && !alwaysCollapsible ? ( +
+ {title} +
+ ) : null} + {children} +
+
+ ) +} diff --git a/frontend/src/components/common/ExplorerFreshnessDisclosure.tsx b/frontend/src/components/common/ExplorerFreshnessDisclosure.tsx new file mode 100644 index 0000000..5a4b90f --- /dev/null +++ b/frontend/src/components/common/ExplorerFreshnessDisclosure.tsx @@ -0,0 +1,43 @@ +'use client' + +import type { ReactNode } from 'react' +import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import type { ExplorerStats } from '@/services/api/stats' +import type { ChainActivityContext } from '@/utils/activityContext' +import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import DisclosureSection from '@/components/common/DisclosureSection' +import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' + +export default function ExplorerFreshnessDisclosure({ + context, + stats, + bridgeStatus, + scopeLabel, + activityTitle = 'Recency', + title = 'Index freshness', + className = 'mb-4', + children, +}: { + context: ChainActivityContext + stats?: ExplorerStats | null + bridgeStatus?: MissionControlBridgeStatusResponse | null + scopeLabel: string + activityTitle?: string + title?: string + className?: string + children?: ReactNode +}) { + return ( + + + + {children} + + ) +} diff --git a/frontend/src/components/common/Footer.tsx b/frontend/src/components/common/Footer.tsx index 75ade5f..d9a6202 100644 --- a/frontend/src/components/common/Footer.tsx +++ b/frontend/src/components/common/Footer.tsx @@ -1,4 +1,6 @@ import Link from 'next/link' +import DisclosureSection from '@/components/common/DisclosureSection' +import FooterLinkGroups from '@/components/common/FooterLinkGroups' import FooterPublicApiLinks from '@/components/common/FooterPublicApiLinks' const footerLinkClass = @@ -8,96 +10,53 @@ export default function Footer() { const year = new Date().getFullYear() return ( -
-
-
-
-
- DBIS Explorer -
-

- Built on Blockscout for the DBIS Chain 138 explorer surface. - Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap. -

-

- Primary public explorer access is served at explorer.d-bis.org. - blockscout.defi-oracle.io is the Blockscout companion domain for the same Chain 138 explorer surface. -

-

- © {year} DBIS. All rights reserved. +

+
+
+
+
DBIS Explorer
+

+ Chain 138 explorer on Blockscout. Primary domain: explorer.d-bis.org.

+

© {year} DBIS

-
-
- Resources -
-
    -
  • Search
  • -
  • Documentation
  • -
  • Blocks
  • -
  • Transactions
  • -
  • Tokens
  • -
  • Addresses
  • -
  • Watchlist
  • -
  • Account access
  • -
  • Wallet tools
  • -
  • Operations hub
  • -
  • Bridge
  • -
  • Routes
  • -
  • Liquidity
  • -
  • Pools
  • -
  • Protocols
  • -
  • Analytics
  • -
  • Operator
  • -
  • System
  • -
  • WETH
  • -
  • Privacy Policy
  • -
  • Terms of Service
  • -
  • Acknowledgments
  • -
+
+
+
+ + + -
-
- Public APIs -
- -

- Read-only JSON endpoints on the public explorer domain. No API key required. -

-
+
+ + + -
-
- Contact -
-
+ +

- Support:{' '} support@d-bis.org

- Snap site:{' '} + + Chain 138 map + + {' · '} - /snap/ on the current explorer domain + Snap site

-

- Command center:{' '} - - Chain 138 visual map - -

-

- Questions about the explorer, chain metadata, route discovery, or liquidity access - can be sent to the support mailbox above. -

-
+
diff --git a/frontend/src/components/common/FooterLinkGroups.tsx b/frontend/src/components/common/FooterLinkGroups.tsx new file mode 100644 index 0000000..806ef91 --- /dev/null +++ b/frontend/src/components/common/FooterLinkGroups.tsx @@ -0,0 +1,46 @@ +'use client' + +import Link from 'next/link' +import { footerNavGroups, type FooterNavLink } from '@/data/footerNav' + +const footerLinkClass = + 'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors' + +function FooterNavLinkItem({ link }: { link: FooterNavLink }) { + if (link.external) { + return ( +
  • + + {link.label} + +
  • + ) + } + + return ( +
  • + + {link.label} + +
  • + ) +} + +export default function FooterLinkGroups() { + return ( +
    + {footerNavGroups.map((group) => ( +
    +
    + {group.title} +
    +
      + {group.links.map((link) => ( + + ))} +
    +
    + ))} +
    + ) +} diff --git a/frontend/src/components/common/FooterPublicApiLinks.tsx b/frontend/src/components/common/FooterPublicApiLinks.tsx index 0b0cce8..eb8137e 100644 --- a/frontend/src/components/common/FooterPublicApiLinks.tsx +++ b/frontend/src/components/common/FooterPublicApiLinks.tsx @@ -12,7 +12,7 @@ function absoluteApiUrl(href: string): string { return `${window.location.origin}${href.startsWith('/') ? href : `/${href}`}` } -export default function FooterPublicApiLinks() { +export default function FooterPublicApiLinks({ compact = false }: { compact?: boolean }) { const [copiedHref, setCopiedHref] = useState(null) const copyUrl = async (href: string) => { @@ -27,15 +27,23 @@ export default function FooterPublicApiLinks() { } return ( -
      +
        {explorerPublicApiLinks.map((link) => (
      • -
        +
        - + {link.label} -

        {link.description}

        + {!compact ? ( +

        {link.description}

        + ) : null}
        diff --git a/frontend/src/components/common/SubsystemPosturePanel.tsx b/frontend/src/components/common/SubsystemPosturePanel.tsx index 0f21188..68866f8 100644 --- a/frontend/src/components/common/SubsystemPosturePanel.tsx +++ b/frontend/src/components/common/SubsystemPosturePanel.tsx @@ -1,7 +1,7 @@ import { Card } from '@/libs/frontend-ui-primitives' import { useUiMode } from './UiModeContext' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import type { MissionControlSubsystemStatus } from '@/services/api/missionControl' -import { formatRelativeAge } from '@/utils/format' function subsystemLabel(key: string): string { const labels: Record = { @@ -107,7 +107,13 @@ export default function SubsystemPosturePanel({
        - {subsystem.updated_at ? `Updated ${formatRelativeAge(subsystem.updated_at)}` : 'Update time unavailable'} + {subsystem.updated_at ? ( + <> + Updated + + ) : ( + 'Update time unavailable' + )}
        {compactMeta(subsystem)} diff --git a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx index 512d448..f2778f9 100644 --- a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx +++ b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx @@ -17,16 +17,18 @@ import { import { transactionsApi, type Transaction } from '@/services/api/transactions' import { formatWeiAsEth } from '@/utils/format' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import SectionTabs, { TabPanel } from '@/components/common/SectionTabs' import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import OperationsPageShell, { MetricCard, StatusBadge, formatNumber, - relativeAge, truncateMiddle, } from './OperationsPageShell' @@ -61,6 +63,7 @@ export default function AnalyticsOperationsPage({ const [transactions, setTransactions] = useState(initialTransactions) const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [loadingError, setLoadingError] = useState(null) + const [analyticsTab, setAnalyticsTab] = useState<'trend' | 'blocks' | 'transactions'>('trend') const page = explorerFeaturePages.analytics useEffect(() => { @@ -148,58 +151,36 @@ export default function AnalyticsOperationsPage({ ) : null} -
        - - + -
        + -
        - - - - - + + + +
        -
        +
        + -
        + + +
        -
        -
        -
        Peak Day
        -
        - {topDay ? formatNumber(topDay.transaction_count) : 'Unknown'} -
        -
        {topDay?.date || 'No trend data yet'}
        -
        -
        -
        Contract Creations
        -
        - {formatNumber(activitySnapshot?.contract_creations)} -
        -
        Within the sampled recent transaction feed.
        -
        -
        -
        Avg Sample Fee
        -
        - {activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'} -
        -
        Average fee from the recent public transaction sample.
        -
        -
        + + {activitySnapshot ? (
        -
        -
        Token Transfer Share
        -
        +
        +
        Token transfers
        +
        {Math.round(activitySnapshot.token_transfer_share * 100)}%
        -
        Sampled transactions involving token transfers.
        -
        -
        Contract Call Share
        -
        +
        +
        Contract calls
        +
        {Math.round(activitySnapshot.contract_call_share * 100)}%
        -
        Sampled transactions calling contracts.
        -
        -
        Creation Share
        -
        +
        +
        Creations
        +
        {Math.round(activitySnapshot.contract_creation_share * 100)}%
        -
        Sampled transactions deploying contracts.
        - ) : null} + ) : ( +

        No sample breakdown available.

        + )} + -
        +
        {trailingWindow.map((point) => { const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0 return ( @@ -298,9 +283,11 @@ export default function AnalyticsOperationsPage({
        + + -
        +
        {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (

        Recent head blocks are currently empty; use the latest transaction block for recent visible activity. @@ -309,22 +296,22 @@ export default function AnalyticsOperationsPage({ {blocks.map((block) => (

        -
        +
        - + Block {formatNumber(block.number)} -
        - {truncateMiddle(block.hash)} · miner{' '} +
        + {truncateMiddle(block.hash)} ·{' '} {truncateMiddle(block.miner)}
        -
        - {formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {relativeAge(block.timestamp)} +
        + {formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas ·
        @@ -334,20 +321,22 @@ export default function AnalyticsOperationsPage({ ) : null}
        + + -
        +
        {transactions.map((transaction) => (
        -
        +
        - + {truncateMiddle(transaction.hash, 12, 10)} -
        +
        Block{' '} {formatNumber(transaction.block_number)} @@ -366,23 +355,25 @@ export default function AnalyticsOperationsPage({ ) : null}
        -
        +
        -
        - {relativeAge(transaction.created_at)} +
        +
        -
        - {transaction.method ? : null} - {transaction.contract_address ? : null} - {transaction.token_transfers && transaction.token_transfers.length > 0 ? ( - - ) : null} -
        + {(transaction.method || transaction.contract_address || (transaction.token_transfers && transaction.token_transfers.length > 0)) ? ( +
        + {transaction.method ? : null} + {transaction.contract_address ? : null} + {transaction.token_transfers && transaction.token_transfers.length > 0 ? ( + + ) : null} +
        + ) : null}
        ))} {transactions.length === 0 ? ( @@ -390,7 +381,7 @@ export default function AnalyticsOperationsPage({ ) : null}
        -
        + ) } diff --git a/frontend/src/components/explorer/BridgeMonitoringPage.tsx b/frontend/src/components/explorer/BridgeMonitoringPage.tsx index 49bda20..5284326 100644 --- a/frontend/src/components/explorer/BridgeMonitoringPage.tsx +++ b/frontend/src/components/explorer/BridgeMonitoringPage.tsx @@ -12,8 +12,10 @@ import { import { statsApi, type ExplorerStats } from '@/services/api/stats' import { explorerFeaturePages } from '@/data/explorerOperations' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import PaginationControls from '@/components/common/PaginationControls' import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' @@ -23,6 +25,7 @@ import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens' import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel' import OperationsSurfaceNav from './OperationsSurfaceNav' import OperationsActionGrid from './OperationsActionGrid' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' type FeedState = 'connecting' | 'live' | 'fallback' @@ -36,7 +39,7 @@ interface RelayLaneCard { queueSize: number processed: number failed: number - lastPolled: string + lastPolledAt?: string | null bridgeAddress: string issueScope: string | null issueMessage: string | null @@ -46,18 +49,7 @@ interface RelayLaneCard { } const relayOrder = ['mainnet_cw', 'mainnet_weth', 'bsc', 'avax', 'avax_cw', 'avax_to_138'] - -function relativeAge(isoString?: string): string { - if (!isoString) return 'Unknown' - const parsed = Date.parse(isoString) - if (!Number.isFinite(parsed)) return 'Unknown' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) - return `${hours}h ago` -} +const BRIDGE_RELAY_PAGE_SIZE = 4 function shortAddress(value?: string): string { if (!value) return 'Unspecified' @@ -155,6 +147,7 @@ export default function BridgeMonitoringPage({ const [stats, setStats] = useState(initialStats) const [bridgeRoutes, setBridgeRoutes] = useState(null) const [feedState, setFeedState] = useState(initialBridgeStatus ? 'fallback' : 'connecting') + const [relayPage, setRelayPage] = useState(1) const page = explorerFeaturePages.bridge useEffect(() => { @@ -285,7 +278,7 @@ export default function BridgeMonitoringPage({ queueSize: snapshot?.queue?.size ?? 0, processed: snapshot?.queue?.processed ?? 0, failed: snapshot?.queue?.failed ?? 0, - lastPolled: relativeAge(snapshot?.last_source_poll?.at), + lastPolledAt: snapshot?.last_source_poll?.at || null, bridgeAddress: snapshot?.destination?.relay_bridge_default || snapshot?.destination?.relay_bridge || @@ -305,118 +298,91 @@ export default function BridgeMonitoringPage({ }) }, [bridgeStatus]) + useEffect(() => { + setRelayPage(1) + }, [relayLanes.length]) + + const relayPageCount = relayLanes.length ? Math.max(1, Math.ceil(relayLanes.length / BRIDGE_RELAY_PAGE_SIZE)) : 1 + const visibleRelayLanes = relayLanes.slice( + (relayPage - 1) * BRIDGE_RELAY_PAGE_SIZE, + relayPage * BRIDGE_RELAY_PAGE_SIZE, + ) + const chainStatus = bridgeStatus?.data?.chains?.['138'] const overallStatus = bridgeStatus?.data?.status || 'unknown' - const checkedAt = relativeAge(bridgeStatus?.data?.checked_at) return ( -
        -
        -
        +
        +
        +
        {page.eyebrow}
        -

        +

        {page.title}

        -

        +

        {page.description}

        {page.note ? ( - -

        - {page.note} -

        -
        + + +

        + {page.note} +

        +
        +
        ) : null} -
        - - + -
        + -
        - -
        - Relay Fleet -
        -
        - {overallStatus} -
        -
        - {relayLanes.length} managed lanes visible -
        -
        - Feed: {feedState === 'live' ? 'Live SSE' : feedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'} -
        -
        + - -
        - Chain 138 RPC -
        -
        - {chainStatus?.status || 'unknown'} -
        -
        - Head age: {chainStatus?.head_age_sec != null ? `${chainStatus.head_age_sec.toFixed(1)}s` : 'Unknown'} -
        -
        - Latency: {chainStatus?.latency_ms != null ? `${chainStatus.latency_ms}ms` : 'Unknown'} -
        -
        - - -
        - Last Check -
        -
        - {checkedAt} -
        -
        - Public status JSON and live stream are both active. -
        -
        - -
        -
        -
        - -
        - {relayLanes.map((lane) => ( + +
        + {visibleRelayLanes.map((lane) => (
        -
        +
        {lane.label}
        -
        +
        {`${lane.sourceChain} -> ${lane.destinationChain}`}
        -
        +
        {lane.status}
        -
        +
        Profile
        {lane.profile}
        @@ -436,7 +402,7 @@ export default function BridgeMonitoringPage({
        - Last polled: {lane.lastPolled} + Last polled:
        Bridge: {shortAddress(lane.bridgeAddress)} @@ -460,23 +426,38 @@ export default function BridgeMonitoringPage({ ))}
        + {relayLanes.length > BRIDGE_RELAY_PAGE_SIZE ? ( + + ) : null} + - + + + {routeEntries.length > 0 ? ( - + +

        Destination bridge contracts from{' '} /token-aggregation/api/v1/bridge/routes - {bridgeRoutes?.source ? ( - <> - {' '} - (source: {bridgeRoutes.source} - {bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''}) - - ) : null} + {bridgeRoutes?.lastModified ? ( + <> + {' '} + (source: {bridgeRoutes.source} · updated ) + + ) : bridgeRoutes?.source ? ( + <> (source: {bridgeRoutes.source}) + ) : null} . Gnosis, Cronos, Celo, and Wemix lanes are aligned to deployed CCIP receivers — fund LINK on each remote bridge before live traffic.

        @@ -508,9 +489,10 @@ export default function BridgeMonitoringPage({

        + ) : null} - +
        ) } diff --git a/frontend/src/components/explorer/FeatureLandingPage.tsx b/frontend/src/components/explorer/FeatureLandingPage.tsx index 6cc9a68..194b952 100644 --- a/frontend/src/components/explorer/FeatureLandingPage.tsx +++ b/frontend/src/components/explorer/FeatureLandingPage.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' +import DisclosureSection from '@/components/common/DisclosureSection' import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations' function ActionLink({ action }: { action: ExplorerFeatureAction }) { @@ -23,44 +24,42 @@ function ActionLink({ action }: { action: ExplorerFeatureAction }) { export default function FeatureLandingPage({ page }: { page: ExplorerFeaturePage }) { return ( -
        -
        -
        +
        +
        +
        {page.eyebrow}
        -

        +

        {page.title}

        -

        +

        {page.description}

        {page.note ? ( - -

        - {page.note} -

        -
        + + +

        {page.note}

        +
        +
        ) : null} -
        - {page.actions.map((action) => ( - -
        -
        - {action.title} + +
        + {page.actions.map((action) => ( + +
        +
        {action.title}
        +

        {action.description}

        +
        + +
        -

        - {action.description} -

        -
        - -
        -
        - - ))} -
        + + ))} +
        +
        ) } diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx index 1e1fbbb..70f4570 100644 --- a/frontend/src/components/explorer/LiquidityOperationsPage.tsx +++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx @@ -20,18 +20,20 @@ import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { statsApi, type ExplorerStats } from '@/services/api/stats' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import PageIntro from '@/components/common/PageIntro' +import DisclosureSection from '@/components/common/DisclosureSection' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' -import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' import OperationsSurfaceNav from './OperationsSurfaceNav' import LpPositionPanel from '@/components/wallet/LpPositionPanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import { formatCurrency, formatNumber, - relativeAge, truncateMiddle, } from './OperationsPageShell' @@ -83,6 +85,7 @@ export default function LiquidityOperationsPage({ const [loadingError, setLoadingError] = useState(null) const [reloadKey, setReloadKey] = useState(0) const [copiedEndpoint, setCopiedEndpoint] = useState(null) + const [activeTab, setActiveTab] = useState<'pools' | 'routes' | 'api'>('pools') useEffect(() => { let cancelled = false @@ -273,22 +276,27 @@ export default function LiquidityOperationsPage({ } } + const liquidityTabs = useMemo[]>( + () => [ + { id: 'pools', label: 'Pools', count: aggregatedPools.length }, + { id: 'routes', label: 'Routes', count: highlightedRoutes.length }, + { id: 'api', label: 'API & tools' }, + ], + [aggregatedPools.length, highlightedRoutes.length], + ) + return ( -
        -
        -
        - Liquidity -
        -

        - Public liquidity, route discovery, and execution access points -

        -

        - This page now reads the live explorer APIs instead of hardcoded pool snapshots. It pulls the - public route matrix, planner capabilities, and mission-control token pool inventory together - so integrators can inspect what Chain 138 is actually serving right now. -

        - -
        +
        + @@ -303,25 +311,34 @@ export default function LiquidityOperationsPage({ /> ) : null} -
        - - + -
        + -
        + + + +
        Live Pools @@ -369,11 +386,22 @@ export default function LiquidityOperationsPage({
        +
        -
        + + + +
        -
        - {aggregatedPools.slice(0, 8).map((pool) => ( +
        + {aggregatedPools.slice(0, 5).map((pool) => (
        -
        + + + +

        + Connect a wallet on the{' '} + + Wallet + {' '} + page to scan your LP shares and estimated USD NAV. LP tokens are chain-local — bridge underlying c*/cW* + instead. +

        +
        +
        + + + +
        {highlightedRoutes.map((route) => ( @@ -486,22 +530,10 @@ export default function LiquidityOperationsPage({
        +
        -
        - - -

        - Connect a wallet on the{' '} - - Wallet - {' '} - page to scan your LP shares and estimated USD NAV. LP tokens are chain-local — bridge underlying c*/cW* - instead. -

        -
        -
        - -
        + +
        {endpointCards.map((endpoint) => ( @@ -567,7 +599,7 @@ export default function LiquidityOperationsPage({ view, and this page for the broader route and execution surfaces.

        - The live route matrix was updated {relativeAge(routeMatrix?.updated)}, and the current + The live route matrix was updated , and the current route-backed pool set references {formatNumber(routeBackedPoolAddresses.length)} unique pool addresses.

        @@ -594,6 +626,7 @@ export default function LiquidityOperationsPage({
        +
        ) } diff --git a/frontend/src/components/explorer/OperationsActionGrid.tsx b/frontend/src/components/explorer/OperationsActionGrid.tsx index f250d61..4665068 100644 --- a/frontend/src/components/explorer/OperationsActionGrid.tsx +++ b/frontend/src/components/explorer/OperationsActionGrid.tsx @@ -38,14 +38,16 @@ function ActionCard({ action }: { action: ExplorerFeatureAction }) { export default function OperationsActionGrid({ actions, title = 'Quick actions', + className = '', }: { actions: ExplorerFeatureAction[] title?: string + className?: string }) { if (actions.length === 0) return null return ( - <> +
        @@ -68,6 +70,6 @@ export default function OperationsActionGrid({ ))}
        - +
        ) } diff --git a/frontend/src/components/explorer/OperationsHubPage.tsx b/frontend/src/components/explorer/OperationsHubPage.tsx index 8bbf96b..26b676c 100644 --- a/frontend/src/components/explorer/OperationsHubPage.tsx +++ b/frontend/src/components/explorer/OperationsHubPage.tsx @@ -8,28 +8,18 @@ import { getMissionControlRelays, missionControlApi, type MissionControlBridgeSt import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' import { useUiMode } from '@/components/common/UiModeContext' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' -import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' -import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' +import PageIntro from '@/components/common/PageIntro' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import OperationsActionGrid from '@/components/explorer/OperationsActionGrid' import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' +import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import { statsApi, type ExplorerStats } from '@/services/api/stats' -function relativeAge(isoString?: string): string { - if (!isoString) return 'Unknown' - const parsed = Date.parse(isoString) - if (!Number.isFinite(parsed)) return 'Unknown' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) - return `${hours}h ago` -} - function ActionLink({ href, label, @@ -192,119 +182,67 @@ export default function OperationsHubPage({ ) return ( -
        -
        -
        - {page.eyebrow} -
        -

        - {page.title} -

        -

        - {page.description} -

        - -
        +
        + {page.note ? ( - -

        + +

        {page.note}

        -
        + ) : null} {loadingError ? ( - +

        {loadingError}

        ) : null} -
        - - + -
        + -
        - -
        - Bridge Fleet -
        -
        - {bridgeStatus?.data?.status || 'unknown'} -
        -
        - {relayCount} managed lanes · queue {totalQueue} -
        - {laneSummary ? ( -
        - {laneSummary.funded}/{laneSummary.total} config-ready funded - {laneSummary.unfunded > 0 ? ` · ${laneSummary.unfunded} unfunded` : ''} - {' · '} - {laneSummary.proofRecorded}/{laneSummary.total} proof-recorded -
        - ) : null} -
        + - -
        - Route Coverage -
        -
        - {routeMatrix?.counts?.filteredLiveRoutes ?? 0} -
        -
        - {routeMatrix?.counts?.liveSwapRoutes ?? 0} swaps · {routeMatrix?.counts?.liveBridgeRoutes ?? 0} bridges -
        -
        - - -
        - Wallet Surface -
        -
        - {networksConfig?.chains?.length ?? 0} -
        -
        - Chains · {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks -
        -
        - - -
        - RPC Capabilities -
        -
        - {capabilities?.http?.supportedMethods?.length ?? 0} -
        -
        - HTTP methods · {capabilities?.tracing?.supportedMethods?.length ?? 0} tracing methods -
        -
        -
        - -
        +
        Bridge checked
        - {relativeAge(bridgeStatus?.data?.checked_at)} +
        {mode === 'guided' ? 'Public mission-control snapshot freshness.' : 'Mission-control freshness.'} @@ -313,7 +251,7 @@ export default function OperationsHubPage({
        Route matrix updated
        - {relativeAge(routeMatrix?.updated)} +
        {mode === 'guided' ? 'Token-aggregation route inventory timestamp.' : 'Route inventory timestamp.'} @@ -385,9 +323,11 @@ export default function OperationsHubPage({
        -
        + - + + +
        diff --git a/frontend/src/components/explorer/OperationsPageShell.tsx b/frontend/src/components/explorer/OperationsPageShell.tsx index 39111d3..f41dcf7 100644 --- a/frontend/src/components/explorer/OperationsPageShell.tsx +++ b/frontend/src/components/explorer/OperationsPageShell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import { Card } from '@/libs/frontend-ui-primitives' +import DisclosureSection from '@/components/common/DisclosureSection' import type { ExplorerFeaturePage } from '@/data/explorerOperations' import OperationsSurfaceNav from './OperationsSurfaceNav' import OperationsActionGrid from './OperationsActionGrid' @@ -7,18 +8,6 @@ import OperationsTrackNote from './OperationsTrackNote' export type StatusTone = 'normal' | 'warning' | 'danger' -export function relativeAge(isoString?: string): string { - if (!isoString) return 'Unknown' - const parsed = Date.parse(isoString) - if (!Number.isFinite(parsed)) return 'Unknown' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) - return `${hours}h ago` -} - export function formatNumber(value?: number | null): string { if (typeof value !== 'number' || !Number.isFinite(value)) return '0' return new Intl.NumberFormat('en-US').format(value) @@ -85,28 +74,38 @@ export function MetricCard({ export default function OperationsPageShell({ page, children, + compact = true, }: { page: ExplorerFeaturePage children: ReactNode + compact?: boolean }) { return ( -
        -
        -
        +
        +
        +
        {page.eyebrow}
        -

        +

        {page.title}

        -

        +

        {page.description}

        {page.note ? ( - -

        {page.note}

        -
        + compact ? ( + + +

        {page.note}

        +
        +
        + ) : ( + +

        {page.note}

        +
        + ) ) : null} {page.accessTrack && page.accessNote ? ( @@ -117,7 +116,13 @@ export default function OperationsPageShell({ {children} - + {compact ? ( + + + + ) : ( + + )}
        ) } diff --git a/frontend/src/components/explorer/OperatorOperationsPage.tsx b/frontend/src/components/explorer/OperatorOperationsPage.tsx index 01883d7..e0241a3 100644 --- a/frontend/src/components/explorer/OperatorOperationsPage.tsx +++ b/frontend/src/components/explorer/OperatorOperationsPage.tsx @@ -9,11 +9,13 @@ import { } from '@/services/api/missionControl' import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner' import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import PaginationControls from '@/components/common/PaginationControls' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import OperationsPageShell, { - MetricCard, StatusBadge, formatNumber, - relativeAge, truncateMiddle, } from './OperationsPageShell' @@ -31,6 +33,8 @@ function relayTone(status?: string): 'normal' | 'warning' | 'danger' { return 'normal' } +const OPERATOR_RELAY_PAGE_SIZE = 4 + export default function OperatorOperationsPage({ initialBridgeStatus = null, initialRouteMatrix = null, @@ -42,6 +46,8 @@ export default function OperatorOperationsPage({ const [plannerCapabilities, setPlannerCapabilities] = useState(initialPlannerCapabilities) const [internalPlan, setInternalPlan] = useState(initialInternalPlan) const [loadingError, setLoadingError] = useState(null) + const [activeTab, setActiveTab] = useState<'relays' | 'readiness'>('relays') + const [relayPage, setRelayPage] = useState(1) const page = explorerFeaturePages.operator useEffect(() => { @@ -84,16 +90,25 @@ export default function OperatorOperationsPage({ const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus]) const relayEntries = useMemo(() => Object.entries(relays || {}), [relays]) - const totalQueue = useMemo( - () => - relayEntries.reduce((sum, [, relay]) => { - const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0 - return sum + queueSize - }, 0), - [relayEntries] + + useEffect(() => { + setRelayPage(1) + }, [relayEntries.length]) + + const relayPageCount = relayEntries.length ? Math.max(1, Math.ceil(relayEntries.length / OPERATOR_RELAY_PAGE_SIZE)) : 1 + const relayVisibleEntries = relayEntries.slice( + (relayPage - 1) * OPERATOR_RELAY_PAGE_SIZE, + relayPage * OPERATOR_RELAY_PAGE_SIZE, ) const providers = plannerCapabilities?.providers || [] const liveProviders = providers.filter((provider) => provider.live) + const operatorTabs = useMemo[]>( + () => [ + { id: 'relays', label: 'Relay lanes', count: relayEntries.length }, + { id: 'readiness', label: 'Readiness' }, + ], + [relayEntries.length], + ) return ( @@ -103,39 +118,28 @@ export default function OperatorOperationsPage({ ) : null} -
        - - - - -
        + -
        + + +
        - {relayEntries.map(([key, relay]) => { + {relayVisibleEntries.map(([key, relay]) => { const snapshot = relay.url_probe?.body || relay.file_snapshot const status = snapshot?.status || 'unknown' return ( @@ -155,7 +159,7 @@ export default function OperatorOperationsPage({
        - Last source poll {relativeAge(snapshot?.last_source_poll?.at)} · processed {formatNumber(snapshot?.queue?.processed ?? 0)} + Last source poll · processed {formatNumber(snapshot?.queue?.processed ?? 0)}
        ) @@ -163,9 +167,21 @@ export default function OperatorOperationsPage({ {relayEntries.length === 0 ? (

        No relay lane data available.

        ) : null} + {relayPageCount > 1 ? ( + + ) : null}
        + +
        @@ -199,7 +215,7 @@ export default function OperatorOperationsPage({
        -
        + ) } diff --git a/frontend/src/components/explorer/PoolsOperationsPage.tsx b/frontend/src/components/explorer/PoolsOperationsPage.tsx index 002093b..703e646 100644 --- a/frontend/src/components/explorer/PoolsOperationsPage.tsx +++ b/frontend/src/components/explorer/PoolsOperationsPage.tsx @@ -3,6 +3,12 @@ import { useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' +import DisclosureSection from '@/components/common/DisclosureSection' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import ListFilterBar from '@/components/common/ListFilterBar' +import PageIntro from '@/components/common/PageIntro' +import PaginationControls from '@/components/common/PaginationControls' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { type TokenListResponse } from '@/services/api/config' import { tokensApi } from '@/services/api/tokens' import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' @@ -15,6 +21,26 @@ import { import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes' import { formatCurrency, formatNumber, truncateMiddle } from './OperationsPageShell' +const POOLS_PAGE_SIZE = 6 + +type PoolsPageTab = 'overview' | 'pools' | 'tokens' + +function matchesPoolQuery(pool: { address?: string | null; token0?: { symbol?: string | null }; token1?: { symbol?: string | null }; dex?: string | null; sourceSymbols?: string[] }, query: string) { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return true + const haystack = [ + pool.address, + pool.token0?.symbol, + pool.token1?.symbol, + pool.dex, + ...(pool.sourceSymbols || []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + return haystack.includes(normalizedQuery) +} + interface TokenPoolRecord { symbol: string pools: MissionControlLiquidityPool[] @@ -25,6 +51,10 @@ export default function PoolsOperationsPage() { const [routeMatrix, setRouteMatrix] = useState(null) const [tokenPoolRecords, setTokenPoolRecords] = useState([]) const [loadingError, setLoadingError] = useState(null) + const [activeTab, setActiveTab] = useState('pools') + const [poolQuery, setPoolQuery] = useState('') + const [poolPage, setPoolPage] = useState(1) + const [tokenQuery, setTokenQuery] = useState('') useEffect(() => { let cancelled = false @@ -88,23 +118,63 @@ export default function PoolsOperationsPage() { () => getRouteBackedPoolAddresses(routeMatrix), [routeMatrix] ) - const topPools = aggregatedPools.slice(0, 9) + + const filteredPools = useMemo( + () => aggregatedPools.filter((pool) => matchesPoolQuery(pool, poolQuery)), + [aggregatedPools, poolQuery], + ) + + const poolPageCount = Math.max(1, Math.ceil(filteredPools.length / POOLS_PAGE_SIZE)) + + const paginatedPools = useMemo(() => { + const safePage = Math.min(Math.max(poolPage, 1), poolPageCount) + const start = (safePage - 1) * POOLS_PAGE_SIZE + return filteredPools.slice(start, start + POOLS_PAGE_SIZE) + }, [filteredPools, poolPage, poolPageCount]) + + const filteredFeaturedTokens = useMemo(() => { + const normalizedQuery = tokenQuery.trim().toLowerCase() + if (!normalizedQuery) return featuredTokens + return featuredTokens.filter((token) => { + const haystack = [token.symbol, token.name, token.address].filter(Boolean).join(' ').toLowerCase() + return haystack.includes(normalizedQuery) + }) + }, [featuredTokens, tokenQuery]) + + useEffect(() => { + setPoolPage(1) + }, [poolQuery]) + + useEffect(() => { + if (poolPage > poolPageCount) { + setPoolPage(poolPageCount) + } + }, [poolPage, poolPageCount]) + + const poolTabs = useMemo[]>( + () => [ + { id: 'overview', label: 'Overview' }, + { id: 'pools', label: 'Pool cards', count: aggregatedPools.length }, + { id: 'tokens', label: 'By token', count: featuredTokens.length }, + ], + [aggregatedPools.length, featuredTokens.length], + ) return ( -
        -
        -
        - Live Pool Inventory -
        -

        - Pools -

        -

        - This page now summarizes the live pool inventory discovered through mission-control token - pool endpoints and cross-checks it against the current route matrix. -

        - -
        +
        + + + + @@ -114,7 +184,27 @@ export default function PoolsOperationsPage() { ) : null} -
        + record.pools.length > 0).length) }, + ]} + /> + + + + + +
        Unique pools @@ -147,14 +237,58 @@ export default function PoolsOperationsPage() {
        +
        -
        + +
        +

        + The broader liquidity page now shows live route, planner, and pool access together. +

        +

        + The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live + swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes. +

        +
        + + Open liquidity access + + + Open routes page + + + Open wallet tools + +
        +
        +
        + + + +
        +
        - {topPools.map((pool) => ( -
        ( +
        {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} @@ -168,76 +302,90 @@ export default function PoolsOperationsPage() {
        Seen from {pool.sourceSymbols.join(', ')}
        -
        + ))} - {topPools.length === 0 ? ( -

        No live pools available right now.

        + {paginatedPools.length === 0 ? ( +

        + {aggregatedPools.length === 0 ? 'No live pools available right now.' : 'No pools match your filter.'} +

        ) : null}
        +
        + -
        + +
        -
        - {featuredTokens.map((token) => { + +
        + {filteredFeaturedTokens.length === 0 ? ( +

        No featured tokens match your filter.

        + ) : ( + filteredFeaturedTokens.map((token) => { const record = tokenPoolRecords.find((entry) => entry.symbol === token.symbol) return ( -
        -
        +
        -
        - {token.symbol} -
        -
        - {token.name || 'Unnamed token'} -
        -
        -
        - {formatNumber(record?.pools.length || 0)} pools +
        {token.symbol}
        +
        {token.name || 'Unnamed token'}
        + + Open token → +
        -
        + {record && record.pools.length > 0 ? ( +
        + {record.pools.map((pool) => ( + +
        + {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} +
        +
        + {pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)} +
        + + ))} +
        + ) : ( +

        No live pools returned for this token yet.

        + )} + ) - })} -
        - - - -
        -

        - The broader liquidity page now shows live route, planner, and pool access together. -

        -

        - The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live - swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes. -

        -
        - - Open liquidity access - - - Open routes page - - - Open wallet tools - -
        + }) + )}
        +
        ) } diff --git a/frontend/src/components/explorer/RoutesMonitoringPage.tsx b/frontend/src/components/explorer/RoutesMonitoringPage.tsx index 45b36dd..bda85a0 100644 --- a/frontend/src/components/explorer/RoutesMonitoringPage.tsx +++ b/frontend/src/components/explorer/RoutesMonitoringPage.tsx @@ -12,12 +12,15 @@ import { import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { statsApi, type ExplorerStats } from '@/services/api/stats' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import SectionTabs, { TabPanel } from '@/components/common/SectionTabs' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' import OperationsSurfaceNav from './OperationsSurfaceNav' import OperationsActionGrid from './OperationsActionGrid' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' interface RoutesMonitoringPageProps { initialRouteMatrix?: RouteMatrixResponse | null @@ -29,18 +32,6 @@ interface RoutesMonitoringPageProps { const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' -function relativeAge(isoString?: string): string { - if (!isoString) return 'Unknown' - const parsed = Date.parse(isoString) - if (!Number.isFinite(parsed)) return 'Unknown' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) - return `${hours}h ago` -} - function compactAddress(value?: string): string { if (!value) return 'Unspecified' if (value.length <= 14) return value @@ -110,6 +101,7 @@ export default function RoutesMonitoringPage({ const [stats, setStats] = useState(initialStats) const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [loadingError, setLoadingError] = useState(null) + const [routesTab, setRoutesTab] = useState<'overview' | 'live' | 'backlog'>('overview') const page = explorerFeaturePages.routes useEffect(() => { @@ -205,25 +197,27 @@ export default function RoutesMonitoringPage({ ) return ( -
        -
        -
        +
        +
        +
        {page.eyebrow}
        -

        +

        {page.title}

        -

        +

        {page.description}

        {page.note ? ( - -

        - {page.note} -

        -
        + + +

        + {page.note} +

        +
        +
        ) : null} @@ -234,215 +228,178 @@ export default function RoutesMonitoringPage({ ) : null} -
        - - + -
        + -
        - -
        - Live Swap Routes -
        -
        - {routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length} -
        -
        - Planner-visible same-chain routes on Chain 138. -
        -
        + route.routeType === 'swap').length) }, + { label: 'Bridges', value: String(routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length) }, + { label: 'Networks', value: String(networks.length) }, + { label: 'Pools', value: String(pools.length) }, + ]} + /> - -
        - Live Bridge Routes -
        -
        - {routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length} -
        -
        - Bridge routes exposed through the current route matrix. -
        -
        + - -
        - Network Catalog -
        -
        - {networks.length} -
        -
        - Published networks available through the explorer config surface. -
        -
        - - -
        - cUSDT Pool View -
        -
        - {pools.length} -
        -
        - Live mission-control pools for the canonical cUSDT token. -
        -
        -
        - -
        - -
        -
        -
        Generated
        -
        - {relativeAge(routeMatrix?.generatedAt)} -
        -
        - Matrix version {routeMatrix?.version || 'unknown'} -
        -
        -
        -
        Updated Source
        -
        - {relativeAge(routeMatrix?.updated)} -
        -
        - {familyCount} partner families surfaced in live routes. -
        -
        -
        -
        Filtered Live Routes
        -
        - {routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length} -
        -
        - Includes swap and bridge lanes currently in the public matrix. -
        -
        -
        -
        Planned / Blocked
        -
        - {routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length} -
        -
        - Remaining lanes still waiting on pools, funding, or routing support. -
        -
        -
        -
        - - -
        - {highlightedNetworks.map((network) => ( -
        -
        - {network.chainName || 'Unknown chain'} -
        -
        - Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'} -
        -
        - ))} -
        -
        -
        - -
        - -
        - {topRoutes.map((route) => ( -
        -
        -
        -
        - {route.label || route.routeId} -
        -
        - {routeAssetPair(route)} -
        + + +
        + +
        +
        +
        Generated
        +
        +
        -
        - {route.routeType || 'route'} +
        + Matrix version {routeMatrix?.version || 'unknown'}
        -
        - {`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${ - (route.hopCount ?? 0) === 1 ? '' : 's' - }`} +
        +
        Updated
        +
        + +
        +
        + {familyCount} partner families in live routes +
        -
        - Protocols: {protocolList(route)} +
        +
        Live routes
        +
        + {routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length} +
        +
        +
        +
        Planned / blocked
        +
        + {routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length} +
        - ))} -
        - - - -
        - {pools.map((pool) => ( -
        -
        - {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} -
        -
        - {pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)} -
        -
        - Pool {compactAddress(pool.address)} -
        + + +
        + {highlightedNetworks.map((network) => ( +
        +
        + {network.chainName || 'Unknown chain'} +
        +
        + Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'} +
        +
        + ))}
        - ))} +
        - -
        + + -
        - -
        - {plannedRoutes.slice(0, 6).map((route) => ( + +
        + +
        + {topRoutes.map((route) => ( +
        +
        +
        +
        + {route.label || route.routeId} +
        +
        + {routeAssetPair(route)} +
        +
        +
        + {route.routeType || 'route'} +
        +
        +
        + {`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${(route.hopCount ?? 0) === 1 ? '' : 's'}`} + {' · '} + {protocolList(route)} +
        +
        + ))} +
        +
        + +
        + {pools.slice(0, 6).map((pool) => ( +
        +
        + {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} +
        +
        + {pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)} · {compactAddress(pool.address)} +
        +
        + ))} +
        +
        +
        +
        + + + +
        + {plannedRoutes.slice(0, 8).map((route) => (
        -
        +
        {route.routeId}
        -
        +
        {(route.tokenInSymbols || []).join(' / ') || routeAssetPair(route)}
        -
        +
        {route.reason || 'Pending additional deployment or routing work.'}
        ))}
        -
        + - +
        ) } diff --git a/frontend/src/components/explorer/SystemOperationsPage.tsx b/frontend/src/components/explorer/SystemOperationsPage.tsx index 708b5ba..2b760ea 100644 --- a/frontend/src/components/explorer/SystemOperationsPage.tsx +++ b/frontend/src/components/explorer/SystemOperationsPage.tsx @@ -6,11 +6,13 @@ import { tokensApi } from '@/services/api/tokens' import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' import { statsApi, type ExplorerStats } from '@/services/api/stats' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import OperationsPageShell, { - MetricCard, StatusBadge, formatNumber, - relativeAge, } from './OperationsPageShell' import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' @@ -38,6 +40,7 @@ export default function SystemOperationsPage({ const [routeMatrix, setRouteMatrix] = useState(initialRouteMatrix) const [stats, setStats] = useState(initialStats) const [loadingError, setLoadingError] = useState(null) + const [activeTab, setActiveTab] = useState<'topology' | 'networks'>('topology') const page = explorerFeaturePages.system useEffect(() => { @@ -90,9 +93,12 @@ export default function SystemOperationsPage({ const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus]) const chainStatus = bridgeStatus?.data?.chains?.['138'] - const chainCoverage = useMemo( - () => new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size, - [tokenList] + const systemTabs = useMemo[]>( + () => [ + { id: 'topology', label: 'Topology' }, + { id: 'networks', label: 'Networks', count: networksConfig?.chains?.length ?? 0 }, + ], + [networksConfig?.chains?.length], ) return ( @@ -103,33 +109,28 @@ export default function SystemOperationsPage({ ) : null} -
        - - - - -
        - + + + + -
        + + +
        @@ -147,7 +148,7 @@ export default function SystemOperationsPage({ {formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} live routes
        - Updated {relativeAge(routeMatrix?.updated)} · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked + Updated · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked
        @@ -181,7 +182,9 @@ export default function SystemOperationsPage({
        + +
        {(networksConfig?.chains || []).map((chain) => ( @@ -202,7 +205,7 @@ export default function SystemOperationsPage({ ) : null}
        -
        +
        ) } diff --git a/frontend/src/components/explorer/WethOperationsPage.tsx b/frontend/src/components/explorer/WethOperationsPage.tsx index 21cfca7..7f792c8 100644 --- a/frontend/src/components/explorer/WethOperationsPage.tsx +++ b/frontend/src/components/explorer/WethOperationsPage.tsx @@ -8,12 +8,13 @@ import { type MissionControlRelayPayload, } from '@/services/api/missionControl' import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import OperationsPageShell, { - MetricCard, StatusBadge, formatNumber, - relativeAge, - truncateMiddle, } from './OperationsPageShell' interface WethOperationsPageProps { @@ -33,14 +34,6 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) { return relay?.url_probe?.body || relay?.file_snapshot } -function relaySummary(snapshot: ReturnType) { - if (!snapshot) return 'destination unknown' - if (snapshot.status === 'paused' && snapshot.monitoring?.delivery_enabled === false) { - return `Delivery paused · queue ${formatNumber(snapshot.queue?.size ?? 0)}` - } - return `Queue ${formatNumber(snapshot.queue?.size ?? 0)} · ${snapshot.destination?.chain_name || 'destination unknown'}` -} - export default function WethOperationsPage({ initialBridgeStatus = null, initialPlannerCapabilities = null, @@ -50,6 +43,7 @@ export default function WethOperationsPage({ const [plannerCapabilities, setPlannerCapabilities] = useState(initialPlannerCapabilities) const [internalPlan, setInternalPlan] = useState(initialInternalPlan) const [loadingError, setLoadingError] = useState(null) + const [activeTab, setActiveTab] = useState<'lanes' | 'providers'>('lanes') const page = explorerFeaturePages.weth useEffect(() => { @@ -101,14 +95,24 @@ export default function WethOperationsPage({ [plannerCapabilities] ) + const wethTabs = useMemo[]>( + () => [ + { id: 'lanes', label: 'Bridge lanes', count: 2 }, + { id: 'providers', label: 'Providers', count: wethProviders.length }, + ], + [wethProviders.length], + ) + return ( - -

        - These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets. - Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies. -

        -
        + + +

        + These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets. + Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies. +

        +
        +
        {loadingError ? ( @@ -116,37 +120,26 @@ export default function WethOperationsPage({ ) : null} -
        - - - - -
        + -
        - + + + +
        {[ { label: 'Mainnet WETH', snapshot: mainnetWeth }, @@ -166,13 +159,15 @@ export default function WethOperationsPage({
        - Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll {relativeAge(snapshot?.last_source_poll?.at)} + Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll
        ))}
        + +
        {wethProviders.map((provider) => { @@ -207,7 +202,7 @@ export default function WethOperationsPage({ ) : null}
        -
        + ) } diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 2b52af8..9e78663 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Card } from '@/libs/frontend-ui-primitives/Card' -import { Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { blocksApi, type Block } from '@/services/api/blocks' import { @@ -17,13 +17,15 @@ import { } from '@/services/api/missionControl' import { loadDashboardData } from '@/utils/dashboard' import EntityBadge from '@/components/common/EntityBadge' -import { formatRelativeAge, formatTimestamp, formatWeiAsEth } from '@/utils/format' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' +import { formatInteger, formatTimestamp, formatWeiAsEth } from '@/utils/format' import { transactionsApi, type Transaction } from '@/services/api/transactions' import { summarizeChainActivity } from '@/utils/activityContext' import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import DisclosureSection from '@/components/common/DisclosureSection' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel' -import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { Explain, useUiMode } from '@/components/common/UiModeContext' import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' @@ -37,6 +39,12 @@ import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh' type HomeStats = ExplorerStats +const HOME_VISIBLE_TRANSACTIONS = 3 +const HOME_VISIBLE_BLOCKS = 5 +const HOME_PRICE_FEED_LIMIT = 4 + +type HomeActivityTab = 'transactions' | 'blocks' + interface HomePageProps { initialStats?: HomeStats | null initialRecentBlocks?: Block[] @@ -99,7 +107,7 @@ function formatGasPriceGwei(value: number) { return `${value.toFixed(3)} gwei` } -function compactStatNote(guided: string, expert: string, mode: 'guided' | 'expert') { +function compactStatNote(guided: ReactNode, expert: ReactNode, mode: 'guided' | 'expert') { return mode === 'guided' ? guided : expert } @@ -133,6 +141,7 @@ export default function Home({ const [missionExpanded, setMissionExpanded] = useState(false) const [relayExpanded, setRelayExpanded] = useState(false) const [statsDetailsExpanded, setStatsDetailsExpanded] = useState(false) + const [homeActivityTab, setHomeActivityTab] = useState('transactions') const [relayPage, setRelayPage] = useState(1) const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>( initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting' @@ -148,7 +157,7 @@ export default function Home({ const response = await blocksApi.list({ chain_id: chainId, page: 1, - page_size: 10, + page_size: HOME_VISIBLE_BLOCKS, }) return response.data }, @@ -376,7 +385,6 @@ export default function Home({ } return parts.join(' · ') })() - const snapshotAgeLabel = checkedAt ? formatRelativeAge(checkedAt) : 'Unknown' const chainVisibilityState = chainStatus?.head_age_sec != null ? chainStatus.head_age_sec <= 30 @@ -418,9 +426,11 @@ export default function Home({ ...(bridgeStatus?.data?.sampling?.issues || {}), ...(stats?.sampling?.issues || {}), }) - const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp - ? formatRelativeAge(activityContext.latest_transaction_timestamp) - : 'Unknown' + const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp ? ( + + ) : ( + 'Unknown' + ) const latestTransactionFreshness = activityContext.latest_transaction_age_seconds == null ? 'Latest transaction freshness is unavailable.' @@ -445,7 +455,7 @@ export default function Home({ ) const transactionsTodaySummary = formatObservabilityValue( transactionsToday, - (value) => value.toLocaleString(), + (value) => formatInteger(value), ) const networkUtilizationSummary = networkUtilization == null @@ -456,31 +466,34 @@ export default function Home({ const missionCollapsedSummary = relaySummary ? `${missionHeadline} · ${relayOperationalCount} operational` : `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}` - const primaryMetricCards = [ + const primaryMetricCards: Array<{ label: string; value: string; note: ReactNode }> = [ { label: 'Latest Block', - value: latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable', + value: latestBlock != null ? formatInteger(latestBlock) : 'Unavailable', note: activityContext.latest_block_timestamp ? compactStatNote( - `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`, - formatRelativeAge(activityContext.latest_block_timestamp), + <> + Head freshness + {blockCompleteness ? ` · ${blockCompleteness}` : ''} + , + , mode, ) : compactStatNote('Head freshness unavailable.', 'Unavailable', mode), }, { label: 'Total Blocks', - value: stats ? stats.total_blocks.toLocaleString() : 'Unavailable', + value: stats ? formatInteger(stats.total_blocks) : 'Unavailable', note: compactStatNote('Visible public explorer block count.', 'Explorer block count', mode), }, { label: 'Total Transactions', - value: stats ? stats.total_transactions.toLocaleString() : 'Unavailable', + value: stats ? formatInteger(stats.total_transactions) : 'Unavailable', note: compactStatNote('Visible indexed explorer transaction count.', 'Indexed tx count', mode), }, { label: 'Total Addresses', - value: stats ? stats.total_addresses.toLocaleString() : 'Unavailable', + value: stats ? formatInteger(stats.total_addresses) : 'Unavailable', note: compactStatNote('Current public explorer address count.', 'Address count', mode), }, ] @@ -506,7 +519,7 @@ export default function Home({ note: compactStatNote(networkUtilizationSummary.note, networkUtilization != null ? 'Latest stats sample' : 'Unavailable', mode), }, ] - const activityMetricCards = [ + const activityMetricCards: Array<{ label: string; value: string; note: ReactNode; detail: string }> = [ { label: 'Latest Transaction', value: activityContext.latest_transaction_block_number != null ? `#${activityContext.latest_transaction_block_number}` : 'Unknown', @@ -516,7 +529,11 @@ export default function Home({ { label: 'Last Non-Empty Block', value: activityContext.last_non_empty_block_number != null ? `#${activityContext.last_non_empty_block_number}` : 'Unknown', - note: formatRelativeAge(activityContext.last_non_empty_block_timestamp), + note: activityContext.last_non_empty_block_timestamp ? ( + + ) : ( + 'Unknown' + ), detail: activityContext.latest_transaction_block_number != null && activityContext.last_non_empty_block_number != null && @@ -528,11 +545,11 @@ export default function Home({ label: 'Block Gap', value: activityContext.block_gap_to_latest_transaction != null - ? activityContext.block_gap_to_latest_transaction.toLocaleString() + ? formatInteger(activityContext.block_gap_to_latest_transaction) : 'Unknown', note: activityContext.block_gap_to_latest_transaction != null - ? `${activityContext.block_gap_to_latest_transaction.toLocaleString()} blocks behind tip` + ? `${formatInteger(activityContext.block_gap_to_latest_transaction)} blocks behind tip` : 'Gap unavailable', detail: 'Difference between the current tip and the latest visible transaction block.', }, @@ -548,8 +565,28 @@ export default function Home({ } }, [relayPage, relayPageCount]) + const visibleRecentTransactions = useMemo( + () => recentTransactions.slice(0, HOME_VISIBLE_TRANSACTIONS), + [recentTransactions], + ) + const visibleRecentBlocks = useMemo( + () => recentBlocks.slice(0, HOME_VISIBLE_BLOCKS), + [recentBlocks], + ) + const visibleFeaturedPrices = useMemo( + () => featuredPrices.slice(0, HOME_PRICE_FEED_LIMIT), + [featuredPrices], + ) + const homeActivityTabs = useMemo[]>( + () => [ + { id: 'transactions', label: 'Transactions', count: visibleRecentTransactions.length }, + { id: 'blocks', label: 'Blocks', count: visibleRecentBlocks.length }, + ], + [visibleRecentBlocks.length, visibleRecentTransactions.length], + ) + return ( -
        +
        {(relaySummary || bridgeStatus) && (
        - {`${statsGeneratedAt ? `Snapshot updated ${formatRelativeAge(statsGeneratedAt)}.` : `Snapshot updated ${snapshotAgeLabel}.`} ${ - missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason - }`} + Snapshot updated{' '} + {statsGeneratedAt ? ( + + ) : checkedAt ? ( + + ) : ( + 'Unknown.' + )}{' '} + {missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason}
        {snapshotScope} @@ -722,7 +765,15 @@ export default function Home({
        -

        {item.text}

        +

        + {item.text} + {item.polledAt ? ( + <> + {' '} + · polled + + ) : null} +

        {getLaneImpactNote(item.key, resolveRelaySeverityLabel(item.status, item.tone))}

        @@ -776,7 +827,7 @@ export default function Home({ {(relaySummary || bridgeStatus || stats) && (
        - + {chainStatus?.status ? : null} {relaySummary ? : null} @@ -785,35 +836,27 @@ export default function Home({ )} {stats && ( -
        -

        Network overview

        -
        +
        +
        {primaryMetricCards.map((card) => ( - -
        {card.label}
        -
        {card.value}
        -
        {card.note}
        -
        + + {card.label}:{' '} + {card.value} + ))}
        - - - + + + + +
    ) diff --git a/frontend/src/components/pools/PoolDetailPage.tsx b/frontend/src/components/pools/PoolDetailPage.tsx index 8f4d844..9fdb6e9 100644 --- a/frontend/src/components/pools/PoolDetailPage.tsx +++ b/frontend/src/components/pools/PoolDetailPage.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card, Address } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import SectionTabs, { TabPanel } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import LpPositionPanel from '@/components/wallet/LpPositionPanel' @@ -11,7 +13,7 @@ import { fetchPoolRegistry, type CuratedPoolRegistryEntry, } from '@/services/api/liquidityPositions' -import { useUiMode } from '@/components/common/UiModeContext' +import { formatBigIntWhole, formatTimestamp } from '@/utils/format' function formatUsd(value: number | undefined): string { if (value == null || !Number.isFinite(value)) return 'Unavailable' @@ -36,8 +38,8 @@ function formatReserve(raw: string | undefined, symbol: string): string { .slice(0, 4) .replace(/0+$/, '') return fractionText - ? `${whole.toLocaleString()}.${fractionText} ${symbol}` - : `${whole.toLocaleString()} ${symbol}` + ? `${formatBigIntWhole(whole)}.${fractionText} ${symbol}` + : `${formatBigIntWhole(whole)} ${symbol}` } catch { return 'Unavailable' } @@ -71,9 +73,9 @@ interface PoolDetailPageProps { } export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { - const { mode } = useUiMode() const [pools, setPools] = useState([]) const [loading, setLoading] = useState(true) + const [poolTab, setPoolTab] = useState<'summary' | 'lp'>('summary') useEffect(() => { let active = true @@ -101,20 +103,16 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null return ( -
    +
    @@ -142,21 +140,36 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { ) : null} {pool ? ( -
    + <> {asymmetryNote ? ( - -

    - {asymmetryNote.message} -

    -
    + + +

    + {asymmetryNote.message} +

    +
    +
    ) : null} + + +
    @@ -229,7 +242,7 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { {pool.liquiditySource ?? 'curated registry'} {pool.liquidityAsOf ? ( - As of {new Date(pool.liquidityAsOf).toLocaleString()} + As of {formatTimestamp(pool.liquidityAsOf)} ) : null} @@ -242,14 +255,17 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
    +
    + -
    + + ) : null}
    ) diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index 83dd32e..f9040cd 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -1,6 +1,10 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import ListFilterBar from '@/components/common/ListFilterBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import PaginationControls from '@/components/common/PaginationControls' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base' import { tokensApi } from '@/services/api/tokens' import { selectWalletFeaturedTokens } from '@/utils/featuredTokens' @@ -157,6 +161,17 @@ interface AddToMetaMaskProps { initialCapabilitiesMeta?: FetchMetadata | null } +const FEATURED_TOKENS_PAGE_SIZE = 5 + +type AddToMetaMaskTab = 'networks' | 'tokens' | 'other-chains' | 'advanced' + +const addToMetaMaskTabs: SectionTab[] = [ + { id: 'networks', label: 'Networks' }, + { id: 'tokens', label: 'Tokens' }, + { id: 'other-chains', label: 'Other chains' }, + { id: 'advanced', label: 'API & RPC' }, +] + function chainParamsForWallet(chain: WalletChain) { return toWalletAddEthereumChainParams(chain, { preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(), @@ -393,6 +408,9 @@ export function AddToMetaMask({ const [fundedListingError, setFundedListingError] = useState(null) const [pendingWatchFlow, setPendingWatchFlow] = useState(null) const [providerTick, setProviderTick] = useState(0) + const [activeTab, setActiveTab] = useState('networks') + const [tokenQuery, setTokenQuery] = useState('') + const [tokenPage, setTokenPage] = useState(1) const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext() @@ -560,6 +578,35 @@ export function AddToMetaMask({ [catalogTokens, curatedTokens], ) + const filteredFeaturedTokens = useMemo(() => { + const normalizedQuery = tokenQuery.trim().toLowerCase() + if (!normalizedQuery) return featuredTokens + return featuredTokens.filter((token) => { + const haystack = [token.symbol, token.name, token.address, ...(token.tags || [])] + .join(' ') + .toLowerCase() + return haystack.includes(normalizedQuery) + }) + }, [featuredTokens, tokenQuery]) + + const featuredTokenPageCount = Math.max(1, Math.ceil(filteredFeaturedTokens.length / FEATURED_TOKENS_PAGE_SIZE)) + + const paginatedFeaturedTokens = useMemo(() => { + const safePage = Math.min(Math.max(tokenPage, 1), featuredTokenPageCount) + const start = (safePage - 1) * FEATURED_TOKENS_PAGE_SIZE + return filteredFeaturedTokens.slice(start, start + FEATURED_TOKENS_PAGE_SIZE) + }, [filteredFeaturedTokens, featuredTokenPageCount, tokenPage]) + + useEffect(() => { + setTokenPage(1) + }, [tokenQuery]) + + useEffect(() => { + if (tokenPage > featuredTokenPageCount) { + setTokenPage(featuredTokenPageCount) + } + }, [featuredTokenPageCount, tokenPage]) + const watchAssetTokens = useMemo(() => { const endpointTokens = dedupeWalletWatchTokens( (metamaskConfig?.watchAssets || []) @@ -964,214 +1011,96 @@ export function AddToMetaMask({ : capabilitiesUrl return ( -
    +

    Add to MetaMask

    - The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume. - That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of - relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such - as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token - metadata directly to the wallet. + Add Chain 138 and token metadata from explorer-served catalogs via EIP-747 wallet prompts.

    - + + tab.id === 'tokens' ? { ...tab, count: featuredTokens.length } : tab, + )} + activeTab={activeTab} + onChange={setActiveTab} + idPrefix="add-to-metamask" + ariaLabel="Wallet setup sections" + /> -
    - - - - -
    + + -
    -
    Optional Chain 138 Open Snap
    -

    - This is not required for the production - wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747 - wallet_watchAsset prompts. The optional Snap uses{' '} - only open Snap permissions (minimal - privileged APIs in the Snap itself).{' '} - Stable MetaMask still only installs npm - Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", that is - an external MetaMask review gate rather than an explorer/network failure. Use{' '} - MetaMask Flask for development or apply - for allowlisting before using this with Stable MetaMask. The package on npm is{' '} - {CHAIN138_OPEN_SNAP_ID} - — publish from the repo with scripts/deployment/publish-chain138-open-snap.sh after{' '} - npm login. +

    + + + + +
    + + +

    + Not required for production wallet setup. Use MetaMask Flask or an allowlisted Stable build. Package:{' '} + {CHAIN138_OPEN_SNAP_ID} +

    + +
    + + + +

    + Import canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential + EIP-747 prompts. + {mobileWalletContext ? ' On mobile, use Continue between prompt batches.' : null}

    - -
    - -
    -
    -
    Explorer-served MetaMask metadata
    -
    -

    Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

    -

    Chain 138 token entries: {tokenCount138}

    -

    EIP-747 watchAsset entries: {watchAssetTokens.length}

    -

    Networks source: {networksMeta?.source || 'unknown'}

    -

    Token list source: {tokenListMeta?.source || 'unknown'}

    -

    MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}

    - {metadataKeywordString ?

    Keywords: {metadataKeywordString}

    : null} -
    -
    -
    -

    Networks config URL

    - {networksUrl} -
    - - - Open JSON - -
    -
    -
    -

    Capabilities URL

    - {displayedCapabilitiesUrl} -
    - - - Open JSON - -
    -
    -
    -

    EIP-747 MetaMask payload URL

    - {metamaskConfigUrl} -
    - - - Open JSON - -
    -
    -
    -

    Token list URL

    - {tokenListUrl} -
    - - - Open JSON - -
    -
    -
    +
    +
    + +
    -
    Chain 138 RPC capabilities
    -

    - This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer - can use, and where MetaMask will still fall back because the public node does not expose every optional fee method. -

    -
    -

    - RPC endpoint:{' '} - - {capabilities?.rpcUrl || 'using published explorer fallback'} - + +

    + Bulk add switches to DeFi Oracle Meta Mainnet first. Use{' '} + Add tokens with balance only to skip zero-balance catalog entries. + MetaMask keeps one row per token symbol on a network.

    -

    - Capabilities source:{' '} - - {capabilitiesMeta?.source || 'unknown'} - -

    -

    - HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'} -

    -

    - Missing wallet-facing methods:{' '} - {unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'} -

    -

    - Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'} -

    - {capabilities?.walletSupport?.notes?.map((note) => ( -

    - {note} -

    - ))} - {capabilities?.http?.notes?.map((note) => ( -

    - {note} -

    - ))} - {capabilitiesMeta?.lastModified ? ( -

    - Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)} -

    - ) : null} -
    -
    - -
    -
    Featured Chain 138 tokens
    -

    - These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same - symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user - approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add - switches to DeFi Oracle Meta Mainnet first — tokens imported while on another network will not show - balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a - working RPC. Use Add tokens with balance only to skip zero-balance and - undeployed catalog entries. - {mobileWalletContext - ? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.' - : null} -

    -

    - MetaMask keeps one row per token symbol on a network. If a balance - disappears after import, remove duplicate custom tokens (especially second cUSDC{' '} - / cUSDT rows or old gas placeholders like{' '} - {CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '} - DeFi Oracle Meta Mainnet, then tap{' '} - Add tokens with balance only or{' '} - Add featured tokens. -

    -
    + +
    {pendingWatchFlow ? (
    -
    -
    - ))} + )) + )}
    +
    -
    -
    Ethereum Mainnet cWUSDC
    -

    - This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price - display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing. -

    -
    -
    + +
    +
    +
    + {MAINNET_CWUSDC_TOKEN.symbol}{' '} + ({MAINNET_CWUSDC_TOKEN.name}) +
    +
    {MAINNET_CWUSDC_TOKEN.address}
    +
    + +
    +
    + + + +
    +
    +
    Explorer-served MetaMask metadata
    +
    +

    Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

    +

    Chain 138 token entries: {tokenCount138}

    +

    EIP-747 watchAsset entries: {watchAssetTokens.length}

    +

    Networks source: {networksMeta?.source || 'unknown'}

    +

    Token list source: {tokenListMeta?.source || 'unknown'}

    +

    MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}

    + {metadataKeywordString ?

    Keywords: {metadataKeywordString}

    : null} +
    +
    -
    - {MAINNET_CWUSDC_TOKEN.symbol}{' '} - ({MAINNET_CWUSDC_TOKEN.name}) -
    -
    {MAINNET_CWUSDC_TOKEN.address}
    -
    - Ethereum Mainnet • Decimals: {MAINNET_CWUSDC_TOKEN.decimals} +

    Networks config URL

    + {networksUrl} +
    + + + Open JSON +
    - +
    +

    Capabilities URL

    + {displayedCapabilitiesUrl} +
    + + + Open JSON + +
    +
    +
    +

    EIP-747 MetaMask payload URL

    + {metamaskConfigUrl} +
    + + + Open JSON + +
    +
    +
    +

    Token list URL

    + {tokenListUrl} +
    + + + Open JSON + +
    +
    +
    +
    + +
    +
    Chain 138 RPC capabilities
    +

    + This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer + can use, and where MetaMask will still fall back because the public node does not expose every optional fee method. +

    +
    +

    + RPC endpoint:{' '} + + {capabilities?.rpcUrl || 'using published explorer fallback'} + +

    +

    + Capabilities source:{' '} + + {capabilitiesMeta?.source || 'unknown'} + +

    +

    + HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'} +

    +

    + Missing wallet-facing methods:{' '} + {unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'} +

    +

    + Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'} +

    + {capabilities?.walletSupport?.notes?.map((note) => ( +

    + {note} +

    + ))} + {capabilities?.http?.notes?.map((note) => ( +

    + {note} +

    + ))} + {capabilitiesMeta?.lastModified ? ( +

    + Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)} +

    + ) : null}
    -
    +
    {status ?

    {status}

    : null} {error ?

    {error}

    : null} - -
    ) } diff --git a/frontend/src/components/wallet/AddTokenToWalletButton.tsx b/frontend/src/components/wallet/AddTokenToWalletButton.tsx new file mode 100644 index 0000000..7ebdda4 --- /dev/null +++ b/frontend/src/components/wallet/AddTokenToWalletButton.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import type { WalletChain } from '@/components/wallet/AddToMetaMask' +import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient' +import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain' +import { + isMobileWalletContext, + MOBILE_WALLET_BUTTON_CLASS, + resolveWalletEthereumProvider, +} from '@/utils/walletProviderEnv' +import { isWalletWatchEligibleAddress } from '@/utils/walletWatchEligible' +import { watchTokenInWallet } from '@/utils/walletWatchToken' + +const FALLBACK_CHAIN_138: WalletChain = { + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'DeFi Oracle Meta Mainnet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://rpc-http-pub.d-bis.org'], + blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'], + iconUrls: [ + 'https://explorer.d-bis.org/api/v1/report/logo/chain-138', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico', + ], +} + +type AddTokenToWalletButtonProps = { + address: string + symbol: string + decimals: number + name?: string + logoURI?: string + chainId?: number + variant?: 'primary' | 'secondary' + className?: string +} + +export default function AddTokenToWalletButton({ + address, + symbol, + decimals, + name, + logoURI, + chainId = 138, + variant = 'primary', + className = '', +}: AddTokenToWalletButtonProps) { + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + const [chainNetwork, setChainNetwork] = useState(null) + const [providerTick, setProviderTick] = useState(0) + + useEffect(() => { + if (typeof window === 'undefined') return undefined + const refresh = () => setProviderTick((value) => value + 1) + window.addEventListener('ethereum#initialized', refresh) + window.addEventListener('focus', refresh) + return () => { + window.removeEventListener('ethereum#initialized', refresh) + window.removeEventListener('focus', refresh) + } + }, []) + + useEffect(() => { + let active = true + const apiBase = window.location.origin.replace(/\/+$/, '') + void fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((payload) => { + if (!active) return + if (payload?.addEthereumChain) { + setChainNetwork(payload.addEthereumChain as WalletChain) + } + }) + .catch(() => { + if (active) setChainNetwork(null) + }) + return () => { + active = false + } + }, [chainId]) + + const network = useMemo(() => { + if (chainId === 138) return chainNetwork || FALLBACK_CHAIN_138 + return chainNetwork + }, [chainId, chainNetwork]) + + const resolveEthereum = () => { + void providerTick + return resolveWalletEthereumProvider(getActiveWalletConnectProvider()) + } + + const addChainOnly = async () => { + setError(null) + setStatus(null) + if (!network) { + setError('Network metadata is unavailable right now.') + return + } + const ethereum = resolveEthereum() + if (!ethereum) { + setError('No wallet provider found. Connect MetaMask, open this page in a wallet browser, or use WalletConnect on /wallet.') + return + } + try { + await ethereum.request({ + method: 'wallet_addEthereumChain', + params: [ + toWalletAddEthereumChainParams(network, { + preferSingleRpc: isMobileWalletContext(), + }), + ], + }) + setStatus(`Added ${network.chainName}.`) + } catch (e) { + const err = e as { code?: number; message?: string } + if (err.code === 4902) { + setStatus(`Added ${network.chainName}.`) + return + } + setError(err.message || `Failed to add ${network.chainName}.`) + } + } + + const addToken = async () => { + setError(null) + setStatus(null) + + if (!isWalletWatchEligibleAddress(address)) { + setError(`${symbol} uses a roadmap placeholder address on Chain 138. Use a live canonical token from the token index instead.`) + return + } + + if (!network) { + setError('Network metadata is unavailable right now.') + return + } + + const ethereum = resolveEthereum() + if (!ethereum) { + setError('No wallet provider found. Connect MetaMask, open this page in a wallet browser, or use WalletConnect on /wallet.') + return + } + + const result = await watchTokenInWallet( + ethereum, + { + chainId, + address, + symbol, + decimals, + logoURI, + }, + network, + ) + + if (!result.ok) { + setError(result.error || 'Could not add token to wallet.') + return + } + + setStatus( + result.added + ? `Added ${symbol} on ${network.chainName}. Stay on that network to see balances.` + : `${symbol} request was dismissed.`, + ) + } + + const buttonClass = + variant === 'primary' + ? 'rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50' + : 'rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-100 dark:hover:bg-gray-900' + + return ( +
    +
    + {chainId === 138 ? ( + + ) : null} + +
    + {status ?

    {status}

    : null} + {error ?

    {error}

    : null} +
    + ) +} diff --git a/frontend/src/components/wallet/MultiChainWalletImport.tsx b/frontend/src/components/wallet/MultiChainWalletImport.tsx index 9d049d8..e682665 100644 --- a/frontend/src/components/wallet/MultiChainWalletImport.tsx +++ b/frontend/src/components/wallet/MultiChainWalletImport.tsx @@ -1,6 +1,8 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import DisclosureSection from '@/components/common/DisclosureSection' +import ListFilterBar from '@/components/common/ListFilterBar' import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base' import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask' import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient' @@ -85,12 +87,16 @@ function chainParamsForWallet(chain: WalletChain) { }) } +const PRIMARY_IMPORT_CHAIN_IDS = new Set([138, 1]) + export default function MultiChainWalletImport() { const [status, setStatus] = useState(null) const [error, setError] = useState(null) const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null) const [chains, setChains] = useState([]) const [pendingFlow, setPendingFlow] = useState(null) + const [chainQuery, setChainQuery] = useState('') + const [showAllChains, setShowAllChains] = useState(false) const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext() const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider()) @@ -157,6 +163,22 @@ export default function MultiChainWalletImport() { ) }, [chains]) + const filteredChains = useMemo(() => { + const normalizedQuery = chainQuery.trim().toLowerCase() + if (!normalizedQuery) return chains + return chains.filter((row) => { + const label = chainLabel(row.chainId).toLowerCase() + return label.includes(normalizedQuery) || String(row.chainId).includes(normalizedQuery) + }) + }, [chainQuery, chains]) + + const visibleChains = useMemo(() => { + if (chainQuery.trim() || showAllChains) return filteredChains + return filteredChains.filter((row) => PRIMARY_IMPORT_CHAIN_IDS.has(row.chainId)) + }, [chainQuery, filteredChains, showAllChains]) + + const hiddenChainCount = Math.max(0, filteredChains.length - visibleChains.length) + const switchOrAddChain = async (network: WalletChain) => { const ethereum = resolveEthereum() if (!ethereum) { @@ -258,39 +280,46 @@ export default function MultiChainWalletImport() { } return ( -
    -

    Multi-chain token import

    -

    - Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential - EIP-747 wallet_watchAsset prompts. - {mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null} -

    +
    + - {pendingFlow ? ( + {!chainQuery.trim() && !showAllChains && hiddenChainCount > 0 ? ( ) : null} -
    - {chains.map((row) => { +
    + {visibleChains.length === 0 ? ( +

    No chains match your filter.

    + ) : ( + visibleChains.map((row) => { const featured = featuredByChain.get(row.chainId) ?? [] return ( -
    -
    -
    -
    {chainLabel(row.chainId)}
    -
    - {row.loading - ? 'Loading token metadata…' - : row.error - ? row.error - : `${row.tokens.length} canonical tokens · ${featured.length} featured`} -
    + +
    +
    + {row.loading + ? 'Loading token metadata…' + : row.error + ? row.error + : `${featured.length} featured · ${row.tokens.length} canonical`}
    {progress?.chainId === row.chainId ? ( -
    +
    Prompt {progress.current} of {progress.total}
    ) : null} -
    + ) - })} + }) + )}
    + {pendingFlow ? ( + + ) : null} + {status ?

    {status}

    : null} {error ?

    {error}

    : null}
    diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx index b1e6961..9ee8dbe 100644 --- a/frontend/src/components/wallet/WalletPage.tsx +++ b/frontend/src/components/wallet/WalletPage.tsx @@ -11,7 +11,7 @@ import WalletConnectPostureNote from '@/components/wallet/WalletConnectPostureNo import { connectAndAuthenticateWalletConnect, getActiveWalletConnectSessionId, isWalletConnectClientReady, loadWalletConnectConfig } from '@/services/wallet/walletConnectClient' import { registerWalletConnectSession } from '@/services/api/walletConnect' import Link from 'next/link' -import { Explain, useUiMode } from '@/components/common/UiModeContext' +import { useUiMode } from '@/components/common/UiModeContext' import { accessApi, type WalletAccessSession } from '@/services/api/access' import EntityBadge from '@/components/common/EntityBadge' import { @@ -23,7 +23,8 @@ import { } from '@/services/api/addresses' import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens' import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh' -import { formatRelativeAge, formatTokenAmount } from '@/utils/format' +import { formatInteger, formatTimestamp, formatTokenAmount } from '@/utils/format' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import { isWatchlistEntry, readWatchlistFromStorage, @@ -31,6 +32,10 @@ import { writeWatchlistToStorage, } from '@/utils/watchlist' import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv' +import PageIntro from '@/components/common/PageIntro' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' interface WalletPageProps { initialNetworks?: NetworksCatalog | null @@ -47,6 +52,8 @@ function shortAddress(value?: string | null): string { return `${value.slice(0, 6)}...${value.slice(-4)}` } +type WalletPageTab = 'connect' | 'snapshot' | 'setup' + export default function WalletPage(props: WalletPageProps) { const { mode } = useUiMode() const [walletSession, setWalletSession] = useState(null) @@ -60,6 +67,30 @@ export default function WalletPage(props: WalletPageProps) { const [recentAddressTransactions, setRecentAddressTransactions] = useState([]) const [tokenBalances, setTokenBalances] = useState([]) const [tokenTransfers, setTokenTransfers] = useState([]) + const [activeTab, setActiveTab] = useState('connect') + + const walletTabs = useMemo[]>(() => { + const tabs: SectionTab[] = [ + { id: 'connect', label: 'Connect' }, + { id: 'setup', label: 'Wallet setup' }, + ] + if (walletSession) { + tabs.splice(1, 0, { id: 'snapshot', label: 'My wallet' }) + } + return tabs + }, [walletSession]) + + useEffect(() => { + if (activeTab === 'snapshot' && !walletSession) { + setActiveTab('connect') + } + }, [activeTab, walletSession]) + + useEffect(() => { + if (walletSession?.address) { + setActiveTab((current) => (current === 'connect' ? 'snapshot' : current)) + } + }, [walletSession?.address]) useEffect(() => { if (typeof window === 'undefined') return @@ -154,7 +185,7 @@ export default function WalletPage(props: WalletPageProps) { addressesApi.getSafe(138, address), addressesApi.getTransactionsSafe(138, address, 1, 3), addressesApi.getTokenBalancesSafe(address), - addressesApi.getTokenTransfersSafe(address, 1, 4), + addressesApi.getTokenTransfersSafe(address, 1, 2), ]) setAddressInfo(infoResponse.ok ? infoResponse.data : null) @@ -176,7 +207,7 @@ export default function WalletPage(props: WalletPageProps) { return 0 } }) - .slice(0, 4) + .slice(0, 3) : [], ) setTokenTransfers(transfersResponse.ok ? transfersResponse.data : []) @@ -222,15 +253,36 @@ export default function WalletPage(props: WalletPageProps) { }, [loadWalletSnapshot, walletSession?.address]) return ( -
    -

    Wallet Tools

    -

    - {mode === 'guided' - ? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.' - : 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'} -

    - -
    +
    + + + + + + + + +
    Wallet session
    @@ -261,7 +313,7 @@ export default function WalletPage(props: WalletPageProps) {
    {walletSession?.expiresAt ? (
    - Session expires {new Date(walletSession.expiresAt).toLocaleString()} + Session expires {formatTimestamp(walletSession.expiresAt)}
    ) : null}
    @@ -355,9 +407,12 @@ export default function WalletPage(props: WalletPageProps) { ) : null}
    +
    + + {walletSession ? ( -
    +
    @@ -374,32 +429,15 @@ export default function WalletPage(props: WalletPageProps) {
    -
    -
    -
    Transactions
    -
    - {addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'} -
    -
    -
    -
    Token Holdings
    -
    - {addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'} -
    -
    -
    -
    Address Type
    -
    - {addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'} -
    -
    -
    -
    Recent Indexed Tx
    -
    - {recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'} -
    -
    -
    +
    {recentAddressTransactions.length === 0 ? ( @@ -407,7 +445,7 @@ export default function WalletPage(props: WalletPageProps) { No recent indexed transactions are currently visible for this connected wallet.
    ) : ( - recentAddressTransactions.map((transaction) => ( + recentAddressTransactions.slice(0, 3).map((transaction) => (
    - Block #{transaction.block_number.toLocaleString()} + Block #{formatInteger(transaction.block_number)}
    )) @@ -448,7 +486,7 @@ export default function WalletPage(props: WalletPageProps) { No indexed token balances are currently visible for this wallet.
    ) : ( - tokenBalances.map((balance) => ( + tokenBalances.slice(0, 5).map((balance) => ( ) : ( - tokenTransfers.map((transfer) => { + tokenTransfers.slice(0, 5).map((transfer) => { const incoming = transfer.to_address.toLowerCase() === walletSession.address.toLowerCase() const counterparty = incoming ? transfer.from_address : transfer.to_address return ( @@ -506,7 +544,7 @@ export default function WalletPage(props: WalletPageProps) { {formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol, 6)}
    - Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · {formatRelativeAge(transfer.timestamp)} + Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} ·
    @@ -535,29 +573,23 @@ export default function WalletPage(props: WalletPageProps) { />
    - ) : null} -
    - -
    - - <> - Need swap and liquidity discovery too? Visit the{' '} - - Liquidity - {' '} - page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints. - - - {mode === 'expert' ? ( - <> - Liquidity and planner posture lives on the{' '} - - Liquidity - {' '} - surface. - - ) : null} -
    + ) : ( +
    + Connect a wallet on the Connect tab to see balances, transfers, and LP positions here. +
    + )} + + + + +

    + Need swap and liquidity discovery?{' '} + + Open Liquidity + + . +

    +
    ) } diff --git a/frontend/src/data/footerNav.ts b/frontend/src/data/footerNav.ts new file mode 100644 index 0000000..1e4dd97 --- /dev/null +++ b/frontend/src/data/footerNav.ts @@ -0,0 +1,60 @@ +export type FooterNavLink = { + href: string + label: string + external?: boolean +} + +export type FooterNavGroup = { + id: string + title: string + links: FooterNavLink[] +} + +export const footerNavGroups: FooterNavGroup[] = [ + { + id: 'explore', + title: 'Explore', + links: [ + { href: '/search', label: 'Search' }, + { href: '/blocks', label: 'Blocks' }, + { href: '/transactions', label: 'Transactions' }, + { href: '/tokens', label: 'Tokens' }, + { href: '/addresses', label: 'Addresses' }, + { href: '/watchlist', label: 'Watchlist' }, + ], + }, + { + id: 'data', + title: 'Data & tools', + links: [ + { href: '/docs', label: 'Documentation' }, + { href: '/wallet', label: 'Wallet tools' }, + { href: '/access', label: 'Account access' }, + { href: '/analytics', label: 'Analytics' }, + { href: '/weth', label: 'WETH' }, + ], + }, + { + id: 'operations', + title: 'Operations', + links: [ + { href: '/operations', label: 'Operations hub' }, + { href: '/bridge', label: 'Bridge' }, + { href: '/routes', label: 'Routes' }, + { href: '/liquidity', label: 'Liquidity' }, + { href: '/pools', label: 'Pools' }, + { href: '/protocols', label: 'Protocols' }, + { href: '/operator', label: 'Operator' }, + { href: '/system', label: 'System' }, + ], + }, + { + id: 'legal', + title: 'Legal', + links: [ + { href: '/privacy.html', label: 'Privacy Policy', external: true }, + { href: '/terms.html', label: 'Terms of Service', external: true }, + { href: '/acknowledgments.html', label: 'Acknowledgments', external: true }, + ], + }, +] diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 5d7f5fd..71788e5 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -1,6 +1,5 @@ -'use client' - import { useCallback, useEffect, useMemo, useState } from 'react' +import type { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' @@ -20,7 +19,7 @@ import { } from '@/services/api/contracts' import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' import { isEnsName, resolveEnsAddress } from '@/utils/ens' -import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry' +import { resolveAddressFromRegistryEns, getKnownDisplayName } from '@/utils/web3IdentityRegistry' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' import PostureBadge from '@/components/common/PostureBadge' @@ -32,7 +31,7 @@ import { } from '@/utils/watchlist' import PageIntro from '@/components/common/PageIntro' import PaginationControls from '@/components/common/PaginationControls' -import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const ADDRESS_DETAIL_TABS_ID = 'address-detail' @@ -75,10 +74,14 @@ function isContractWriteExecutionEnabled() { return value === '1' || value === 'true' || value === 'yes' } -export default function AddressDetailPage() { +export default function AddressDetailPage({ serverRegistryLabel = null }: { serverRegistryLabel?: string | null }) { const router = useRouter() const address = typeof router.query.address === 'string' ? router.query.address : '' const isValidAddressParam = address !== '' && isValidAddress(address) + const registryDisplayName = useMemo( + () => (isValidAddressParam ? getKnownDisplayName(address) : undefined), + [address, isValidAddressParam], + ) const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const contractWriteExecutionEnabled = isContractWriteExecutionEnabled() @@ -703,19 +706,23 @@ export default function AddressDetailPage() { const resolvedAddressInfo = addressInfo as AddressInfo return ( -
    +
    -
    +
    Back to addresses @@ -849,10 +856,7 @@ export default function AddressDetailPage() { ariaLabel="Address details" /> -
    + {resolvedAddressInfo.is_contract ? (
    @@ -1097,9 +1101,9 @@ export default function AddressDetailPage() { ) : null} {gruProfile ?
    : null} -
    + -
    + {gruBalanceCount > 0 ? (
    @@ -1128,9 +1132,9 @@ export default function AddressDetailPage() { )} -
    +
    -
    + {gruTransferCount > 0 ? (
    @@ -1162,9 +1166,9 @@ export default function AddressDetailPage() { )} -
    +
    -
    + {activityLoading ? (

    Loading recent transactions...

    @@ -1185,9 +1189,18 @@ export default function AddressDetailPage() { )}
    -
    + )}
    ) } + +export const getServerSideProps: GetServerSideProps<{ serverRegistryLabel: string | null }> = async (context) => { + const address = typeof context.params?.address === 'string' ? context.params.address : '' + return { + props: { + serverRegistryLabel: getKnownDisplayName(address) ?? null, + }, + } +} diff --git a/frontend/src/pages/addresses/index.tsx b/frontend/src/pages/addresses/index.tsx index cbfde53..6af6967 100644 --- a/frontend/src/pages/addresses/index.tsx +++ b/frontend/src/pages/addresses/index.tsx @@ -6,14 +6,15 @@ import { Card, Address } from '@/libs/frontend-ui-primitives' import { transactionsApi, Transaction } from '@/services/api/transactions' import { readWatchlistFromStorage } from '@/utils/watchlist' import PageIntro from '@/components/common/PageIntro' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { fetchPublicJson } from '@/utils/publicExplorer' -import { normalizeTransaction } from '@/services/api/blockscout' +import { statsApi } from '@/services/api/stats' +import { missionControlApi } from '@/services/api/missionControl' import { isEnsName, resolveEnsAddress } from '@/utils/ens' import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' @@ -34,20 +35,6 @@ interface AddressesPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null } -function serializeRecentTransactions(transactions: Transaction[]): Transaction[] { - return JSON.parse( - JSON.stringify( - transactions.map((transaction) => ({ - hash: transaction.hash, - block_number: transaction.block_number, - from_address: transaction.from_address, - to_address: transaction.to_address ?? null, - created_at: transaction.created_at, - })), - ), - ) as Transaction[] -} - export default function AddressesPage({ initialRecentTransactions, initialLatestBlocks, @@ -58,11 +45,14 @@ export default function AddressesPage({ const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const [query, setQuery] = useState('') const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) + const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks) + const [stats, setStats] = useState(initialStats) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [watchlist, setWatchlist] = useState([]) const activityContext = useMemo( () => summarizeChainActivity({ - blocks: initialLatestBlocks.map((block) => ({ + blocks: latestBlocks.map((block) => ({ chain_id: chainId, number: block.number, hash: '', @@ -73,12 +63,12 @@ export default function AddressesPage({ gas_limit: 0, })), transactions: recentTransactions, - latestBlockNumber: initialLatestBlocks[0]?.number ?? null, - latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null, - freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), - diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null, + latestBlockNumber: latestBlocks[0]?.number ?? null, + latestBlockTimestamp: latestBlocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null, }), - [chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions], + [chainId, bridgeStatus, latestBlocks, stats, recentTransactions], ) useEffect(() => { @@ -104,6 +94,47 @@ export default function AddressesPage({ } }, [chainId, initialRecentTransactions]) + useEffect(() => { + if (initialStats && initialBridgeStatus && initialLatestBlocks.length > 0) { + setStats(initialStats) + setBridgeStatus(initialBridgeStatus) + setLatestBlocks(initialLatestBlocks) + return + } + + let active = true + + Promise.allSettled([ + statsApi.get().catch(() => null), + missionControlApi.getBridgeStatus().catch(() => null), + fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>( + '/api/v2/blocks?page=1&page_size=3', + ).catch(() => null), + ]).then(([statsResult, bridgeResult, blocksResult]) => { + if (!active) return + if (statsResult.status === 'fulfilled' && statsResult.value) { + setStats(statsResult.value) + } + if (bridgeResult.status === 'fulfilled' && bridgeResult.value) { + setBridgeStatus(bridgeResult.value) + } + if (blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)) { + setLatestBlocks( + blocksResult.value.items + .map((item) => ({ + number: Number(item.height || 0), + timestamp: item.timestamp || '', + })) + .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp), + ) + } + }) + + return () => { + active = false + } + }, [initialBridgeStatus, initialLatestBlocks, initialStats]) + useEffect(() => { if (typeof window === 'undefined') { return @@ -127,7 +158,7 @@ export default function AddressesPage({ if (seen.has(normalized)) continue seen.add(normalized) addresses.push(candidate) - if (addresses.length >= 12) return addresses + if (addresses.length >= 8) return addresses } } @@ -135,6 +166,15 @@ export default function AddressesPage({ }, [recentTransactions]) const [opening, setOpening] = useState(false) + const [listTab, setListTab] = useState<'watchlist' | 'recent'>('watchlist') + + const addressListTabs = useMemo[]>( + () => [ + { id: 'watchlist', label: 'Watchlist', count: watchlist.length }, + { id: 'recent', label: 'Recently active', count: activeAddresses.length }, + ], + [activeAddresses.length, watchlist.length], + ) const handleOpenAddress = async (event: React.FormEvent) => { event.preventDefault() @@ -162,80 +202,83 @@ export default function AddressesPage({ } return ( -
    +
    -
    - - -
    + - -
    + + setQuery(event.target.value)} - placeholder="0x... or name.eth" - className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500" + placeholder="0x… or name.eth" + className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900" /> -

    - Open any Chain 138 address or resolve a mainnet `.eth` name, then jump into your saved watchlist below. -

    -
    - + + + + {watchlist.length === 0 ? (

    - No saved addresses yet. Address detail pages let you add entries to the shared explorer watchlist. + No saved addresses yet. Add entries from address detail pages.

    ) : ( -
    +
    {watchlist.map((entry) => (
    ))} -
    - - Open the full watchlist → - -
    + + Full watchlist → +
    )} + - + + {activeAddresses.length === 0 ? (

    - Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above. + No recent address activity in the visible transaction sample.

    ) : ( -
    +
    {activeAddresses.map((entry) => (
    @@ -244,37 +287,16 @@ export default function AddressesPage({
    )} -
    +
    ) } -export const getServerSideProps: GetServerSideProps = async () => { - const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([ - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null), - fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null), - fetchPublicJson>('/api/v2/stats').catch(() => null), - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - ]) - const initialRecentTransactions = Array.isArray(transactionsResult?.items) - ? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)) - : [] - const initialLatestBlocks = Array.isArray(blocksResult?.items) - ? blocksResult.items - .map((item) => ({ - number: Number(item.height || 0), - timestamp: item.timestamp || '', - })) - .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp) - : [] - - return { - props: { - initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions), - initialLatestBlocks, - initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, - initialBridgeStatus: bridgeResult, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialRecentTransactions: [], + initialLatestBlocks: [], + initialStats: null, + initialBridgeStatus: null, + }, +}) diff --git a/frontend/src/pages/analytics/index.tsx b/frontend/src/pages/analytics/index.tsx index b55f0ca..63870a2 100644 --- a/frontend/src/pages/analytics/index.tsx +++ b/frontend/src/pages/analytics/index.tsx @@ -1,18 +1,9 @@ import type { GetServerSideProps } from 'next' import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage' -import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout' -import { - normalizeTransactionTrend, - summarizeRecentTransactions, - type ExplorerRecentActivitySnapshot, - type ExplorerStats, - type ExplorerTransactionTrendPoint, -} from '@/services/api/stats' +import type { ExplorerRecentActivitySnapshot, ExplorerStats, ExplorerTransactionTrendPoint } from '@/services/api/stats' import type { Block } from '@/services/api/blocks' import type { Transaction } from '@/services/api/transactions' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' -import { fetchPublicJson } from '@/utils/publicExplorer' -import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' interface AnalyticsPageProps { initialStats: ExplorerStats | null @@ -23,79 +14,17 @@ interface AnalyticsPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null } -function serializeBlocks(blocks: Block[]): Block[] { - return JSON.parse( - JSON.stringify( - blocks.map((block) => ({ - chain_id: block.chain_id, - number: block.number, - hash: block.hash, - timestamp: block.timestamp, - miner: block.miner, - gas_used: block.gas_used, - gas_limit: block.gas_limit, - transaction_count: block.transaction_count, - })), - ), - ) as Block[] -} - -function serializeTransactions(transactions: Transaction[]): Transaction[] { - return JSON.parse( - JSON.stringify( - transactions.map((transaction) => ({ - hash: transaction.hash, - block_number: transaction.block_number, - from_address: transaction.from_address, - to_address: transaction.to_address ?? null, - value: transaction.value, - status: transaction.status ?? null, - contract_address: transaction.contract_address ?? null, - fee: transaction.fee ?? null, - })), - ), - ) as Transaction[] -} - export default function AnalyticsPage(props: AnalyticsPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [truthContextResult, trendResult, activityResult, blocksResult, transactionsResult] = await Promise.allSettled([ - fetchExplorerTruthContext(), - fetchPublicJson('/api/v2/stats/charts/transactions'), - fetchPublicJson('/api/v2/main-page/transactions'), - fetchPublicJson('/api/v2/blocks?page=1&page_size=5'), - fetchPublicJson('/api/v2/transactions?page=1&page_size=5'), - ]) - - return { - props: { - initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null, - initialTransactionTrend: - trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [], - initialActivitySnapshot: - activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value as never) : null, - initialBlocks: - blocksResult.status === 'fulfilled' && Array.isArray((blocksResult.value as { items?: unknown[] })?.items) - ? serializeBlocks( - ((blocksResult.value as { items?: unknown[] }).items || []).map((item) => - normalizeBlock(item as never, chainId), - ), - ) - : [], - initialTransactions: - transactionsResult.status === 'fulfilled' && Array.isArray((transactionsResult.value as { items?: unknown[] })?.items) - ? serializeTransactions( - ((transactionsResult.value as { items?: unknown[] }).items || []).map((item) => - normalizeTransaction(item as never, chainId), - ), - ) - : [], - initialBridgeStatus: - truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialStats: null, + initialTransactionTrend: [], + initialActivitySnapshot: null, + initialBlocks: [], + initialTransactions: [], + initialBridgeStatus: null, + }, +}) diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index a140044..244a64f 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -6,8 +6,10 @@ import { blocksApi, Block } from '@/services/api/blocks' import { Card, Address, Table } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { DetailRow } from '@/components/common/DetailRow' +import CompactMetricBar from '@/components/common/CompactMetricBar' import PageIntro from '@/components/common/PageIntro' -import { formatTimestamp, formatWeiAsEth } from '@/utils/format' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import { formatInteger, formatTimestamp, formatWeiAsEth } from '@/utils/format' import { type Transaction } from '@/services/api/transactions' import { useBlockTransactions } from '@/hooks/useBlockTransactions' @@ -20,6 +22,7 @@ export default function BlockDetailPage() { const [block, setBlock] = useState(null) const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<'summary' | 'transactions'>('summary') const { transactions: blockTransactions, @@ -123,40 +126,38 @@ export default function BlockDetailPage() { ? 'No indexed block transactions were returned for this page yet.' : 'This block does not contain any indexed transactions.' + const blockIntroActions = useMemo(() => { + const actions = [ + { href: '/blocks', label: 'All blocks' }, + { href: '/transactions', label: 'Transactions' }, + ] + if (block && block.number > 0) { + actions.push({ href: `/blocks/${block.number - 1}`, label: 'Previous' }) + } + if (block) { + actions.push({ href: `/blocks/${block.number + 1}`, label: 'Next' }) + } + return actions + }, [block]) + + const blockTabs = useMemo[]>( + () => [ + { id: 'summary', label: 'Summary' }, + { id: 'transactions', label: 'Transactions', count: block?.transaction_count ?? 0 }, + ], + [block?.transaction_count], + ) + return ( -
    +
    -
    - - Back to blocks - - {block && block.number > 0 ? ( - - Previous block - - ) : null} - {block && ( - - Next block - - )} - {block?.transaction_count ? ( - - Open block transactions - - ) : null} -
    - {!router.isReady || loading ? (

    Loading block...

    @@ -186,6 +187,26 @@ export default function BlockDetailPage() {
    ) : ( + <> + + + + +
    @@ -200,12 +221,16 @@ export default function BlockDetailPage() { - + - {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} + {formatInteger(block.gas_used)} / {formatInteger(block.gas_limit)} {gasUtilization != null && ( @@ -214,13 +239,13 @@ export default function BlockDetailPage() { )}
    - )} +
    - {block && ( - + +

    - This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search. + Indexed transaction set for block #{formatInteger(block.number)}.

    {transactionsLoading ? (

    Loading block transactions...

    @@ -261,6 +286,8 @@ export default function BlockDetailPage() { )}
    +
    + )}
    ) diff --git a/frontend/src/pages/blocks/index.tsx b/frontend/src/pages/blocks/index.tsx index 5891376..95ace13 100644 --- a/frontend/src/pages/blocks/index.tsx +++ b/frontend/src/pages/blocks/index.tsx @@ -1,22 +1,22 @@ import type { GetServerSideProps } from 'next' import { useCallback, useEffect, useMemo, useState } from 'react' import { blocksApi, Block } from '@/services/api/blocks' -import { Card, Address } from '@/libs/frontend-ui-primitives' +import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import PageIntro from '@/components/common/PageIntro' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' import { formatTimestamp } from '@/utils/format' import { fetchPublicJson } from '@/utils/publicExplorer' import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout' import type { Transaction } from '@/services/api/transactions' import { transactionsApi } from '@/services/api/transactions' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' import PaginationControls from '@/components/common/PaginationControls' import { useListPageQuery } from '@/utils/useListPageQuery' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' +import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' interface BlocksPageProps { initialBlocks: Block[] @@ -49,9 +49,11 @@ export default function BlocksPage({ initialStats, initialBridgeStatus, }: BlocksPageProps) { - const pageSize = 20 + const pageSize = 15 const [blocks, setBlocks] = useState(initialBlocks) const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) + const [stats, setStats] = useState(initialStats) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [loading, setLoading] = useState(initialBlocks.length === 0) const { page, setPage } = useListPageQuery() const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') @@ -108,8 +110,35 @@ export default function BlocksPage({ } }, [chainId, initialRecentTransactions]) - const showPagination = page > 1 || blocks.length > 0 - const canGoNext = blocks.length === pageSize + useEffect(() => { + if (initialStats && initialBridgeStatus) { + setStats(initialStats) + setBridgeStatus(initialBridgeStatus) + return + } + + let active = true + fetchExplorerTruthContext() + .then((context) => { + if (!active) return + setStats((current) => current ?? context.initialStats) + setBridgeStatus((current) => current ?? context.initialBridgeStatus) + }) + .catch(() => undefined) + + return () => { + active = false + } + }, [initialStats, initialBridgeStatus]) + + const paginatedBlocks = useMemo(() => { + const start = (page - 1) * pageSize + return blocks.slice(start, start + pageSize) + }, [blocks, page, pageSize]) + + const showPagination = blocks.length > pageSize || page > 1 + const canGoNext = page * pageSize < blocks.length + const activityContext = useMemo( () => summarizeChainActivity({ @@ -117,95 +146,79 @@ export default function BlocksPage({ transactions: recentTransactions, latestBlockNumber: blocks[0]?.number ?? null, latestBlockTimestamp: blocks[0]?.timestamp ?? null, - freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), - diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null, }), - [blocks, initialBridgeStatus, initialStats, recentTransactions], + [blocks, bridgeStatus, stats, recentTransactions], ) + const columns = [ + { + header: 'Block', + accessor: (block: Block) => ( + + #{block.number} + + ), + }, + { + header: 'Age', + accessor: (block: Block) => ( + {formatTimestamp(block.timestamp)} + ), + }, + { + header: 'Miner', + accessor: (block: Block) => ( + +
    + + ), + }, + { + header: 'Txs', + accessor: (block: Block) => block.transaction_count, + }, + ] + return ( -
    +
    -
    - - -
    + + + {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? ( +

    + Recent head blocks are empty; use the latest transaction block for visible activity. +

    + ) : null} {loading ? (

    Loading blocks...

    ) : ( -
    - {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? ( - -

    - Recent head blocks are currently empty; use the latest transaction block for recent visible activity. -

    -
    - ) : null} - {blocks.length === 0 ? ( - -

    Recent blocks are unavailable right now.

    -
    - - Open recent transactions → - - - Search by block number → - -
    -
    - ) : ( - blocks.map((block) => ( - -
    -
    - - Block #{block.number} - -
    -
    -
    -
    - Miner:{' '} - -
    - -
    -
    -
    -
    - {formatTimestamp(block.timestamp)} -
    -
    - {block.transaction_count} transactions -
    -
    -
    -
    - )) - )} -
    + String(block.number)} + /> )} {showPagination ? ( @@ -219,48 +232,22 @@ export default function BlocksPage({ className="mt-6" /> ) : null} - -
    - -

    - Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses. -

    -
    - - Transactions → - - - Addresses → - - - Search → - -
    -
    -
    ) } export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([ - fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null), - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null), - fetchPublicJson>('/api/v2/stats').catch(() => null), - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - ]) + const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=15').catch(() => null) return { props: { initialBlocks: Array.isArray(blocksResult?.items) ? blocksResult.items.map((item) => normalizeBlock(item as never, chainId)) : [], - initialRecentTransactions: Array.isArray(transactionsResult?.items) - ? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))) - : [], - initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, - initialBridgeStatus: bridgeResult, + initialRecentTransactions: [], + initialStats: null, + initialBridgeStatus: null, }, } } diff --git a/frontend/src/pages/docs/gru.tsx b/frontend/src/pages/docs/gru.tsx index 63c57c7..a0f58c5 100644 --- a/frontend/src/pages/docs/gru.tsx +++ b/frontend/src/pages/docs/gru.tsx @@ -7,8 +7,9 @@ import EntityBadge from '@/components/common/EntityBadge' export default function GruDocsPage() { return ( -
    +
    +
    -
    - -
    - {docsCards.map((item) => ( -
    -
    {item.title}
    -

    {item.description}

    -
    - - Open guide → - -
    -
    - ))} -
    -
    - -
    - -
    -

    - The docs are meant to prove the explorer, not merely describe it. Each guide below links back into live Chain 138 pages where the - documented signals can be inspected directly. -

    -
    - - Search cUSDT → - - - Open cUSDT token page → - - - Browse recent transactions → - -
    -
    -
    - - -
    -

    - DBIS Explorer is the public Chain 138 explorer operated by DBIS. Primary public access is served at - explorer.d-bis.org; blockscout.defi-oracle.io is the Blockscout companion domain. -

    -

    - These domains are part of the same explorer and companion-tooling surface, including the Snap install path at - /snap/. Support and policy notices are handled through - support@d-bis.org. -

    -
    -
    - - -
    - {policyLinks.map((item) => ( - - ))} -
    -
    - - -
    -

    - The public explorer docs cover GRU posture, transaction review scoring, liquidity access, and navigation into the broader Chain 138 surfaces. -

    -

    - Support: support@d-bis.org -

    -

    - Command center: open visual map → -

    -
    -
    + +
    + {docsCards.map((item) => ( + +
    {item.title}
    +

    {item.description}

    + + ))}
    -
    + + + +
    +

    + Primary domain explorer.d-bis.org; Blockscout companion blockscout.defi-oracle.io. + Support: support@d-bis.org +

    +
    + {policyLinks.map((item) => ( + + {item.label} + + ))} + + Verify cUSDT live + + + Command center map + +
    +
    +
    ) } diff --git a/frontend/src/pages/docs/posture-glossary.tsx b/frontend/src/pages/docs/posture-glossary.tsx index 0d26488..695b35e 100644 --- a/frontend/src/pages/docs/posture-glossary.tsx +++ b/frontend/src/pages/docs/posture-glossary.tsx @@ -8,8 +8,9 @@ import { postureGlossaryTerms } from '@/data/postureGlossary' export default function PostureGlossaryDocsPage() { return ( -
    +
    +
    +
    } -function serializeTransactions(transactions: Transaction[]): Transaction[] { - return JSON.parse( - JSON.stringify( - transactions.map((transaction) => ({ - hash: transaction.hash, - block_number: transaction.block_number, - from_address: transaction.from_address, - to_address: transaction.to_address ?? null, - value: transaction.value, - status: transaction.status ?? null, - contract_address: transaction.contract_address ?? null, - fee: transaction.fee ?? null, - created_at: transaction.created_at, - })), - ), - ) as Transaction[] -} - -export const getServerSideProps: GetServerSideProps = async () => { - const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - - const [truthContextResult, blocksResult, transactionsResult, trendResult, activityResult] = await Promise.allSettled([ - fetchExplorerTruthContext(), - fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'), - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'), - fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>( - '/api/v2/stats/charts/transactions' - ), - fetchPublicJson< - Array<{ - status?: string | null - transaction_types?: string[] | null - gas_used?: number | string | null - fee?: { value?: string | number | null } | string | null - }> - >('/api/v2/main-page/transactions'), - ]) - - return { - props: { - initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null, - initialRecentBlocks: - blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items) - ? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId)) - : [], - initialRecentTransactions: - transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items) - ? serializeTransactions( - transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)), - ) - : [], - initialTransactionTrend: - trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [], - initialActivitySnapshot: - activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null, - initialBridgeStatus: - truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null, - initialRelaySummary: - truthContextResult.status === 'fulfilled' && truthContextResult.value.initialBridgeStatus - ? summarizeMissionControlRelay(truthContextResult.value.initialBridgeStatus as never) - : null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialStats: null, + initialRecentBlocks: [], + initialRecentTransactions: [], + initialTransactionTrend: [], + initialActivitySnapshot: null, + initialBridgeStatus: null, + initialRelaySummary: null, + }, +}) diff --git a/frontend/src/pages/liquidity/index.tsx b/frontend/src/pages/liquidity/index.tsx index 34e992e..7342e79 100644 --- a/frontend/src/pages/liquidity/index.tsx +++ b/frontend/src/pages/liquidity/index.tsx @@ -5,9 +5,6 @@ import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { ExplorerStats } from '@/services/api/stats' -import { fetchPublicJson } from '@/utils/publicExplorer' -import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' -import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' interface TokenPoolRecord { symbol: string @@ -24,70 +21,18 @@ interface LiquidityPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null } -const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']) - -async function fetchPublicPostJson(path: string, body: unknown): Promise { - const response = await fetch(`https://blockscout.defi-oracle.io${path}`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - }) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - return (await response.json()) as T -} - export default function LiquidityPage(props: LiquidityPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] = - await Promise.all([ - loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), - fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), - fetchPublicJson('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch( - () => null, - ), - fetchPublicPostJson('/token-aggregation/api/v2/routes/internal-execution-plan', { - sourceChainId: 138, - tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', - amountIn: '100000000000000000', - }).catch(() => null), - fetchExplorerTruthContext(), - ]) - - const featuredTokens = (tokenListResult?.tokens || []).filter( - (token) => token.chainId === 138 && typeof token.symbol === 'string' && featuredTokenSymbols.has(token.symbol), - ) - - const tokenPoolsResults = await Promise.all( - featuredTokens.map(async (token) => { - const response = await fetchPublicJson<{ pools?: MissionControlLiquidityPool[]; data?: { pools?: MissionControlLiquidityPool[] } }>( - `/explorer-api/v1/mission-control/liquidity/token/${token.address}/pools`, - ).catch(() => null) - const pools = Array.isArray(response?.pools) - ? response.pools - : Array.isArray(response?.data?.pools) - ? response.data.pools - : [] - return { symbol: token.symbol || token.address || 'unknown', pools } - }), - ).catch(() => [] as TokenPoolRecord[]) - - return { - props: { - initialTokenList: tokenListResult, - initialRouteMatrix: routeMatrixResult, - initialPlannerCapabilities: plannerCapabilitiesResult, - initialInternalPlan: internalPlanResult, - initialTokenPoolRecords: tokenPoolsResults, - initialStats: truthContext.initialStats, - initialBridgeStatus: truthContext.initialBridgeStatus, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialTokenList: null, + initialRouteMatrix: null, + initialPlannerCapabilities: null, + initialInternalPlan: null, + initialTokenPoolRecords: [], + initialStats: null, + initialBridgeStatus: null, + }, +}) diff --git a/frontend/src/pages/operator/index.tsx b/frontend/src/pages/operator/index.tsx index c1df2b2..5136e6a 100644 --- a/frontend/src/pages/operator/index.tsx +++ b/frontend/src/pages/operator/index.tsx @@ -1,6 +1,5 @@ import type { GetServerSideProps } from 'next' import OperatorOperationsPage from '@/components/explorer/OperatorOperationsPage' -import { fetchPublicJson } from '@/utils/publicExplorer' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner' import type { RouteMatrixResponse } from '@/services/api/routes' @@ -16,21 +15,11 @@ export default function OperatorPage(props: OperatorPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [bridgeStatus, routeMatrix, plannerCapabilities] = await Promise.all([ - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), - fetchPublicJson('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch( - () => null, - ), - ]) - - return { - props: { - initialBridgeStatus: bridgeStatus, - initialRouteMatrix: routeMatrix, - initialPlannerCapabilities: plannerCapabilities, - initialInternalPlan: null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialBridgeStatus: null, + initialRouteMatrix: null, + initialPlannerCapabilities: null, + initialInternalPlan: null, + }, +}) diff --git a/frontend/src/pages/protocols/[id].tsx b/frontend/src/pages/protocols/[id].tsx index 82f4fa1..aa1d6ce 100644 --- a/frontend/src/pages/protocols/[id].tsx +++ b/frontend/src/pages/protocols/[id].tsx @@ -5,9 +5,12 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { Card, Address } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import SectionTabs, { TabPanel } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import { fetchOfficialProtocols, type OfficialProtocolRow, type ProtocolVerificationReport } from '@/services/api/officialProtocols' +import { formatTimestamp } from '@/utils/format' export default function ProtocolDetailPage() { const router = useRouter() @@ -17,6 +20,7 @@ export default function ProtocolDetailPage() { const [verification, setVerification] = useState(null) const [registryMeta, setRegistryMeta] = useState<{ schemaVersion?: string; updated?: string; generatedAt?: string }>({}) const [loading, setLoading] = useState(true) + const [protocolTab, setProtocolTab] = useState<'overview' | 'contracts'>('overview') useEffect(() => { if (!router.isReady || !protocolId) return @@ -42,20 +46,20 @@ export default function ProtocolDetailPage() { }, [protocolId, router.isReady]) return ( -
    +
    @@ -74,9 +78,22 @@ export default function ProtocolDetailPage() { ) : null} {protocol ? ( -
    + <> + + + {verification ? ( - +

    - Live bytecode probe via Chain 138 RPC at {new Date(verification.verifiedAt).toLocaleString()}. + Live bytecode probe via Chain 138 RPC at {formatTimestamp(verification.verifiedAt)}. {registryMeta.updated ? ` Registry updated ${registryMeta.updated}.` : ''}

    @@ -135,12 +152,28 @@ export default function ProtocolDetailPage() { ) : null} + {forbidden.length > 0 ? ( + + +
      + {forbidden.slice(0, 5).map((row) => ( +
    • + {row.id} — {row.description} +
    • + ))} +
    +
    +
    + ) : null} + + + -
    +
    {protocol.contracts.map((contract) => ( -
    +
    {contract.name}
    -
    +
    @@ -155,19 +188,8 @@ export default function ProtocolDetailPage() { ))}
    - - {forbidden.length > 0 ? ( - -
      - {forbidden.slice(0, 5).map((row) => ( -
    • - {row.id} — {row.description} -
    • - ))} -
    -
    - ) : null} -
    + + ) : null}
    ) diff --git a/frontend/src/pages/protocols/index.tsx b/frontend/src/pages/protocols/index.tsx index 01507df..6349f12 100644 --- a/frontend/src/pages/protocols/index.tsx +++ b/frontend/src/pages/protocols/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import { fetchOfficialProtocols, type OfficialProtocolsResponse } from '@/services/api/officialProtocols' @@ -27,15 +28,15 @@ export default function ProtocolsIndexPage() { }, []) return ( -
    +
    @@ -55,17 +56,17 @@ export default function ProtocolsIndexPage() { ) : null} {report ? ( -
    - +
    +
      {report.guardrails.slice(0, 4).map((line) => (
    • {line}
    • ))}
    - +
    -
    +
    {report.protocols.map((protocol) => ( (null) const [poolRegistryRows, setPoolRegistryRows] = useState([]) + const [indexedPage, setIndexedPage] = useState(1) + const [activeSearchTab, setActiveSearchTab] = useState('indexed') const runSearch = async (rawQuery: string) => { const trimmedQuery = rawQuery.trim() @@ -329,6 +338,62 @@ export default function SearchPage({ { label: 'Other', items: groupedResults.other }, ] + const indexedPageCount = Math.max(1, Math.ceil(filteredResults.length / SEARCH_INDEXED_PAGE_SIZE)) + const paginatedFilteredResults = useMemo(() => { + const safePage = Math.min(Math.max(indexedPage, 1), indexedPageCount) + const start = (safePage - 1) * SEARCH_INDEXED_PAGE_SIZE + return filteredResults.slice(start, start + SEARCH_INDEXED_PAGE_SIZE) + }, [filteredResults, indexedPage, indexedPageCount]) + const paginatedGroupedResults = useMemo( + () => ({ + tokens: paginatedFilteredResults.filter((result) => result.type === 'token'), + addresses: paginatedFilteredResults.filter((result) => result.type === 'address'), + transactions: paginatedFilteredResults.filter((result) => result.type === 'transaction'), + blocks: paginatedFilteredResults.filter((result) => result.type === 'block'), + other: paginatedFilteredResults.filter((result) => !['token', 'address', 'transaction', 'block'].includes(result.type)), + }), + [paginatedFilteredResults], + ) + const paginatedResultSections = useMemo( + () => [ + { label: 'Tokens', items: paginatedGroupedResults.tokens }, + { label: 'Addresses', items: paginatedGroupedResults.addresses }, + { label: 'Transactions', items: paginatedGroupedResults.transactions }, + { label: 'Blocks', items: paginatedGroupedResults.blocks }, + { label: 'Other', items: paginatedGroupedResults.other }, + ], + [paginatedGroupedResults], + ) + const searchTabs = useMemo[]>(() => { + const tabs: SectionTab[] = [] + if (filteredResults.length > 0) tabs.push({ id: 'indexed', label: 'Indexed', count: filteredResults.length }) + if (meshSeed && (meshLoading || meshRows.length > 0)) tabs.push({ id: 'mesh', label: 'Mesh', count: meshRows.length }) + if (poolMatches.length > 0 && hasSearched) tabs.push({ id: 'pools', label: 'Pools', count: poolMatches.length }) + if (filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading) { + tabs.push({ id: 'wrapped', label: 'Wrapped', count: filteredWrappedMatches.length }) + } + return tabs + }, [ + filteredResults.length, + filteredWrappedMatches.length, + hasSearched, + meshLoading, + meshRows.length, + meshSeed, + poolMatches.length, + ]) + + useEffect(() => { + setIndexedPage(1) + }, [trimmedQuery, filterMode]) + + useEffect(() => { + if (searchTabs.length === 0) return + if (!searchTabs.some((tab) => tab.id === activeSearchTab)) { + setActiveSearchTab(searchTabs[0].id) + } + }, [activeSearchTab, searchTabs]) + useEffect(() => { if (!meshSeed || !hasSearched) { setMeshRows([]) @@ -413,21 +478,19 @@ export default function SearchPage({ }, [filteredResults]) return ( -
    +
    @@ -470,6 +533,25 @@ export default function SearchPage({ )} + {!loading && hasSearched && searchTabs.length > 0 ? ( + <> + + + {!loading && tokenTarget && !meshSeed && (

    @@ -523,174 +605,6 @@ export default function SearchPage({ )} - {!loading && poolMatches.length > 0 && hasSearched && ( - -

    - Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge - underlying c*/cW* instead. -

    -
    - {poolMatches.slice(0, 12).map((row) => ( - -
    - - - -
    -
    - Pool
    -
    - - ))} -
    -
    - )} - - {!loading && meshSeed && (meshLoading || meshRows.length > 0) && ( - -

    - {mode === 'guided' - ? `Resolved ${meshSeed.hubSymbol} hub on Chain 138 and mapped ${meshSeed.wrappedSymbol} addresses across configured transport pairs via token-mapping.` - : `${meshSeed.hubSymbol} ↔ ${meshSeed.wrappedSymbol} mesh (${meshSeed.matchReason}).`} -

    - {polygonMapper?.officialPolygonTokenList?.upstreamRepo ? ( -

    - Polygon (137) rows align with{' '} - - official Polygon Token Mapper - - {polygonMapper.match?.polygonAddress - ? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…` - : ''} - . -

    - ) : null} - {meshLoading ? ( -

    Resolving mesh addresses…

    - ) : ( -
    - {meshRows.map((row) => { - const href = externalChainExplorerUrl(row.chainId, row.address) - const content = ( - <> -
    - - - - - {row.chainId === 137 && polygonMapper ? ( - - ) : null} -
    -
    -
    -
    - - ) - if (!href) { - return ( -
    - {content} -
    - ) - } - if (row.chainId === 138) { - return ( - - {content} - - ) - } - return ( - - {content} - - ) - })} -
    - )} -
    - )} - - {!loading && filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading && ( - -

    - Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers. -

    -
    - {filteredWrappedMatches.slice(0, 24).map((row) => { - const href = externalChainExplorerUrl(row.chainId, row.address) - const content = ( - <> -
    - - - - - -
    -
    -
    -
    - - ) - if (!href) { - return ( -
    - {content} -
    - ) - } - if (row.chainId === 138) { - return ( - - {content} - - ) - } - return ( - - {content} - - ) - })} -
    -
    - )} - {!loading && !tokenTarget && directTarget && !deferAddressJump && (

    @@ -724,7 +638,7 @@ export default function SearchPage({ )} - {results.length > 0 && ( + {filteredResults.length > 0 && (

    @@ -751,7 +665,7 @@ export default function SearchPage({ GRU guide →
    - {resultSections.map((section) => + {paginatedResultSections.map((section) => section.items.length > 0 ? (
    @@ -829,19 +743,197 @@ export default function SearchPage({
    ) : null, )} +
    )} {!loading && hasSearched && hasSupplementalMatches && filteredResults.length === 0 && !error ? ( - +

    Blockscout indexed search returned no hits for{' '} {trimmedQuery}, but curated mesh, - pool, or wrapped-token registry matches are shown below. + pool, or wrapped-token registry matches are shown in the other tabs.

    ) : null} + + + + +

    + Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge + underlying c*/cW* instead. +

    +
    + {poolMatches.slice(0, 6).map((row) => ( + +
    + + + +
    +
    + Pool
    +
    + + ))} +
    +
    +
    + + + +

    + {mode === 'guided' + ? `Resolved ${meshSeed?.hubSymbol} hub on Chain 138 and mapped ${meshSeed?.wrappedSymbol} addresses across configured transport pairs via token-mapping.` + : `${meshSeed?.hubSymbol} ↔ ${meshSeed?.wrappedSymbol} mesh (${meshSeed?.matchReason}).`} +

    + {polygonMapper?.officialPolygonTokenList?.upstreamRepo ? ( +

    + Polygon (137) rows align with{' '} + + official Polygon Token Mapper + + {polygonMapper.match?.polygonAddress + ? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…` + : ''} + . +

    + ) : null} + {meshLoading ? ( +

    Resolving mesh addresses…

    + ) : ( +
    + {meshRows.map((row) => { + const href = externalChainExplorerUrl(row.chainId, row.address) + const content = ( + <> +
    + + + + + {row.chainId === 137 && polygonMapper ? ( + + ) : null} +
    +
    +
    +
    + + ) + if (!href) { + return ( +
    + {content} +
    + ) + } + if (row.chainId === 138) { + return ( + + {content} + + ) + } + return ( + + {content} + + ) + })} +
    + )} +
    +
    + + + +

    + Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers. +

    +
    + {filteredWrappedMatches.slice(0, 8).map((row) => { + const href = externalChainExplorerUrl(row.chainId, row.address) + const content = ( + <> +
    + + + + + +
    +
    +
    +
    + + ) + if (!href) { + return ( +
    + {content} +
    + ) + } + if (row.chainId === 138) { + return ( + + {content} + + ) + } + return ( + + {content} + + ) + })} +
    +
    +
    + + ) : null} {!loading && hasSearched && !error && filteredResults.length === 0 && !hasSupplementalMatches && ( @@ -865,6 +957,7 @@ export default function SearchPage({ )} {!loading && !hasSearched && ( +
    @@ -897,6 +990,7 @@ export default function SearchPage({ ) : null}
    + )}
    ) @@ -923,21 +1017,10 @@ export const getServerSideProps: GetServerSideProps = async (co } } - const shouldFetchSearch = - Boolean(initialQuery) && - !inferTokenSearchTarget(initialQuery, initialCuratedTokens) && - !inferDirectSearchTarget(initialQuery) - - const searchResult = shouldFetchSearch - ? await fetchPublicJson<{ items?: RawExplorerSearchItem[] }>( - `/api/v2/search?q=${encodeURIComponent(initialQuery)}`, - ).catch(() => null) - : null - return { props: { initialQuery, - initialRawResults: Array.isArray(searchResult?.items) ? searchResult.items : [], + initialRawResults: [], initialCuratedTokens, }, } diff --git a/frontend/src/pages/system/index.tsx b/frontend/src/pages/system/index.tsx index acb6fb4..10f4bc9 100644 --- a/frontend/src/pages/system/index.tsx +++ b/frontend/src/pages/system/index.tsx @@ -1,11 +1,9 @@ import type { GetServerSideProps } from 'next' import SystemOperationsPage from '@/components/explorer/SystemOperationsPage' -import { fetchPublicJson } from '@/utils/publicExplorer' -import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { RouteMatrixResponse } from '@/services/api/routes' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' interface SystemPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null @@ -20,24 +18,13 @@ export default function SystemPage(props: SystemPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([ - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - fetchPublicJson('/api/config/networks').catch(() => null), - loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), - fetchPublicJson('/api/config/capabilities').catch(() => null), - fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), - fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null), - ]) - - return { - props: { - initialBridgeStatus: bridgeStatus, - initialNetworksConfig: networksConfig, - initialTokenList: tokenList, - initialCapabilities: capabilities, - initialRouteMatrix: routeMatrix, - initialStats: stats, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialBridgeStatus: null, + initialNetworksConfig: null, + initialTokenList: null, + initialCapabilities: null, + initialRouteMatrix: null, + initialStats: null, + }, +}) diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index 0d98f7a..b217719 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -14,12 +14,13 @@ import PostureBadge from '@/components/common/PostureBadge' import GruStandardsCard from '@/components/common/GruStandardsCard' import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import AddTokenToWalletButton from '@/components/wallet/AddTokenToWalletButton' import PaginationControls from '@/components/common/PaginationControls' -import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const TOKEN_DETAIL_TABS_ID = 'token-detail' -import { formatTokenAmount, formatTimestamp } from '@/utils/format' +import { formatInteger, 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' @@ -165,7 +166,7 @@ export default function TokenDetailPage() { if (provenanceTags.includes('compliant')) signals.push('marked compliant') if (provenanceTags.includes('bridge')) signals.push('bridge-linked asset') if (liquiditySummary.poolCount > 0) signals.push(`${liquiditySummary.poolCount} related liquidity pool${liquiditySummary.poolCount === 1 ? '' : 's'}`) - if ((token?.holders || 0) > 0) signals.push(`${token?.holders?.toLocaleString()} indexed holders`) + if ((token?.holders || 0) > 0) signals.push(`${formatInteger(token?.holders ?? 0)} indexed holders`) return signals }, [liquiditySummary.poolCount, provenance?.listed, provenanceTags, token?.holders]) @@ -342,24 +343,24 @@ export default function TokenDetailPage() { }, { header: 'TVL', - accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${Math.round(pool.tvl).toLocaleString()}` : 'N/A', + accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${formatInteger(Math.round(pool.tvl))}` : 'N/A', }, ] return ( -
    +
    -
    +
    Back to tokens @@ -402,6 +403,16 @@ export default function TokenDetailPage() { ) : null} +
    + +
    {token.name || provenance?.name || 'Unknown'} {token.symbol || provenance?.symbol || 'Unknown'} @@ -434,7 +445,7 @@ export default function TokenDetailPage() { )} {token.holders != null && ( - {token.holders.toLocaleString()} + {formatInteger(token.holders)} )} {provenanceTags.length > 0 ? provenanceTags.map((tag) => ( @@ -464,7 +475,7 @@ export default function TokenDetailPage() { ariaLabel="Token details" /> -
    +
    @@ -486,7 +497,7 @@ export default function TokenDetailPage() {
    Liquidity & Distribution
    -
    Related pools: {liquiditySummary.poolCount.toLocaleString()}
    +
    Related pools: {formatInteger(liquiditySummary.poolCount)}
    Total visible TVL: {formatUsd(liquiditySummary.totalTvl)}
    Largest visible holder: {holderConcentration != null ? `${holderConcentration}% of supply` : 'Unavailable'}
    @@ -494,9 +505,9 @@ export default function TokenDetailPage() {
    Transfer Activity
    -
    Recent transfer sample: {transferFlowSummary.sampleSize.toLocaleString()}
    -
    Unique senders: {transferFlowSummary.uniqueSenders.toLocaleString()}
    -
    Unique recipients: {transferFlowSummary.uniqueRecipients.toLocaleString()}
    +
    Recent transfer sample: {formatInteger(transferFlowSummary.sampleSize)}
    +
    Unique senders: {formatInteger(transferFlowSummary.uniqueSenders)}
    +
    Unique recipients: {formatInteger(transferFlowSummary.uniqueRecipients)}
    @@ -511,9 +522,9 @@ export default function TokenDetailPage() {
    -
    + -
    + {gruProfile ? : null} {gruExplorerMetadata ? ( @@ -594,9 +605,9 @@ export default function TokenDetailPage() {
    ) : null} -
    +
    -
    +
    - + -
    + {gruExplorerMetadata ? (
    @@ -632,9 +643,9 @@ export default function TokenDetailPage() { /> -
    +
    -
    +
    - + )} diff --git a/frontend/src/pages/tokens/index.tsx b/frontend/src/pages/tokens/index.tsx index 2c7715c..baef5fb 100644 --- a/frontend/src/pages/tokens/index.tsx +++ b/frontend/src/pages/tokens/index.tsx @@ -1,11 +1,14 @@ import type { GetStaticProps } from 'next' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' import EntityBadge from '@/components/common/EntityBadge' +import ListFilterBar from '@/components/common/ListFilterBar' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import PaginationControls from '@/components/common/PaginationControls' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { tokensApi, type IndexedTokenListItem } from '@/services/api/tokens' import type { TokenListToken } from '@/services/api/config' import { @@ -14,8 +17,9 @@ import { sortIndexedTokensCanonicalFirst, } from '@/utils/canonicalTokens' import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' -import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces' +import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces' import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens' +import { formatInteger } from '@/utils/format' const quickSearches = [ { label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' }, @@ -26,11 +30,23 @@ const quickSearches = [ { label: 'USDT', description: 'Native-side USDT routes and address discovery.' }, ] +const CURATED_PAGE_SIZE = 6 +const INDEXED_PAGE_SIZE = 12 + +type TokensPageTab = 'curated' | 'indexed' | 'shortcuts' + function normalizeAddress(value: string) { const trimmed = value.trim() return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : '' } +function matchesTokenQuery(token: { symbol?: string | null; name?: string | null; address?: string | null }, query: string) { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return true + const haystack = [token.symbol, token.name, token.address].filter(Boolean).join(' ').toLowerCase() + return haystack.includes(normalizedQuery) +} + interface TokensPageProps { initialCuratedTokens: TokenListToken[] } @@ -59,7 +75,11 @@ function tagPriority(tag: string) { export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { const router = useRouter() - const [query, setQuery] = useState('') + const [lookupQuery, setLookupQuery] = useState('') + const [listQuery, setListQuery] = useState('') + const [activeTab, setActiveTab] = useState('curated') + const [curatedPage, setCuratedPage] = useState(1) + const [indexedPage, setIndexedPage] = useState(1) const [curatedTokens, setCuratedTokens] = useState(initialCuratedTokens) const [indexedTokens, setIndexedTokens] = useState([]) const [indexedLoading, setIndexedLoading] = useState(true) @@ -67,8 +87,8 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { const handleSubmit = (event: React.FormEvent) => { event.preventDefault() - const normalized = normalizeAddress(query) - router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(query.trim())}`) + const normalized = normalizeAddress(lookupQuery) + router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(lookupQuery.trim())}`) } useEffect(() => { @@ -104,26 +124,50 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { [canonicalAddressSet, indexedTokens], ) - useEffect(() => { - let active = true + const filteredCuratedTokens = useMemo( + () => featuredCuratedTokens.filter((token) => matchesTokenQuery(token, listQuery)), + [featuredCuratedTokens, listQuery], + ) + + const filteredIndexedTokens = useMemo( + () => sortedIndexedTokens.filter((token) => matchesTokenQuery(token, listQuery)), + [listQuery, sortedIndexedTokens], + ) + + const curatedPageCount = Math.max(1, Math.ceil(filteredCuratedTokens.length / CURATED_PAGE_SIZE)) + const paginatedCuratedTokens = useMemo(() => { + const safePage = Math.min(Math.max(curatedPage, 1), curatedPageCount) + const start = (safePage - 1) * CURATED_PAGE_SIZE + return filteredCuratedTokens.slice(start, start + CURATED_PAGE_SIZE) + }, [curatedPage, curatedPageCount, filteredCuratedTokens]) + + const loadIndexedTokens = useCallback(async (page: number) => { setIndexedLoading(true) - - tokensApi.listIndexedSafe(1, 50).then(({ ok, data }) => { - if (!active) return + try { + const { ok, data } = await tokensApi.listIndexedSafe(page, INDEXED_PAGE_SIZE) setIndexedTokens(ok ? data : []) + } catch { + setIndexedTokens([]) + } finally { setIndexedLoading(false) - }).catch(() => { - if (active) { - setIndexedTokens([]) - setIndexedLoading(false) - } - }) - - return () => { - active = false } }, []) + useEffect(() => { + void loadIndexedTokens(indexedPage) + }, [indexedPage, loadIndexedTokens]) + + useEffect(() => { + setCuratedPage(1) + setIndexedPage(1) + }, [listQuery]) + + useEffect(() => { + if (curatedPage > curatedPageCount) { + setCuratedPage(curatedPageCount) + } + }, [curatedPage, curatedPageCount]) + useEffect(() => { let active = true @@ -146,103 +190,148 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { } }, [featuredCuratedTokens]) + const tokenTabs = useMemo[]>( + () => [ + { id: 'curated', label: 'Curated', count: featuredCuratedTokens.length }, + { id: 'indexed', label: 'Indexed', count: sortedIndexedTokens.length }, + { id: 'shortcuts', label: 'Quick searches', count: quickSearches.length }, + ], + [featuredCuratedTokens.length, sortedIndexedTokens.length], + ) + + const indexedHasNextPage = indexedTokens.length === INDEXED_PAGE_SIZE + return ( -
    +
    - -
    - setQuery(event.target.value)} - placeholder="Token symbol, name, or contract address" - className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950" - /> - - -
    + -
    - -

    - {TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list. -

    -
    - {featuredCuratedTokens - .filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0) - .map((token) => { - const market = featuredMarkets[token.address.toLowerCase()]?.market - return ( - -
    -
    -
    {token.symbol || token.name || 'Token'}
    -

    - {token.name || 'Listed in the Chain 138 token registry.'} -

    -
    -
    - {market ? ( -
    -
    -
    Price
    -
    {formatUsd(market.priceUsd)}
    -
    -
    -
    Liquidity
    -
    {formatUsd(market.liquidityUsd)}
    -
    -
    - -
    -
    - ) : null} - {token.tags && token.tags.length > 0 && ( -
    - {[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => ( - - ))} -
    - )} - - ) - })} -
    + + +
    + setLookupQuery(event.target.value)} + placeholder="Token symbol, name, or contract address" + className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950" + /> + +
    -
    + + +
    + {paginatedCuratedTokens.length === 0 ? ( +

    + No curated tokens match your filter. +

    + ) : ( + paginatedCuratedTokens + .filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0) + .map((token) => { + const market = featuredMarkets[token.address.toLowerCase()]?.market + return ( + +
    +
    +
    {token.symbol || token.name || 'Token'}
    +

    + {token.name || 'Listed in the Chain 138 token registry.'} +

    +
    +
    + {market ? ( +
    +
    +
    Price
    +
    {formatUsd(market.priceUsd)}
    +
    +
    +
    Liquidity
    +
    {formatUsd(market.liquidityUsd)}
    +
    +
    + +
    +
    + ) : null} + {token.tags && token.tags.length > 0 ? ( +
    + {[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => ( + + ))} +
    + ) : null} + + ) + }) + )} +
    + +
    + -
    + -

    - Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings. -

    + {indexedLoading ? ( -

    Loading indexed token feed…

    - ) : sortedIndexedTokens.length === 0 ? ( -

    Indexed token feed is temporarily unavailable.

    +

    Loading indexed token feed…

    + ) : filteredIndexedTokens.length === 0 ? ( +

    + {sortedIndexedTokens.length === 0 + ? 'Indexed token feed is temporarily unavailable.' + : 'No indexed tokens on this page match your filter.'} +

    ) : ( -
    - {sortedIndexedTokens.map((token) => { +
    + {filteredIndexedTokens.map((token) => { const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet) return ( {token.name || token.address}

    - {token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'} + {token.holders != null ? `${formatInteger(token.holders)} holders` : 'Holders unavailable'}
    ) })}
    )} +
    -
    + -
    +
    {quickSearches.map((token) => ( @@ -289,7 +386,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { ))}
    -
    +
    ) } diff --git a/frontend/src/pages/transactions/[hash].tsx b/frontend/src/pages/transactions/[hash].tsx index bd2ccc6..8ad2194 100644 --- a/frontend/src/pages/transactions/[hash].tsx +++ b/frontend/src/pages/transactions/[hash].tsx @@ -11,13 +11,15 @@ import { TransactionLookupDiagnostic, TransactionTokenTransfer, } from '@/services/api/transactions' -import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' +import { formatInteger, formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' import PostureBadge from '@/components/common/PostureBadge' import PageIntro from '@/components/common/PageIntro' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' import PaginationControls from '@/components/common/PaginationControls' -import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const TRANSACTION_DETAIL_TABS_ID = 'transaction-detail' @@ -388,29 +390,19 @@ export default function TransactionDetailPage() { }, [hash]) return ( -
    +
    -
    - - Back to transactions - - {(transaction?.hash || hash) && ( - - Search this hash - - )} -
    - {!router.isReady || loading ? (

    Loading transaction...

    @@ -451,7 +443,17 @@ export default function TransactionDetailPage() {
    ) : ( -
    +
    + + +
    @@ -472,7 +474,7 @@ export default function TransactionDetailPage() { Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'} {nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
    -
    Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}
    +
    Gas used: {transaction.gas_used != null ? formatInteger(transaction.gas_used) : 'Unavailable'}
    Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}
    @@ -491,8 +493,8 @@ export default function TransactionDetailPage() { {checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
    ) : null} -
    Token transfers: {tokenTransferCount.toLocaleString()}
    -
    Internal calls: {internalCallCount.toLocaleString()}
    +
    Token transfers: {formatInteger(tokenTransferCount)}
    +
    Internal calls: {formatInteger(internalCallCount)}
    @@ -513,25 +515,28 @@ export default function TransactionDetailPage() {
    + {checkpointAttestation ? ( + + ) : null} -
    + {complianceAssessment ? (
    @@ -565,9 +570,9 @@ export default function TransactionDetailPage() {

    )} -
    +
    -
    +
    @@ -616,7 +621,7 @@ export default function TransactionDetailPage() { {transaction.gas_used != null && ( - {transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()} + {formatInteger(transaction.gas_used)} / {formatInteger(transaction.gas_limit)} )} {transaction.revert_reason && ( @@ -662,9 +667,9 @@ export default function TransactionDetailPage() {
    )} -
    + -
    +
    - + -
    +
    - + {transaction.input_data ? ( -
    +
                     {transaction.input_data}
                   
    -
    + ) : null} )} diff --git a/frontend/src/pages/transactions/index.tsx b/frontend/src/pages/transactions/index.tsx index c3374bd..7207b71 100644 --- a/frontend/src/pages/transactions/index.tsx +++ b/frontend/src/pages/transactions/index.tsx @@ -4,18 +4,19 @@ import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { transactionsApi, Transaction } from '@/services/api/transactions' import { formatWeiAsEth } from '@/utils/format' -import EntityBadge from '@/components/common/EntityBadge' import PageIntro from '@/components/common/PageIntro' import { fetchPublicJson } from '@/utils/publicExplorer' import { normalizeTransaction } from '@/services/api/blockscout' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' import PaginationControls from '@/components/common/PaginationControls' +import ListFilterBar from '@/components/common/ListFilterBar' +import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs' import { useListPageQuery } from '@/utils/useListPageQuery' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' +import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' interface TransactionsPageProps { initialTransactions: Transaction[] @@ -44,21 +45,55 @@ function serializeTransactionList(transactions: Transaction[]): Transaction[] { ) as Transaction[] } +type TransactionFilterTab = 'all' | 'success' | 'failed' | 'contracts' | 'transfers' + +function matchesTransactionQuery(transaction: Transaction, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return true + const fields = [ + transaction.hash, + transaction.from_address, + transaction.to_address || '', + transaction.contract_address || '', + ] + return fields.some((field) => field.toLowerCase().includes(normalizedQuery)) +} + +function matchesTransactionFilter(transaction: Transaction, filter: TransactionFilterTab): boolean { + switch (filter) { + case 'success': + return transaction.status === 1 + case 'failed': + return transaction.status != null && transaction.status !== 1 + case 'contracts': + return Boolean(transaction.contract_address) + case 'transfers': + return (transaction.token_transfers?.length || 0) > 0 + default: + return true + } +} + export default function TransactionsPage({ initialTransactions, initialLatestBlocks, initialStats, initialBridgeStatus, }: TransactionsPageProps) { - const pageSize = 20 + const pageSize = 15 const [transactions, setTransactions] = useState(initialTransactions) + const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks) + const [stats, setStats] = useState(initialStats) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [loading, setLoading] = useState(initialTransactions.length === 0) + const [filterTab, setFilterTab] = useState('all') + const [filterQuery, setFilterQuery] = useState('') const { page, setPage } = useListPageQuery() const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const activityContext = useMemo( () => summarizeChainActivity({ - blocks: initialLatestBlocks.map((block) => ({ + blocks: latestBlocks.map((block) => ({ chain_id: chainId, number: block.number, hash: '', @@ -69,12 +104,12 @@ export default function TransactionsPage({ gas_limit: 0, })), transactions, - latestBlockNumber: initialLatestBlocks[0]?.number ?? null, - latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null, - freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), - diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null, + latestBlockNumber: latestBlocks[0]?.number ?? null, + latestBlockTimestamp: latestBlocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null, }), - [chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions], + [chainId, bridgeStatus, latestBlocks, stats, transactions], ) const loadTransactions = useCallback(async () => { @@ -99,8 +134,59 @@ export default function TransactionsPage({ void loadTransactions() }, [initialTransactions, loadTransactions, page]) + useEffect(() => { + if (initialLatestBlocks.length > 0 && initialStats && initialBridgeStatus) { + setLatestBlocks(initialLatestBlocks) + setStats(initialStats) + setBridgeStatus(initialBridgeStatus) + return + } + + let active = true + Promise.all([ + fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>( + '/api/v2/blocks?page=1&page_size=3', + ).catch(() => null), + fetchExplorerTruthContext(), + ]) + .then(([blocksResult, truthContext]) => { + if (!active) return + if (initialLatestBlocks.length === 0 && Array.isArray(blocksResult?.items)) { + setLatestBlocks( + blocksResult.items + .map((item) => ({ + number: Number(item.height || 0), + timestamp: item.timestamp || '', + })) + .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp), + ) + } + setStats((current) => current ?? truthContext.initialStats) + setBridgeStatus((current) => current ?? truthContext.initialBridgeStatus) + }) + .catch(() => undefined) + + return () => { + active = false + } + }, [initialBridgeStatus, initialLatestBlocks, initialStats]) + + useEffect(() => { + setPage(1) + }, [filterTab, filterQuery, setPage]) + + const filteredTransactions = useMemo( + () => transactions.filter((transaction) => matchesTransactionFilter(transaction, filterTab) && matchesTransactionQuery(transaction, filterQuery)), + [filterQuery, filterTab, transactions], + ) + + const paginatedTransactions = useMemo(() => { + const start = (page - 1) * pageSize + return filteredTransactions.slice(start, start + pageSize) + }, [filteredTransactions, page, pageSize]) + const transactionSummary = useMemo(() => { - const sampleSize = transactions.length + const sampleSize = paginatedTransactions.length if (sampleSize === 0) { return { sampleSize: 0, @@ -111,12 +197,12 @@ export default function TransactionsPage({ } } - const successes = transactions.filter((transaction) => transaction.status === 1).length - const contractCreations = transactions.filter((transaction) => Boolean(transaction.contract_address)).length - const tokenTransferTransactions = transactions.filter( + const successes = paginatedTransactions.filter((transaction) => transaction.status === 1).length + const contractCreations = paginatedTransactions.filter((transaction) => Boolean(transaction.contract_address)).length + const tokenTransferTransactions = paginatedTransactions.filter( (transaction) => (transaction.token_transfers?.length || 0) > 0, ).length - const feeValues = transactions + const feeValues = paginatedTransactions .map((transaction) => { if (!transaction.fee) return null const numeric = Number(transaction.fee) @@ -135,10 +221,23 @@ export default function TransactionsPage({ tokenTransferTransactions, averageFee, } + }, [paginatedTransactions]) + + const filterTabs = useMemo[]>(() => { + const countFor = (filter: TransactionFilterTab) => + transactions.filter((transaction) => matchesTransactionFilter(transaction, filter)).length + + return [ + { id: 'all', label: 'All', count: transactions.length }, + { id: 'success', label: 'Success', count: countFor('success') }, + { id: 'failed', label: 'Failed', count: countFor('failed') }, + { id: 'contracts', label: 'Contract creation', count: countFor('contracts') }, + { id: 'transfers', label: 'Token transfers', count: countFor('transfers') }, + ] }, [transactions]) - const showPagination = page > 1 || transactions.length > 0 - const canGoNext = transactions.length === pageSize + const showPagination = filteredTransactions.length > pageSize || page > 1 + const canGoNext = page * pageSize < filteredTransactions.length const columns = [ { @@ -188,56 +287,63 @@ export default function TransactionsPage({ ] return ( -
    +
    -
    - - -
    + - {!loading && transactions.length > 0 && ( -
    - -
    Sample Size
    -
    {transactionSummary.sampleSize.toLocaleString()}
    -
    Transactions on the current explorer page.
    -
    - -
    Success Rate
    -
    - {transactionSummary.successRate}% - = 90 ? 'healthy' : 'mixed'} tone={transactionSummary.successRate >= 90 ? 'success' : 'warning'} /> -
    -
    Based on the visible recent transaction sample.
    -
    - -
    Contract Creations
    -
    {transactionSummary.contractCreations.toLocaleString()}
    -
    New contracts created in the visible sample.
    -
    - -
    Avg Sample Fee
    -
    {transactionSummary.averageFee || 'Unavailable'}
    -
    - Token-transfer txs: {transactionSummary.tokenTransferTransactions.toLocaleString()} -
    -
    + + + + + {!loading && paginatedTransactions.length > 0 && ( +
    + + {transactionSummary.sampleSize} on page + + + {transactionSummary.successRate}% success + + + {transactionSummary.contractCreations} contracts + + + {transactionSummary.tokenTransferTransactions} with transfers + + {transactionSummary.averageFee ? ( + + avg fee {transactionSummary.averageFee} + + ) : null}
    )} @@ -248,8 +354,12 @@ export default function TransactionsPage({ ) : (
    tx.hash} /> )} @@ -265,55 +375,25 @@ export default function TransactionsPage({ className="mt-6" /> ) : null} - -
    - -

    - Use the linked hashes above to inspect detail pages, or pivot into block production, address activity, and explorer-wide search. -

    -
    - - Blocks → - - - Addresses → - - - Search → - -
    -
    -
    ) } export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([ - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null), - fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null), - fetchPublicJson>('/api/v2/stats').catch(() => null), - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - ]) + const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=15').catch( + () => null, + ) const initialTransactions = Array.isArray(transactionsResult?.items) ? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)) : [] - const initialLatestBlocks = Array.isArray(blocksResult?.items) - ? blocksResult.items - .map((item) => ({ - number: Number(item.height || 0), - timestamp: item.timestamp || '', - })) - .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp) - : [] return { props: { initialTransactions: serializeTransactionList(initialTransactions), - initialLatestBlocks, - initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, - initialBridgeStatus: bridgeResult, + initialLatestBlocks: [], + initialStats: null, + initialBridgeStatus: null, }, } } diff --git a/frontend/src/pages/wallet/index.tsx b/frontend/src/pages/wallet/index.tsx index d132a41..1a789ec 100644 --- a/frontend/src/pages/wallet/index.tsx +++ b/frontend/src/pages/wallet/index.tsx @@ -6,7 +6,6 @@ import type { NetworksCatalog, TokenListCatalog, } from '@/components/wallet/AddToMetaMask' -import { fetchPublicJsonWithMeta } from '@/utils/publicExplorer' interface WalletRoutePageProps { initialNetworks: NetworksCatalog | null @@ -21,21 +20,13 @@ export default function WalletRoutePage(props: WalletRoutePageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([ - fetchPublicJsonWithMeta('/api/config/networks').catch(() => null), - fetchPublicJsonWithMeta('/api/v1/report/token-list?chainId=138&wallet=1').catch(() => null), - fetchPublicJsonWithMeta('/api/config/capabilities').catch(() => null), - ]) - - return { - props: { - initialNetworks: networksResult?.data || null, - initialTokenList: tokenListResult?.data || null, - initialCapabilities: capabilitiesResult?.data || null, - initialNetworksMeta: networksResult?.meta || null, - initialTokenListMeta: tokenListResult?.meta || null, - initialCapabilitiesMeta: capabilitiesResult?.meta || null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialNetworks: null, + initialTokenList: null, + initialCapabilities: null, + initialNetworksMeta: null, + initialTokenListMeta: null, + initialCapabilitiesMeta: null, + }, +}) diff --git a/frontend/src/pages/watchlist/index.tsx b/frontend/src/pages/watchlist/index.tsx index ec97d2a..f1eb110 100644 --- a/frontend/src/pages/watchlist/index.tsx +++ b/frontend/src/pages/watchlist/index.tsx @@ -4,7 +4,8 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { Card, Address } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' -import { Explain, useUiMode } from '@/components/common/UiModeContext' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' import { accessApi, type WalletAccessSession } from '@/services/api/access' import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses' import { @@ -29,7 +30,6 @@ function shortAddress(value?: string | null): string { } export default function WatchlistPage() { - const { mode } = useUiMode() const [entries, setEntries] = useState([]) const [walletSession, setWalletSession] = useState(null) const [snapshots, setSnapshots] = useState>({}) @@ -178,53 +178,29 @@ export default function WatchlistPage() { } return ( -
    +
    - -
    -
    -
    Tracked addresses
    -
    {entries.length}
    -
    -
    -
    Recent indexed activity
    -
    {trackedSummaries.withRecentTransactions}
    -
    Entries with at least one visible recent transaction.
    -
    -
    -
    EOAs / contracts
    -
    - {trackedSummaries.eoas} / {trackedSummaries.contracts} -
    -
    -
    -
    Visible tx volume
    -
    - {trackedSummaries.totalTransactions.toLocaleString()} -
    -
    Aggregate indexed transaction count across tracked entries.
    -
    -
    - - This view keeps tracked-address shortcuts and quick explorer evidence in one place so Guided mode can explain what each entity represents while Expert mode stays denser. - -
    + - + {walletSession ? (
    @@ -261,9 +237,9 @@ export default function WatchlistPage() { Connect a wallet from the wallet tools page to pin your own address into tracked workflows.
    )} - + - +

    {entries.length === 0 @@ -314,31 +290,13 @@ export default function WatchlistPage() {

    -
    -
    -
    Type
    -
    - {info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? 'Loading…' : 'Unknown'} -
    -
    -
    -
    Indexed txs
    -
    - {info ? info.transaction_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'} -
    -
    -
    -
    Token holdings
    -
    - {info ? info.token_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'} -
    -
    -
    -
    Recent visible tx
    -
    - {recentTransaction ? `#${recentTransaction.block_number.toLocaleString()}` : loadingSnapshots ? 'Loading…' : 'None visible'} -
    -
    +
    + {info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? '…' : 'Unknown'} + {info ? `${info.transaction_count} txs` : ''} + {info ? `${info.token_count} tokens` : ''} + + {recentTransaction ? `Latest #${recentTransaction.block_number}` : loadingSnapshots ? '' : 'No recent tx'} +
    diff --git a/frontend/src/services/api/missionControl.ts b/frontend/src/services/api/missionControl.ts index 7f4ace8..562cf3d 100644 --- a/frontend/src/services/api/missionControl.ts +++ b/frontend/src/services/api/missionControl.ts @@ -13,6 +13,7 @@ export interface MissionControlRelayItemSummary { status: string text: string tone: 'normal' | 'warning' | 'danger' + polledAt?: string | null } export interface MissionControlRelaySnapshot { @@ -162,17 +163,6 @@ function getMissionControlBridgeStatusUrl(): string { return `${getExplorerApiBase()}/explorer-api/v1/track1/bridge/status` } -function relativeAge(isoString?: string): string { - if (!isoString) return '' - const parsed = Date.parse(isoString) - if (!Number.isFinite(parsed)) return '' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) - return `${hours}h ago` -} function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: string): string { if (snapshot.last_error?.scope === 'bridge_inventory') { @@ -239,12 +229,10 @@ export function summarizeMissionControlRelay( const statusLabel = describeRelayStatus(snapshot, status) const destination = snapshot.destination?.chain_name const queueSize = snapshot.queue?.size - const pollAge = relativeAge(snapshot.last_source_poll?.at) let text = `${label}: ${statusLabel}` if (destination) text += ` -> ${destination}` if (queueSize != null) text += ` · queue ${queueSize}` - if (pollAge) text += ` · polled ${pollAge}` let tone: MissionControlRelaySummary['tone'] = 'normal' if (['paused', 'starting'].includes(status)) { @@ -254,7 +242,14 @@ export function summarizeMissionControlRelay( tone = 'danger' } - return { key, label, status, text, tone } + return { + key, + label, + status, + text, + tone, + polledAt: snapshot.last_source_poll?.at || null, + } }) .filter((item): item is MissionControlRelayItemSummary => item !== null) diff --git a/frontend/src/utils/activityContext.ts b/frontend/src/utils/activityContext.ts index dab0a00..45c2718 100644 --- a/frontend/src/utils/activityContext.ts +++ b/frontend/src/utils/activityContext.ts @@ -22,11 +22,6 @@ function sortDescending(values: number[]): number[] { return [...values].sort((left, right) => right - left) } -function toTimestamp(value?: string | null): number | null { - if (!value) return null - const parsed = Date.parse(value) - return Number.isFinite(parsed) ? parsed : null -} export function summarizeChainActivity(input: { blocks?: Block[] @@ -71,13 +66,7 @@ export function summarizeChainActivity(input: { const transactionVisibilityUnavailable = freshness?.latest_indexed_transaction.source === 'unavailable' || freshness?.latest_indexed_transaction.completeness === 'unavailable' - const latestTransactionAgeSeconds = - freshness?.latest_indexed_transaction.age_seconds ?? - (() => { - const timestamp = toTimestamp(latestTransactionTimestamp) - if (timestamp == null) return null - return Math.max(0, Math.round((Date.now() - timestamp) / 1000)) - })() + const latestTransactionAgeSeconds = freshness?.latest_indexed_transaction.age_seconds ?? null const gap = freshness?.latest_non_empty_block.distance_from_head ?? diagnostics?.tx_lag_blocks ?? diff --git a/frontend/src/utils/format.test.ts b/frontend/src/utils/format.test.ts index 6d5d477..7b5415b 100644 --- a/frontend/src/utils/format.test.ts +++ b/frontend/src/utils/format.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { formatWeiAsEth } from './format' +import { + formatBigIntWhole, + formatInteger, + formatRelativeAgeAt, + formatTimestamp, + formatWeiAsEth, +} from './format' describe('formatWeiAsEth', () => { it('formats zero and whole ETH values without losing precision', () => { @@ -13,3 +19,32 @@ describe('formatWeiAsEth', () => { expect(formatWeiAsEth('9007199254740993')).toBe('0.009 ETH') }) }) + +describe('formatInteger', () => { + it('uses en-US grouping for finite numbers', () => { + expect(formatInteger(1234567)).toBe('1,234,567') + expect(formatInteger(null)).toBe('0') + }) +}) + +describe('formatBigIntWhole', () => { + it('groups bigint digits with commas', () => { + expect(formatBigIntWhole(1234567890123456789n)).toBe('1,234,567,890,123,456,789') + expect(formatBigIntWhole(-42n)).toBe('-42') + }) +}) + +describe('formatTimestamp', () => { + it('uses fixed en-US locale formatting', () => { + const formatted = formatTimestamp('2026-01-15T12:30:00.000Z') + expect(formatted).toMatch(/1\/15\/2026/) + expect(formatted).toMatch(/PM|AM/) + }) +}) + +describe('formatRelativeAgeAt', () => { + it('computes relative age from a fixed reference time', () => { + const reference = Date.parse('2026-06-21T12:00:00.000Z') + expect(formatRelativeAgeAt(reference, '2026-06-21T11:59:30.000Z')).toBe('30s ago') + }) +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 32b4d62..da1ab17 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -60,20 +60,44 @@ export function formatTokenAmount(value: string | number | null | undefined, dec return symbol ? `${formatted} ${symbol}` : formatted } -export function formatTimestamp(value?: string | null): string { - if (!value) return 'Unknown' - const date = new Date(value) +const TIMESTAMP_LOCALE = 'en-US' +const TIMESTAMP_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, +} +const DATE_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', +} +const INTEGER_FORMAT = new Intl.NumberFormat(TIMESTAMP_LOCALE) + +export function formatTimestamp(value?: string | number | Date | null): string { + if (value == null) return 'Unknown' + const date = value instanceof Date ? value : new Date(value) if (Number.isNaN(date.getTime())) { - return value + return typeof value === 'string' ? value : 'Unknown' } - return date.toLocaleString() + return date.toLocaleString(TIMESTAMP_LOCALE, TIMESTAMP_OPTIONS) } -export function formatRelativeAge(value?: string | null): string { +export function formatDate(value?: string | number | Date | null): string { + if (value == null) return 'Unknown' + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return 'Unknown' + return date.toLocaleDateString(TIMESTAMP_LOCALE, DATE_OPTIONS) +} + +export function formatRelativeAgeAt(referenceMs: number, value?: string | null): string { if (!value) return 'Unknown' const parsed = Date.parse(value) if (!Number.isFinite(parsed)) return 'Unknown' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) + const seconds = Math.max(0, Math.round((referenceMs - parsed) / 1000)) if (seconds < 60) return `${seconds}s ago` const minutes = Math.round(seconds / 60) if (minutes < 60) return `${minutes}m ago` @@ -82,3 +106,20 @@ export function formatRelativeAge(value?: string | null): string { const days = Math.round(hours / 24) return `${days}d ago` } + +/** Client-only helper; prefer ClientRelativeTime in render paths to avoid hydration mismatch. */ +export function formatRelativeAge(value?: string | null): string { + return formatRelativeAgeAt(Date.now(), value) +} + +export function formatInteger(value?: number | null): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '0' + return INTEGER_FORMAT.format(value) +} + +export function formatBigIntWhole(value: bigint): string { + const negative = value < 0n + const digits = (negative ? -value : value).toString() + const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return negative ? `-${grouped}` : grouped +} diff --git a/frontend/src/utils/walletTokenBalances.ts b/frontend/src/utils/walletTokenBalances.ts index 7433a4d..7b5886c 100644 --- a/frontend/src/utils/walletTokenBalances.ts +++ b/frontend/src/utils/walletTokenBalances.ts @@ -1,3 +1,5 @@ +import { formatBigIntWhole } from './format' + export type EthereumRpcProvider = { request: (args: { method: string; params?: unknown }) => Promise } @@ -100,10 +102,10 @@ export async function filterTokensWithNonZeroBalance { + it('switches when chain already exists', async () => { + const request = vi.fn().mockResolvedValue(null) + const ok = await switchOrAddChain({ request }, chain138) + expect(ok).toBe(true) + expect(request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x8a' }], + }) + }) + + it('adds chain when wallet reports 4902', async () => { + const request = vi + .fn() + .mockRejectedValueOnce({ code: 4902 }) + .mockResolvedValueOnce(null) + const ok = await switchOrAddChain({ request }, chain138) + expect(ok).toBe(true) + expect(request).toHaveBeenNthCalledWith(2, { + method: 'wallet_addEthereumChain', + params: [ + expect.objectContaining({ + chainId: '0x8a', + chainName: 'DeFi Oracle Meta Mainnet', + }), + ], + }) + }) + + it('watchTokenInWallet passes hex chainId for Chain 138 tokens', async () => { + const request = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(true) + const result = await watchTokenInWallet( + { request }, + { + chainId: 138, + address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b', + symbol: 'cUSDC', + decimals: 6, + }, + chain138, + ) + expect(result).toEqual({ ok: true, added: true }) + expect(request).toHaveBeenNthCalledWith(2, { + method: 'wallet_watchAsset', + params: { + type: 'ERC20', + options: expect.objectContaining({ + chainId: '0x8a', + symbol: 'cUSDC', + }), + }, + }) + }) +}) diff --git a/frontend/src/utils/walletWatchToken.ts b/frontend/src/utils/walletWatchToken.ts new file mode 100644 index 0000000..c4c2685 --- /dev/null +++ b/frontend/src/utils/walletWatchToken.ts @@ -0,0 +1,64 @@ +import type { WalletChain } from '@/components/wallet/AddToMetaMask' +import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain' +import { + buildWatchAssetRpcRequest, + type WatchAssetToken, +} from '@/utils/walletWatchAsset' +import { + isMobileWalletContext, + type EthereumProvider, +} from '@/utils/walletProviderEnv' + +export async function switchOrAddChain( + ethereum: EthereumProvider, + chain: WalletChain, +): Promise { + try { + await ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chain.chainId }], + }) + return true + } catch (e) { + const err = e as { code?: number } + if (err.code !== 4902) return false + } + + try { + await ethereum.request({ + method: 'wallet_addEthereumChain', + params: [ + toWalletAddEthereumChainParams(chain, { + preferSingleRpc: isMobileWalletContext(), + }), + ], + }) + return true + } catch { + return false + } +} + +export async function watchTokenInWallet( + ethereum: EthereumProvider, + token: WatchAssetToken, + chain: WalletChain, +): Promise<{ ok: boolean; added?: boolean; error?: string }> { + const switched = await switchOrAddChain(ethereum, chain) + if (!switched) { + return { ok: false, error: `Failed to switch to ${chain.chainName}. Add the network first, then retry.` } + } + + try { + const added = await ethereum.request( + buildWatchAssetRpcRequest(token, isMobileWalletContext()), + ) + return { ok: true, added: Boolean(added) } + } catch (e) { + const err = e as { code?: number; message?: string } + if (err.code === 4001 || /rejected|denied|not been authorized/i.test(err.message || '')) { + return { ok: false, error: 'Wallet request was rejected. Approve the popup to add the token.' } + } + return { ok: false, error: err.message || 'Could not add token to wallet.' } + } +} diff --git a/scripts/deploy-next-frontend-to-vmid5000.sh b/scripts/deploy-next-frontend-to-vmid5000.sh index 3ee379c..773e828 100755 --- a/scripts/deploy-next-frontend-to-vmid5000.sh +++ b/scripts/deploy-next-frontend-to-vmid5000.sh @@ -233,6 +233,27 @@ if [[ -f "${SMOKE_SCRIPT}" ]]; then echo "WARN: institutional smoke failed — see ${SMOKE_SCRIPT}" >&2 } fi + +SMOKE_ROUTES="${FRONTEND_ROOT}/scripts/smoke-routes.mjs" +SMOKE_SCROLL="${FRONTEND_ROOT}/scripts/smoke-scroll-height.mjs" +if [[ -f "${SMOKE_ROUTES}" ]] && [[ "${EXPLORER_SKIP_ROUTE_SMOKE:-}" != "1" ]]; then + echo "" + echo "== Playwright route smoke (optional) ==" + if (cd "${FRONTEND_ROOT}" && npm exec -- playwright install chromium >/dev/null 2>&1 && BASE_URL="${EXPLORER_BASE:-https://explorer.d-bis.org}" npm run smoke:routes); then + echo "Route smoke: PASS" + else + echo "WARN: route smoke skipped or failed — set EXPLORER_SKIP_ROUTE_SMOKE=1 to silence; run npm install in frontend first" >&2 + fi +fi +if [[ -f "${SMOKE_SCROLL}" ]] && [[ "${EXPLORER_SKIP_SCROLL_SMOKE:-}" != "1" ]] && [[ "${EXPLORER_SKIP_ROUTE_SMOKE:-}" != "1" ]]; then + echo "" + echo "== Playwright scroll-height smoke (optional) ==" + if (cd "${FRONTEND_ROOT}" && BASE_URL="${EXPLORER_BASE:-https://explorer.d-bis.org}" npm run smoke:scroll); then + echo "Scroll-height smoke: PASS" + else + echo "WARN: scroll-height smoke failed — set EXPLORER_SKIP_SCROLL_SMOKE=1 to silence" >&2 + fi +fi echo "" echo "Nginx follow-up:" echo " Switch the explorer server block to proxy / and /_next/ to 127.0.0.1:${FRONTEND_PORT}"