Fix explorer routing, links, and frontend API loading
This commit is contained in:
@@ -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”
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
138
frontend/src/pages/addresses/index.tsx
Normal file
138
frontend/src/pages/addresses/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
53
frontend/src/pages/pools/index.tsx
Normal file
53
frontend/src/pages/pools/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
80
frontend/src/pages/tokens/index.tsx
Normal file
80
frontend/src/pages/tokens/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
60
frontend/src/pages/watchlist/index.tsx
Normal file
60
frontend/src/pages/watchlist/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
109
frontend/src/services/api/blockscout.ts
Normal file
109
frontend/src/services/api/blockscout.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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: [] }
|
||||
|
||||
5
frontend/tsconfig.check.json
Normal file
5
frontend/tsconfig.check.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", ".next", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user