Fix explorer routing, links, and frontend API loading

This commit is contained in:
defiQUG
2026-03-28 00:21:18 -07:00
parent e229c82fdf
commit 59eee21a3f
23 changed files with 728 additions and 137 deletions

View File

@@ -13,6 +13,21 @@ The frontend is reachable at **https://explorer.d-bis.org** (FQDN) or by **VM IP
2. **Same-origin /api** When the site is served from the explorer host (FQDN `https://explorer.d-bis.org` or VM IP `http://192.168.11.140` / `https://192.168.11.140`), the frontend uses relative `/api` so all requests go through the same nginx proxy. If you open the frontend from elsewhere, the code falls back to the full Blockscout URL (CORS must allow it).
- If the API returns **200** but the UI still shows no data, check the browser console for JavaScript errors (e.g. CSP or network errors).
### Frontend env contract
For the Next frontend in `frontend/`, keep the runtime base URL at the **host origin**, not the `/api` subpath:
```env
NEXT_PUBLIC_API_URL=https://explorer.d-bis.org
NEXT_PUBLIC_CHAIN_ID=138
```
Why:
- The Next pages now call live **Blockscout v2** endpoints under `${NEXT_PUBLIC_API_URL}/api/v2/*`.
- Setting `NEXT_PUBLIC_API_URL=https://explorer.d-bis.org/api` will incorrectly produce requests like `/api/api/v2/*`.
- Token aggregation remains under `/token-aggregation/api/v1/*` and is linked separately by the frontend.
---
## CSP blocks eval / “script-src blocked”

View File

@@ -6,9 +6,11 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"build:check": "npm run lint && npm run type-check && npm run build",
"start": "PORT=${PORT:-3000} node .next/standalone/server.js",
"start:next": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit -p tsconfig.check.json",
"test": "npm run lint && npm run type-check",
"test:unit": "vitest run"
},

View File

@@ -18,7 +18,7 @@
banner.id = 'apiUnavailableBanner';
banner.setAttribute('role', 'alert');
banner.style.cssText = 'background: rgba(200,80,80,0.95); color: #fff; padding: 0.75rem 1rem; margin-bottom: 1rem; border-radius: 8px; font-size: 0.9rem;';
banner.innerHTML = '<strong>Explorer API temporarily unavailable</strong> (HTTP ' + status + '). Stats, blocks, and transactions cannot load until the backend is running. <a href="https://gitea.d-bis.org/d-bis/explorer-monorepo/src/branch/main/docs/EXPLORER_API_ACCESS.md" target="_blank" rel="noopener" style="color: #fff; text-decoration: underline;">See docs</a>.';
banner.innerHTML = '<strong>Explorer API temporarily unavailable</strong> (HTTP ' + status + '). Stats, blocks, and transactions cannot load until the backend is running. <a href="https://gitea.d-bis.org/d-bis/explorer-monorepo/src/branch/main/docs/EXPLORER_API_ACCESS.md" target="_blank" rel="noopener noreferrer" style="color: #fff; text-decoration: underline;">See docs</a>.';
main.insertBefore(banner, main.firstChild);
}
(function() {
@@ -2016,7 +2016,7 @@
async function renderHomeView() {
showView('home');
if ((window.location.pathname || '').replace(/^\//, '').replace(/\/$/, '') !== 'home') updatePath('/home');
if ((window.location.pathname || '').replace(/\/$/, '') !== '') updatePath('/');
await loadStats();
await loadLatestBlocks();
await loadLatestTransactions();
@@ -2115,7 +2115,7 @@
} else if (fromHash) {
route = fromHash;
}
if (!route) { showHome(); updatePath('/home'); return; }
if (!route || route === 'home') { if (currentView !== 'home') showHome(); return; }
var parts = route.split('/').filter(Boolean);
var decode = function(s) { try { return decodeURIComponent(s); } catch (e) { return s; } };
if (parts[0] === 'block' && parts[1]) { var p1 = decode(parts[1]); var key = 'block:' + p1; if (currentDetailKey === key) return; currentDetailKey = key; setTimeout(function() { showBlockDetail(p1); }, 0); return; }
@@ -2204,7 +2204,7 @@
// Update breadcrumb navigation
function updateBreadcrumb(type, identifier, identifierExtra) {
let breadcrumbContainer;
let breadcrumbHTML = '<a href="/home">Home</a>';
let breadcrumbHTML = '<a href="/">Home</a>';
switch (type) {
case 'block':
breadcrumbContainer = document.getElementById('blockDetailBreadcrumb');
@@ -2222,7 +2222,7 @@
break;
case 'address':
breadcrumbContainer = document.getElementById('addressDetailBreadcrumb');
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><span>Address</span><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">' + escapeHtml(shortenHash(identifier)) + '</span>';
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><a href="/addresses">Addresses</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">' + escapeHtml(shortenHash(identifier)) + '</span>';
break;
case 'token':
breadcrumbContainer = document.getElementById('tokenDetailBreadcrumb');
@@ -3716,7 +3716,7 @@
html += '<div class="card-header"><h3 class="card-title"><i class="fas fa-plug"></i> Public Explorer Access Points</h3></div>';
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:0.85rem;">';
endpointCards.forEach(function(card) {
html += '<a href="' + escapeAttr(card.href) + '" target="_self" rel="noopener" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:16px; padding:1rem; background:var(--muted-surface);">';
html += '<a href="' + escapeAttr(card.href) + '" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:16px; padding:1rem; background:var(--muted-surface);">';
html += '<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.5rem;">';
html += '<div style="font-weight:700; line-height:1.35;">' + escapeHtml(card.title) + '</div>';
html += '<span class="badge badge-info" style="white-space:nowrap;">' + escapeHtml(card.method) + '</span>';
@@ -4091,19 +4091,19 @@
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH9Bridge</div>
<div class="chain-stat-value">
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}', '_blank')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET}</span>
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET}</span>
</div>
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
<a href="https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}" target="_blank" style="color: var(--primary);">View on Etherscan</a>
<a href="https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Etherscan</a>
</div>
</div>
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH10Bridge</div>
<div class="chain-stat-value">
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}', '_blank')" style="cursor: pointer; font-size: 0.9rem;">${WETH10_BRIDGE_MAINNET}</span>
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH10_BRIDGE_MAINNET}</span>
</div>
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
<a href="https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}" target="_blank" style="color: var(--primary);">View on Etherscan</a>
<a href="https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Etherscan</a>
</div>
</div>
</div>
@@ -4678,7 +4678,8 @@
throw new Error('Address not found');
}
} catch (error) {
container.innerHTML = '<div class="error">Failed to load address: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showAddressDetail(\'' + address + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
var retryAddress = String(address || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
container.innerHTML = '<div class="error">Failed to load address: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showAddressDetail(\'' + retryAddress + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
return;
}
} else {
@@ -4694,26 +4695,29 @@
const balanceEth = formatEther(a.balance || '0');
const isContract = !!a.is_contract;
const verifiedBadge = a.is_verified ? '<span class="badge badge-success" style="margin-left: 0.5rem;">Verified</span>' : '';
const contractLink = isContract ? `<a href="${EXPLORER_ORIGIN}/address/${address}/contract" target="_blank" rel="noopener" style="color: var(--primary); font-size: 0.875rem;">View contract on Blockscout</a>` : '';
const encodedAddress = encodeURIComponent(address);
const escapedAddress = escapeHtml(address);
const addressForJs = address.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const contractLink = isContract ? `<a href="${EXPLORER_ORIGIN}/address/${encodedAddress}/contract" target="_blank" rel="noopener noreferrer" style="color: var(--primary); font-size: 0.875rem;">View contract on Blockscout</a>` : '';
const savedLabel = getAddressLabel(address);
const inWatchlist = isInWatchlist(address);
container.innerHTML = `
<div class="info-row">
<div class="info-label">Address</div>
<div class="info-value hash">${address} <button type="button" class="btn-copy" onclick="copyToClipboard('${address.replace(/'/g, "\\'")}', 'Copied');" aria-label="Copy address"><i class="fas fa-copy"></i></button></div>
<div class="info-value hash">${escapedAddress} <button type="button" class="btn-copy" onclick="copyToClipboard('${addressForJs}', 'Copied');" aria-label="Copy address"><i class="fas fa-copy"></i></button></div>
</div>
<div class="info-row">
<div class="info-label">Label</div>
<div class="info-value"><input type="text" id="addressLabelInput" value="${escapeHtml(savedLabel)}" placeholder="Optional label" style="padding: 0.35rem; border-radius: 6px; width: 200px; max-width: 100%;"> <button type="button" class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="var v=document.getElementById(\'addressLabelInput\').value; setAddressLabel(\'${address.replace(/'/g, "\\'")}\', v); showToast(\'Label saved\', \'success\');">Save</button></div>
<div class="info-value"><input type="text" id="addressLabelInput" value="${escapeHtml(savedLabel)}" placeholder="Optional label" style="padding: 0.35rem; border-radius: 6px; width: 200px; max-width: 100%;"> <button type="button" class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="var v=document.getElementById(\'addressLabelInput\').value; setAddressLabel(\'${addressForJs}\', v); showToast(\'Label saved\', \'success\');">Save</button></div>
</div>
<div class="info-row">
<div class="info-label">Watchlist</div>
<div class="info-value"><button type="button" id="addressWatchlistBtn" class="btn btn-primary" style="padding: 0.35rem 0.75rem;" onclick="(function(addr){ if(isInWatchlist(addr)){ removeFromWatchlist(addr); showToast(\'Removed from watchlist\', \'success\'); } else { addToWatchlist(addr); showToast(\'Added to watchlist\', \'success\'); } var b=document.getElementById(\'addressWatchlistBtn\'); if(b) b.innerHTML=isInWatchlist(addr)?\'<i class=\'fas fa-star\'></i> Remove from watchlist\':\'<i class=\'fas fa-star-o\'></i> Add to watchlist\'; })(\'${address.replace(/'/g, "\\'")}\')"><i class="fas fa-star${inWatchlist ? '' : '-o'}"></i> ${inWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}</button></div>
<div class="info-value"><button type="button" id="addressWatchlistBtn" class="btn btn-primary" style="padding: 0.35rem 0.75rem;" onclick="(function(addr){ if(isInWatchlist(addr)){ removeFromWatchlist(addr); showToast(\'Removed from watchlist\', \'success\'); } else { addToWatchlist(addr); showToast(\'Added to watchlist\', \'success\'); } var b=document.getElementById(\'addressWatchlistBtn\'); if(b) b.innerHTML=isInWatchlist(addr)?\'<i class=\'fas fa-star\'></i> Remove from watchlist\':\'<i class=\'fas fa-star-o\'></i> Add to watchlist\'; })(\'${addressForJs}\')"><i class="fas fa-star${inWatchlist ? '' : '-o'}"></i> ${inWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}</button></div>
</div>
<div class="info-row">
<div class="info-label">Token approvals</div>
<div class="info-value"><a href="https://revoke.cash/address/${encodeURIComponent(address)}${CHAIN_ID === 138 ? '?chainId=138' : ''}" target="_blank" rel="noopener" style="color: var(--primary);">Check token approvals</a></div>
<div class="info-value"><a href="https://revoke.cash/address/${encodedAddress}${CHAIN_ID === 138 ? '?chainId=138' : ''}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">Check token approvals</a></div>
</div>
<div class="info-row">
<div class="info-label">Balance</div>
@@ -4956,7 +4960,7 @@
}
el.dataset.loaded = '1';
if (!data) {
el.innerHTML = '<p style="color: var(--text-light);">Contract source not indexed. <a href="' + EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener">Verify on Blockscout</a></p>';
el.innerHTML = '<p style="color: var(--text-light);">Contract source not indexed. <a href="' + EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener noreferrer">Verify on Blockscout</a></p>';
return;
}
const abi = data.abi || data.abi_interface || [];
@@ -4984,7 +4988,7 @@
html += '<div id="writeContractValueRow" style="margin-top: 0.5rem; display: none;"><label>Value (ETH):</label><input type="text" id="writeContractValue" placeholder="0" style="margin-left: 0.5rem; padding: 0.35rem; border-radius: 6px; width: 120px;"></div>';
html += '<button type="button" class="btn btn-primary" style="margin-top: 0.5rem;" id="writeContractBtn">Write</button><pre id="writeContractResult" style="background: var(--light); padding: 1rem; border-radius: 8px; margin-top: 0.5rem; min-height: 2rem; font-size: 0.8rem; display: none;"></pre></div>';
}
html += '<p style="margin-top: 0.5rem;"><a href="' + EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener" style="color: var(--primary);">Read / Write contract on Blockscout</a></p>';
html += '<p style="margin-top: 0.5rem;"><a href="' + EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">Read / Write contract on Blockscout</a></p>';
el.innerHTML = html;
if (viewFns.length > 0) {
(function setupReadContract(contractAddr, abiJson, viewFunctions) {
@@ -5279,7 +5283,7 @@
html += '</div></div></div>';
}
}
html += '<p style="margin-top: 1rem;"><a href="' + EXPLORER_ORIGIN + '/token/' + encodeURIComponent(contractAddress) + '/instance/' + encodeURIComponent(tokenId) + '" target="_blank" rel="noopener" style="color: var(--primary);">View on Blockscout</a></p>';
html += '<p style="margin-top: 1rem;"><a href="' + EXPLORER_ORIGIN + '/token/' + encodeURIComponent(contractAddress) + '/instance/' + encodeURIComponent(tokenId) + '" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Blockscout</a></p>';
container.innerHTML = html;
} catch (err) {
container.innerHTML = '<div class="error">Failed to load NFT: ' + escapeHtml(err.message || 'Unknown') + '</div>';
@@ -5441,7 +5445,7 @@
function getExplorerAIPageContext() {
return {
path: (window.location && window.location.pathname) ? window.location.pathname : '/home',
path: (window.location && window.location.pathname) ? window.location.pathname : '/',
view: currentView || 'home'
};
}

View File

@@ -1007,7 +1007,7 @@
</script>
<nav class="navbar">
<div class="nav-container">
<a class="logo" href="/home" aria-label="Go to explorer home" style="text-decoration:none; color:inherit;">
<a class="logo" href="/" aria-label="Go to explorer home" style="text-decoration:none; color:inherit;">
<i class="fas fa-cube"></i>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>SolaceScanScout</span>
@@ -1026,7 +1026,7 @@
<li class="nav-dropdown" id="navDropdownExplore">
<button type="button" class="nav-dropdown-trigger" aria-expanded="false" aria-haspopup="true" aria-controls="navMenuExplore" id="navTriggerExplore"><i class="fas fa-compass" aria-hidden="true"></i> <span data-i18n="explore">Explore</span> <i class="fas fa-chevron-down" aria-hidden="true"></i></button>
<ul class="nav-dropdown-menu" id="navMenuExplore" role="menu">
<li role="none"><a href="/home" role="menuitem" onclick="event.preventDefault(); showHome(); updatePath('/home'); closeNavMenu();" aria-label="Navigate to home page"><i class="fas fa-home" aria-hidden="true"></i> <span data-i18n="home">Home</span></a></li>
<li role="none"><a href="/" role="menuitem" onclick="event.preventDefault(); showHome(); updatePath('/'); closeNavMenu();" aria-label="Navigate to home page"><i class="fas fa-home" aria-hidden="true"></i> <span data-i18n="home">Home</span></a></li>
<li role="none"><a href="/blocks" role="menuitem" onclick="event.preventDefault(); showBlocks(); updatePath('/blocks'); closeNavMenu();" aria-label="View all blocks"><i class="fas fa-cubes" aria-hidden="true"></i> <span data-i18n="blocks">Blocks</span></a></li>
<li role="none"><a href="/transactions" role="menuitem" onclick="event.preventDefault(); showTransactions(); updatePath('/transactions'); closeNavMenu();" aria-label="View all transactions"><i class="fas fa-exchange-alt" aria-hidden="true"></i> <span data-i18n="transactions">Transactions</span></a></li>
<li role="none"><a href="/addresses" role="menuitem" onclick="event.preventDefault(); showAddresses(); updatePath('/addresses'); closeNavMenu();" aria-label="View all addresses"><i class="fas fa-address-book" aria-hidden="true"></i> <span data-i18n="addresses">Addresses</span></a></li>
@@ -1043,7 +1043,7 @@
<li role="none"><a href="/watchlist" role="menuitem" onclick="event.preventDefault(); showWatchlist(); updatePath('/watchlist'); closeNavMenu();" aria-label="Watchlist"><i class="fas fa-star" aria-hidden="true"></i> <span data-i18n="watchlist">Watchlist</span></a></li>
</ul>
</li>
<li><a href="/snap/" target="_self" rel="noopener" aria-label="Chain 138 MetaMask Snap"><i class="fas fa-wallet" aria-hidden="true"></i> <span>MetaMask Snap</span></a></li>
<li><a href="/snap/" aria-label="Chain 138 MetaMask Snap"><i class="fas fa-wallet" aria-hidden="true"></i> <span>MetaMask Snap</span></a></li>
<li role="none"><a href="/more" role="menuitem" onclick="event.preventDefault(); showMore(); updatePath('/more'); closeNavMenu();" aria-label="View more pages"><i class="fas fa-ellipsis-h" aria-hidden="true"></i> <span data-i18n="more">More</span></a></li>
</ul>
<div class="nav-actions">
@@ -1458,7 +1458,7 @@
</div>
<div id="watchlistView" class="detail-view">
<div class="breadcrumb" id="watchlistBreadcrumb"><a href="/home">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Watchlist</span></div>
<div class="breadcrumb" id="watchlistBreadcrumb"><a href="/">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Watchlist</span></div>
<div class="card">
<div class="card-header">
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back"><i class="fas fa-arrow-left"></i> Back</button>
@@ -1471,7 +1471,7 @@
</div>
<div id="poolsView" class="detail-view">
<div class="breadcrumb" id="poolsBreadcrumb"><a href="/home">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Pools</span></div>
<div class="breadcrumb" id="poolsBreadcrumb"><a href="/">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Pools</span></div>
<div class="card">
<div class="card-header">
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back to home page"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
@@ -1488,7 +1488,7 @@
</div>
<div id="liquidityView" class="detail-view">
<div class="breadcrumb" id="liquidityBreadcrumb"><a href="/home">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Liquidity</span></div>
<div class="breadcrumb" id="liquidityBreadcrumb"><a href="/">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Liquidity</span></div>
<div class="card">
<div class="card-header">
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back to home page"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
@@ -1504,7 +1504,7 @@
</div>
<div id="moreView" class="detail-view">
<div class="breadcrumb" id="moreBreadcrumb"><a href="/home">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">More</span></div>
<div class="breadcrumb" id="moreBreadcrumb"><a href="/">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">More</span></div>
<div class="card">
<div class="card-header">
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back to home page"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>

View File

@@ -1,28 +1,9 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Navbar from '@/components/common/Navbar'
import Footer from '@/components/common/Footer'
import './globals.css'
import type { ReactNode } from 'react'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'SolaceScanScout | The Defi Oracle Meta Explorer',
description: 'The Defi Oracle Meta Explorer - Comprehensive blockchain explorer for ChainID 138',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className={`${inter.className} min-h-screen flex flex-col`}>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</body>
<body>{children}</body>
</html>
)
}

View File

@@ -187,6 +187,8 @@ export default function LiquidityPage() {
<a
key={endpoint.href}
href={endpoint.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex items-center justify-between gap-3">
@@ -238,6 +240,8 @@ export default function LiquidityPage() {
</Link>
<a
href={`${publicApiBase}/routes/tree?chainId=138&amountIn=1000000`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Route tree API

View File

@@ -1,3 +1,5 @@
import Link from 'next/link'
const footerLinkClass =
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
@@ -28,7 +30,9 @@ export default function Footer() {
</div>
<ul className="space-y-2 text-sm">
<li><a className={footerLinkClass} href="/docs.html">Documentation</a></li>
<li><a className={footerLinkClass} href="/liquidity">Liquidity Access</a></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
@@ -48,7 +52,7 @@ export default function Footer() {
</p>
<p>
Snap site:{' '}
<a className={footerLinkClass} href="https://explorer.d-bis.org/snap/">
<a className={footerLinkClass} href="https://explorer.d-bis.org/snap/" target="_blank" rel="noopener noreferrer">
explorer.d-bis.org/snap/
</a>
</p>

View File

@@ -63,7 +63,7 @@ function DropdownItem({
if (external) {
return (
<li role="none">
<a href={href} target="_self" rel="noopener" className={className} role="menuitem">
<a href={href} className={className} role="menuitem">
{icon}
<span>{children}</span>
</a>
@@ -115,12 +115,16 @@ export default function Navbar() {
<DropdownItem href="/" icon={<span className="text-gray-400"></span>}>Home</DropdownItem>
<DropdownItem href="/blocks" icon={<span className="text-gray-400"></span>}>Blocks</DropdownItem>
<DropdownItem href="/transactions" icon={<span className="text-gray-400"></span>}>Transactions</DropdownItem>
<DropdownItem href="/addresses" icon={<span className="text-gray-400"></span>}>Addresses</DropdownItem>
</NavDropdown>
<NavDropdown
label="Tools"
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
>
<DropdownItem href="/search">Search</DropdownItem>
<DropdownItem href="/tokens">Tokens</DropdownItem>
<DropdownItem href="/pools">Pools</DropdownItem>
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
<DropdownItem href="/wallet">Wallet</DropdownItem>
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
</NavDropdown>
@@ -155,6 +159,7 @@ export default function Navbar() {
<ul className="pl-4 mt-1 space-y-0.5">
<li><Link href="/blocks" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
<li><Link href="/transactions" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
<li><Link href="/addresses" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
</ul>
)}
</div>
@@ -166,6 +171,9 @@ export default function Navbar() {
{toolsOpen && (
<ul className="pl-4 mt-1 space-y-0.5">
<li><Link href="/search" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Search</Link></li>
<li><Link href="/tokens" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
<li><Link href="/pools" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
<li><Link href="/watchlist" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
<li><Link href="/wallet" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Wallet</Link></li>
<li><Link href="/liquidity" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
</ul>

View File

@@ -1,13 +1,14 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useRouter } from 'next/router'
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
export default function AddressDetailPage() {
const params = useParams()
const address = (params?.address as string) ?? ''
const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : ''
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
@@ -41,9 +42,21 @@ export default function AddressDetailPage() {
}, [chainId, address])
useEffect(() => {
if (!router.isReady || !address) {
setLoading(router.isReady ? false : true)
if (router.isReady && !address) {
setAddressInfo(null)
setTransactions([])
}
return
}
loadAddressInfo()
loadTransactions()
}, [loadAddressInfo, loadTransactions])
}, [address, loadAddressInfo, loadTransactions, router.isReady])
if (!router.isReady) {
return <div className="p-8">Loading address...</div>
}
if (loading) {
return <div className="p-8">Loading address...</div>
@@ -57,18 +70,26 @@ export default function AddressDetailPage() {
{
header: 'Hash',
accessor: (tx: TransactionSummary) => (
<a href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
<Address address={tx.hash} truncate />
</a>
</Link>
),
},
{
header: 'Block',
accessor: (tx: TransactionSummary) => tx.block_number,
accessor: (tx: TransactionSummary) => (
<Link href={`/blocks/${tx.block_number}`} className="text-primary-600 hover:underline">
{tx.block_number}
</Link>
),
},
{
header: 'To',
accessor: (tx: TransactionSummary) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
accessor: (tx: TransactionSummary) => tx.to_address ? (
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
<Address address={tx.to_address} truncate />
</Link>
) : 'Contract Creation',
},
{
header: 'Value',
@@ -133,4 +154,3 @@ export default function AddressDetailPage() {
</div>
)
}

View File

@@ -0,0 +1,138 @@
'use client'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import { transactionsApi, Transaction } from '@/services/api/transactions'
function normalizeAddress(value: string) {
const trimmed = value.trim()
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
}
export default function AddressesPage() {
const router = useRouter()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [query, setQuery] = useState('')
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([])
const [watchlist, setWatchlist] = useState<string[]>([])
useEffect(() => {
let active = true
transactionsApi.listSafe(chainId, 1, 20).then(({ ok, data }) => {
if (active && ok) {
setRecentTransactions(data)
}
}).catch(() => {
if (active) {
setRecentTransactions([])
}
})
return () => {
active = false
}
}, [chainId])
useEffect(() => {
try {
const raw = window.localStorage.getItem('explorerWatchlist')
const entries = raw ? JSON.parse(raw) : []
setWatchlist(Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === 'string') : [])
} catch {
setWatchlist([])
}
}, [])
const activeAddresses = useMemo(() => {
const seen = new Set<string>()
const addresses: string[] = []
for (const tx of recentTransactions) {
for (const candidate of [tx.from_address, tx.to_address]) {
if (!candidate) continue
const normalized = candidate.toLowerCase()
if (seen.has(normalized)) continue
seen.add(normalized)
addresses.push(candidate)
if (addresses.length >= 12) return addresses
}
}
return addresses
}, [recentTransactions])
const handleOpenAddress = (event: React.FormEvent) => {
event.preventDefault()
const normalized = normalizeAddress(query)
if (!normalized) return
router.push(`/addresses/${normalized}`)
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Addresses</h1>
<Card className="mb-6" title="Open An Address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="0x..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="submit"
disabled={!normalizeAddress(query)}
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
>
Open address
</button>
</form>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Open any Chain 138 address directly, or jump into your saved watchlist below.
</p>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card title="Saved Watchlist">
{watchlist.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
No saved addresses yet. Address detail pages let you add entries to the shared explorer watchlist.
</p>
) : (
<div className="space-y-3">
{watchlist.map((entry) => (
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
<Address address={entry} />
</Link>
))}
<div className="pt-2">
<Link href="/watchlist" className="text-sm text-primary-600 hover:underline">
Open the full watchlist
</Link>
</div>
</div>
)}
</Card>
<Card title="Recently Active Addresses">
{activeAddresses.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent address activity is unavailable right now. You can still open an address directly above.
</p>
) : (
<div className="space-y-3">
{activeAddresses.map((entry) => (
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
<Address address={entry} />
</Link>
))}
</div>
)}
</Card>
</div>
</div>
)
}

View File

@@ -1,14 +1,14 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useRouter } from 'next/router'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
export default function BlockDetailPage() {
const params = useParams()
const rawNumber = (params?.number as string) ?? ''
const router = useRouter()
const rawNumber = typeof router.query.number === 'string' ? router.query.number : ''
const blockNumber = parseInt(rawNumber, 10)
const isValidBlock = rawNumber !== '' && !Number.isNaN(blockNumber) && blockNumber >= 0
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
@@ -29,13 +29,20 @@ export default function BlockDetailPage() {
}, [chainId, blockNumber])
useEffect(() => {
if (!router.isReady) {
return
}
if (!isValidBlock) {
setLoading(false)
setBlock(null)
return
}
loadBlock()
}, [isValidBlock, loadBlock])
}, [isValidBlock, loadBlock, router.isReady])
if (!router.isReady) {
return <div className="p-8">Loading block...</div>
}
if (!isValidBlock) {
return <div className="p-8">Invalid block number. Please use a valid block number from the URL.</div>
@@ -65,11 +72,15 @@ export default function BlockDetailPage() {
</div>
<div>
<span className="font-semibold">Miner:</span>
<Address address={block.miner} className="ml-2" truncate />
<Link href={`/addresses/${block.miner}`} className="ml-2 text-primary-600 hover:underline">
<Address address={block.miner} truncate />
</Link>
</div>
<div>
<span className="font-semibold">Transactions:</span>
<span className="ml-2">{block.transaction_count}</span>
<Link href="/transactions" className="ml-2 text-primary-600 hover:underline">
{block.transaction_count}
</Link>
</div>
<div>
<span className="font-semibold">Gas Used:</span>
@@ -80,4 +91,3 @@ export default function BlockDetailPage() {
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
const poolCards = [
{
title: 'Canonical PMM Matrix',
description: 'Review the public Chain 138 DODO PMM route matrix, live pool freshness, and payload examples.',
href: '/liquidity',
label: 'Open liquidity access',
},
{
title: 'Wallet Funding Path',
description: 'Open wallet tools first if you need Chain 138 setup, token import links, or a quick route into supported assets.',
href: '/wallet',
label: 'Open wallet tools',
},
{
title: 'Explorer Docs',
description: 'Static documentation covers the live pool map, expected web content, and route access details.',
href: '/docs.html',
label: 'Open docs landing page',
external: true,
},
]
export default function PoolsPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Pools</h1>
<div className="grid gap-6 lg:grid-cols-3">
{poolCards.map((card) => (
<Card key={card.title} title={card.title}>
<p className="text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
<div className="mt-4">
{card.external ? (
<a href={card.href} className="text-primary-600 hover:underline">
{card.label}
</a>
) : (
<Link href={card.href} className="text-primary-600 hover:underline">
{card.label}
</Link>
)}
</div>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,41 +1,61 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { getExplorerApiBase } from '@/services/api/blockscout'
interface SearchResult {
type: string
chain_id: number
chain_id?: number
data: {
hash?: string
address?: string
number?: number
block_number?: number
}
score: number
score?: number
}
export default function SearchPage() {
const router = useRouter()
const initialQuery = typeof router.query.q === 'string' ? router.query.q : ''
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!query.trim()) return
const runSearch = async (rawQuery: string) => {
if (!rawQuery.trim()) return
setLoading(true)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(query)}`
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(rawQuery)}`
)
const data = await response.json()
if (!response.ok) {
setResults([])
return
}
setResults(data.results || [])
const normalizedResults = Array.isArray(data?.items)
? data.items.map((item: {
type?: string
address?: string
transaction_hash?: string
block_number?: number
priority?: number
}) => ({
type: item.type || 'unknown',
chain_id: 138,
data: {
hash: item.transaction_hash,
address: item.address,
number: item.block_number,
},
score: item.priority ?? 0,
}))
: []
setResults(normalizedResults)
} catch (error) {
console.error('Search failed:', error)
setResults([])
@@ -44,6 +64,18 @@ export default function SearchPage() {
}
}
useEffect(() => {
if (!router.isReady) return
if (!initialQuery.trim()) return
setQuery(initialQuery)
runSearch(initialQuery)
}, [initialQuery, router.isReady])
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
await runSearch(query)
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Search</h1>
@@ -88,7 +120,7 @@ export default function SearchPage() {
</Link>
)}
<div className="text-sm text-gray-500 mt-1">
Type: {result.type} | Chain: {result.chain_id} | Score: {result.score.toFixed(2)}
Type: {result.type} | Chain: {result.chain_id ?? 138} | Score: {(result.score ?? 0).toFixed(2)}
</div>
</div>
))}
@@ -98,4 +130,3 @@ export default function SearchPage() {
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
function normalizeAddress(value: string) {
const trimmed = value.trim()
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
}
export default function TokensPage() {
const router = useRouter()
const [query, setQuery] = useState('')
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
const normalized = normalizeAddress(query)
router.push(normalized ? `/addresses/${normalized}` : `/search?q=${encodeURIComponent(query.trim())}`)
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Tokens</h1>
<Card className="mb-6" title="Find A Token">
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Token symbol, name, or contract address"
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="submit"
disabled={!query.trim()}
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
>
Search
</button>
</form>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card title="Search Index">
<p className="text-sm text-gray-600 dark:text-gray-400">
Search token symbols, contract addresses, transaction hashes, and block numbers from the explorer index.
</p>
<div className="mt-4">
<Link href="/search" className="text-primary-600 hover:underline">
Open search
</Link>
</div>
</Card>
<Card title="Wallet Discovery">
<p className="text-sm text-gray-600 dark:text-gray-400">
Add Chain 138 and supported token metadata to MetaMask directly from the explorer wallet tools.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Liquidity Routes">
<p className="text-sm text-gray-600 dark:text-gray-400">
Review canonical PMM routes, partner payload templates, and token-routing examples for supported pools.
</p>
<div className="mt-4">
<Link href="/liquidity" className="text-primary-600 hover:underline">
Open liquidity access
</Link>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -1,14 +1,14 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { transactionsApi, Transaction } from '@/services/api/transactions'
export default function TransactionDetailPage() {
const params = useParams()
const hash = (params?.hash as string) ?? ''
const router = useRouter()
const hash = typeof router.query.hash === 'string' ? router.query.hash : ''
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [transaction, setTransaction] = useState<Transaction | null>(null)
@@ -32,8 +32,19 @@ export default function TransactionDetailPage() {
}, [chainId, hash])
useEffect(() => {
if (!router.isReady || !hash) {
setLoading(router.isReady ? false : true)
if (router.isReady && !hash) {
setTransaction(null)
}
return
}
loadTransaction()
}, [loadTransaction])
}, [hash, loadTransaction, router.isReady])
if (!router.isReady) {
return <div className="p-8">Loading transaction...</div>
}
if (loading) {
return <div className="p-8">Loading transaction...</div>
@@ -111,4 +122,3 @@ export default function TransactionDetailPage() {
</div>
)
}

View File

@@ -47,11 +47,19 @@ export default function TransactionsPage() {
},
{
header: 'From',
accessor: (tx: Transaction) => <Address address={tx.from_address} truncate />,
accessor: (tx: Transaction) => (
<Link href={`/addresses/${tx.from_address}`} className="text-primary-600 hover:underline">
<Address address={tx.from_address} truncate />
</Link>
),
},
{
header: 'To',
accessor: (tx: Transaction) => tx.to_address ? <Address address={tx.to_address} truncate /> : <span className="text-gray-400">Contract Creation</span>,
accessor: (tx: Transaction) => tx.to_address ? (
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
<Address address={tx.to_address} truncate />
</Link>
) : <span className="text-gray-400">Contract Creation</span>,
},
{
header: 'Value',
@@ -100,4 +108,3 @@ export default function TransactionsPage() {
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Card, Address } from '@/libs/frontend-ui-primitives'
export default function WatchlistPage() {
const [entries, setEntries] = useState<string[]>([])
useEffect(() => {
try {
const raw = window.localStorage.getItem('explorerWatchlist')
const parsed = raw ? JSON.parse(raw) : []
setEntries(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [])
} catch {
setEntries([])
}
}, [])
const removeEntry = (address: string) => {
setEntries((current) => {
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
try {
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
} catch {}
return next
})
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Watchlist</h1>
<Card title="Saved Addresses">
{entries.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Your watchlist is empty. Add an address from its detail page to keep it here.
</p>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<div key={entry} className="flex flex-col gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-700 md:flex-row md:items-center md:justify-between">
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
<Address address={entry} />
</Link>
<button
type="button"
onClick={() => removeEntry(entry)}
className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Remove
</button>
</div>
))}
</div>
)}
</Card>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { apiClient, ApiResponse } from './client'
import { ApiResponse } from './client'
import { fetchBlockscoutJson, normalizeTransactionSummary } from './blockscout'
export interface AddressInfo {
address: string
@@ -28,11 +29,53 @@ export interface TransactionSummary {
export const addressesApi = {
get: async (chainId: number, address: string): Promise<ApiResponse<AddressInfo>> => {
return apiClient.get<AddressInfo>(`/api/v1/addresses/${chainId}/${address}`)
const [raw, counters] = await Promise.all([
fetchBlockscoutJson<{
hash: string
is_contract: boolean
name?: string | null
token?: { symbol?: string | null } | null
public_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
private_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
watchlist_names?: string[]
}>(`/api/v2/addresses/${address}`),
fetchBlockscoutJson<{
transactions_count?: number
token_balances_count?: number
}>(`/api/v2/addresses/${address}/tabs-counters`),
])
const tags = [
...(raw.public_tags || []),
...(raw.private_tags || []),
...(raw.watchlist_names || []),
]
.map((tag) => {
if (typeof tag === 'string') return tag
return tag.display_name || tag.label || tag.name || ''
})
.filter(Boolean)
return {
data: {
address: raw.hash,
chain_id: chainId,
transaction_count: Number(counters.transactions_count || 0),
token_count: Number(counters.token_balances_count || 0),
is_contract: !!raw.is_contract,
label: raw.name || raw.token?.symbol || undefined,
tags,
},
}
},
/** Use when you need to check response.ok before setting state (avoids treating 4xx/5xx body as data). */
getSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: AddressInfo | null }> => {
return apiClient.getSafe<AddressInfo>(`/api/v1/addresses/${chainId}/${address}`)
try {
const { data } = await addressesApi.get(chainId, address)
return { ok: true, data }
} catch {
return { ok: false, data: null }
}
},
getTransactionsSafe: async (
chainId: number,
@@ -42,16 +85,11 @@ export const addressesApi = {
): Promise<{ ok: boolean; data: TransactionSummary[] }> => {
try {
const params = new URLSearchParams({
chain_id: chainId.toString(),
from_address: address,
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = (await apiClient.get(`/api/v1/transactions?${params.toString()}`)) as unknown as {
data?: TransactionSummary[]
items?: TransactionSummary[]
}
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(`/api/v2/addresses/${address}/transactions?${params.toString()}`)
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransactionSummary(item as never)) : []
return { ok: true, data }
} catch {
return { ok: false, data: [] }
@@ -65,16 +103,11 @@ export const addressesApi = {
pageSize = 20
): Promise<ApiResponse<TransactionSummary[]>> => {
const params = new URLSearchParams({
chain_id: chainId.toString(),
from_address: address,
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = (await apiClient.get(`/api/v1/transactions?${params.toString()}`)) as unknown as {
data?: TransactionSummary[]
items?: TransactionSummary[]
}
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(`/api/v2/addresses/${address}/transactions?${params.toString()}`)
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransactionSummary(item as never)) : []
return { data }
},
}

View File

@@ -1,4 +1,5 @@
import { apiClient, ApiResponse } from './client'
import { ApiResponse } from './client'
import { fetchBlockscoutJson, normalizeBlock } from './blockscout'
export interface Block {
chain_id: number
@@ -22,12 +23,6 @@ export interface BlockListParams {
order?: 'asc' | 'desc'
}
/** Normalize list response: backend may return { data: T[] } or { items: T[] }. */
function normalizeListResponse<T>(raw: { data?: T[]; items?: T[] }): ApiResponse<T[]> {
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
return { data }
}
export const blocksApi = {
list: async (params: BlockListParams): Promise<ApiResponse<Block[]>> => {
const queryParams = new URLSearchParams()
@@ -40,16 +35,18 @@ export const blocksApi = {
if (params.sort) queryParams.append('sort', params.sort)
if (params.order) queryParams.append('order', params.order)
const raw = (await apiClient.get(`/api/v1/blocks?${queryParams.toString()}`)) as unknown as { data?: Block[]; items?: Block[] }
return normalizeListResponse(raw)
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(`/api/v2/blocks?${queryParams.toString()}`)
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeBlock(item as never, params.chain_id)) : []
return { data }
},
getByNumber: async (chainId: number, number: number): Promise<ApiResponse<Block>> => {
return apiClient.get<Block>(`/api/v1/blocks/${chainId}/${number}`)
const raw = await fetchBlockscoutJson<unknown>(`/api/v2/blocks/${number}`)
return { data: normalizeBlock(raw as never, chainId) }
},
getByHash: async (chainId: number, hash: string): Promise<ApiResponse<Block>> => {
return apiClient.get<Block>(`/api/v1/blocks/${chainId}/hash/${hash}`)
const raw = await fetchBlockscoutJson<unknown>(`/api/v2/blocks/${hash}`)
return { data: normalizeBlock(raw as never, chainId) }
},
}

View File

@@ -0,0 +1,109 @@
import type { Block } from './blocks'
import type { Transaction } from './transactions'
import type { TransactionSummary } from './addresses'
export function getExplorerApiBase() {
return (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080').replace(/\/$/, '')
}
export async function fetchBlockscoutJson<T>(path: string): Promise<T> {
const response = await fetch(`${getExplorerApiBase()}${path}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json() as Promise<T>
}
type HashLike = string | { hash?: string | null } | null | undefined
export function extractHash(value: HashLike): string {
if (!value) return ''
return typeof value === 'string' ? value : value.hash || ''
}
function toNumber(value: unknown): number {
if (typeof value === 'number') return value
if (typeof value === 'string' && value.trim() !== '') return Number(value)
return 0
}
interface BlockscoutBlock {
hash: string
height: number | string
timestamp: string
miner: HashLike
transaction_count: number | string
gas_used: number | string
gas_limit: number | string
}
interface BlockscoutTransaction {
hash: string
block_number: number | string
from: HashLike
to: HashLike
value: string
status?: string | null
result?: string | null
gas_price?: number | string | null
gas_limit: number | string
gas_used?: number | string | null
max_fee_per_gas?: number | string | null
max_priority_fee_per_gas?: number | string | null
raw_input?: string | null
timestamp: string
created_contract?: HashLike
}
function normalizeStatus(raw: BlockscoutTransaction): number {
const value = (raw.status || raw.result || '').toString().toLowerCase()
if (value === 'success' || value === 'ok' || value === '1') return 1
if (value === 'error' || value === 'failed' || value === '0') return 0
return 0
}
export function normalizeBlock(raw: BlockscoutBlock, chainId: number): Block {
return {
chain_id: chainId,
number: toNumber(raw.height),
hash: raw.hash,
timestamp: raw.timestamp,
miner: extractHash(raw.miner),
transaction_count: toNumber(raw.transaction_count),
gas_used: toNumber(raw.gas_used),
gas_limit: toNumber(raw.gas_limit),
}
}
export function normalizeTransaction(raw: BlockscoutTransaction, chainId: number): Transaction {
return {
chain_id: chainId,
hash: raw.hash,
block_number: toNumber(raw.block_number),
block_hash: '',
transaction_index: 0,
from_address: extractHash(raw.from),
to_address: extractHash(raw.to) || undefined,
value: raw.value || '0',
gas_price: raw.gas_price != null ? toNumber(raw.gas_price) : undefined,
max_fee_per_gas: raw.max_fee_per_gas != null ? toNumber(raw.max_fee_per_gas) : undefined,
max_priority_fee_per_gas: raw.max_priority_fee_per_gas != null ? toNumber(raw.max_priority_fee_per_gas) : undefined,
gas_limit: toNumber(raw.gas_limit),
gas_used: raw.gas_used != null ? toNumber(raw.gas_used) : undefined,
status: normalizeStatus(raw),
input_data: raw.raw_input || undefined,
contract_address: extractHash(raw.created_contract) || undefined,
created_at: raw.timestamp,
}
}
export function normalizeTransactionSummary(raw: BlockscoutTransaction): TransactionSummary {
return {
hash: raw.hash,
block_number: toNumber(raw.block_number),
from_address: extractHash(raw.from),
to_address: extractHash(raw.to) || undefined,
value: raw.value || '0',
status: normalizeStatus(raw),
}
}

View File

@@ -1,4 +1,5 @@
import { apiClient, ApiResponse } from './client'
import { fetchBlockscoutJson, normalizeTransaction } from './blockscout'
export interface Transaction {
chain_id: number
@@ -22,33 +23,31 @@ export interface Transaction {
export const transactionsApi = {
get: async (chainId: number, hash: string): Promise<ApiResponse<Transaction>> => {
return apiClient.get<Transaction>(`/api/v1/transactions/${chainId}/${hash}`)
const raw = await fetchBlockscoutJson<unknown>(`/api/v2/transactions/${hash}`)
return { data: normalizeTransaction(raw as never, chainId) }
},
/** Use when you need to check response.ok before setting state (avoids treating 4xx/5xx body as data). */
getSafe: async (chainId: number, hash: string): Promise<{ ok: boolean; data: Transaction | null }> => {
return apiClient.getSafe<Transaction>(`/api/v1/transactions/${chainId}/${hash}`)
try {
const { data } = await transactionsApi.get(chainId, hash)
return { ok: true, data }
} catch {
return { ok: false, data: null }
}
},
list: async (chainId: number, page: number, pageSize: number): Promise<ApiResponse<Transaction[]>> => {
const params = new URLSearchParams({
chain_id: chainId.toString(),
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = (await apiClient.get(`/api/v1/transactions?${params.toString()}`)) as unknown as { data?: Transaction[]; items?: Transaction[] }
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(`/api/v2/transactions?${params.toString()}`)
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransaction(item as never, chainId)) : []
return { data }
},
/** Use when you need to check ok before setting state (avoids treating error body as list). */
listSafe: async (chainId: number, page: number, pageSize: number): Promise<{ ok: boolean; data: Transaction[] }> => {
try {
const params = new URLSearchParams({
chain_id: chainId.toString(),
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = await apiClient.getSafe<Transaction[]>(`/api/v1/transactions?${params.toString()}`)
if (!raw.ok) return { ok: false, data: [] }
const data = Array.isArray(raw.data) ? raw.data : []
const { data } = await transactionsApi.list(chainId, page, pageSize)
return { ok: true, data }
} catch {
return { ok: false, data: [] }

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", ".next", "**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -14,12 +18,29 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/libs/*": ["./libs/*"]
"@/*": [
"./src/*"
],
"@/libs/*": [
"./libs/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.spec.ts"
]
}