Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 59 additions & 53 deletions frontend/bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"preact": "^10.29.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@preact/preset-vite": "^2.10.4",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
Expand Down
80 changes: 44 additions & 36 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,57 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './components';
import {
BlocksPage,
BlockDetailPage,
BlockTransactionsPage,
TransactionsPage,
TransactionDetailPage,
AddressPage,
NFTsPage,
NFTContractPage,
NFTTokenPage,
TokensPage,
TokenDetailPage,
NotFoundPage,
WelcomePage,
SearchResultsPage,
AddressesPage,
FaucetPage,
StatusPage,
} from './pages';
import { ThemeProvider } from './context/ThemeContext';

const BlocksPage = lazy(() => import('./pages/BlocksPage'));
const BlockDetailPage = lazy(() => import('./pages/BlockDetailPage'));
const BlockTransactionsPage = lazy(() => import('./pages/BlockTransactionsPage'));
const TransactionsPage = lazy(() => import('./pages/TransactionsPage'));
const TransactionDetailPage = lazy(() => import('./pages/TransactionDetailPage'));
const AddressPage = lazy(() => import('./pages/AddressPage'));
const AddressesPage = lazy(() => import('./pages/AddressesPage'));
const NFTsPage = lazy(() => import('./pages/NFTsPage'));
const NFTContractPage = lazy(() => import('./pages/NFTContractPage'));
const NFTTokenPage = lazy(() => import('./pages/NFTTokenPage'));
const TokensPage = lazy(() => import('./pages/TokensPage'));
const TokenDetailPage = lazy(() => import('./pages/TokenDetailPage'));
const SearchResultsPage = lazy(() => import('./pages/SearchResultsPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const WelcomePage = lazy(() => import('./pages/WelcomePage'));
const FaucetPage = lazy(() => import('./pages/FaucetPage'));
const StatusPage = lazy(() => import('./pages/StatusPage'));

function PageLoader() {
return (
<div className="flex items-center justify-center h-64">
<span className="text-gray-500 text-sm">Loading...</span>
</div>
);
}
Comment on lines +24 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider dark mode support for the loader text.

The text-gray-500 class won't adapt when dark mode is active. Since the app uses ThemeProvider, consider adding a dark mode variant.

🎨 Proposed fix for dark mode support
 function PageLoader() {
   return (
     <div className="flex items-center justify-center h-64">
-      <span className="text-gray-500 text-sm">Loading...</span>
+      <span className="text-gray-500 dark:text-gray-400 text-sm">Loading...</span>
     </div>
   );
 }

Based on learnings: "Frontend uses Tailwind CSS for styling across all components and pages."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function PageLoader() {
return (
<div className="flex items-center justify-center h-64">
<span className="text-gray-500 text-sm">Loading...</span>
</div>
);
}
function PageLoader() {
return (
<div className="flex items-center justify-center h-64">
<span className="text-gray-500 dark:text-gray-400 text-sm">Loading...</span>
</div>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/App.tsx` around lines 24 - 30, The loader's text color in
PageLoader doesn't adapt to dark mode; update the JSX rendering inside the
PageLoader component to use a Tailwind dark-mode variant for the span's text
class (e.g., add a dark:... class alongside text-gray-500) so the loader text is
readable in both light and dark themes.


export default function App() {
return (
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<WelcomePage />} />
<Route path="blocks" element={<BlocksPage />} />
<Route path="blocks/:number" element={<BlockDetailPage />} />
<Route path="blocks/:number/transactions" element={<BlockTransactionsPage />} />
<Route path="transactions" element={<TransactionsPage />} />
<Route path="search" element={<SearchResultsPage />} />
<Route path="addresses" element={<AddressesPage />} />
<Route path="tx/:hash" element={<TransactionDetailPage />} />
<Route path="address/:address" element={<AddressPage />} />
<Route path="nfts" element={<NFTsPage />} />
<Route path="nfts/:contract" element={<NFTContractPage />} />
<Route path="nfts/:contract/:tokenId" element={<NFTTokenPage />} />
<Route path="status" element={<StatusPage />} />
<Route path="tokens" element={<TokensPage />} />
<Route path="tokens/:address" element={<TokenDetailPage />} />
<Route path="faucet" element={<FaucetPage />} />
<Route path="*" element={<NotFoundPage />} />
<Route index element={<Suspense fallback={<PageLoader />}><WelcomePage /></Suspense>} />
<Route path="blocks" element={<Suspense fallback={<PageLoader />}><BlocksPage /></Suspense>} />
<Route path="blocks/:number" element={<Suspense fallback={<PageLoader />}><BlockDetailPage /></Suspense>} />
<Route path="blocks/:number/transactions" element={<Suspense fallback={<PageLoader />}><BlockTransactionsPage /></Suspense>} />
<Route path="transactions" element={<Suspense fallback={<PageLoader />}><TransactionsPage /></Suspense>} />
<Route path="search" element={<Suspense fallback={<PageLoader />}><SearchResultsPage /></Suspense>} />
<Route path="addresses" element={<Suspense fallback={<PageLoader />}><AddressesPage /></Suspense>} />
<Route path="tx/:hash" element={<Suspense fallback={<PageLoader />}><TransactionDetailPage /></Suspense>} />
<Route path="address/:address" element={<Suspense fallback={<PageLoader />}><AddressPage /></Suspense>} />
<Route path="nfts" element={<Suspense fallback={<PageLoader />}><NFTsPage /></Suspense>} />
<Route path="nfts/:contract" element={<Suspense fallback={<PageLoader />}><NFTContractPage /></Suspense>} />
<Route path="nfts/:contract/:tokenId" element={<Suspense fallback={<PageLoader />}><NFTTokenPage /></Suspense>} />
<Route path="status" element={<Suspense fallback={<PageLoader />}><StatusPage /></Suspense>} />
<Route path="tokens" element={<Suspense fallback={<PageLoader />}><TokensPage /></Suspense>} />
<Route path="tokens/:address" element={<Suspense fallback={<PageLoader />}><TokenDetailPage /></Suspense>} />
<Route path="faucet" element={<Suspense fallback={<PageLoader />}><FaucetPage /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
</Route>
</Routes>
</BrowserRouter>
Expand Down
19 changes: 6 additions & 13 deletions frontend/src/api/addresses.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,38 @@
import client from './client';
import type { Address, PaginatedResponse } from '../types';
import type { AddressTransfer } from '../types';
import type { Address, PaginatedResponse, AddressTransfer } from '../types';

export async function getAddress(address: string): Promise<Address> {
const response = await client.get<Address>(`/addresses/${address}`);
return response.data;
return client.get<Address>(`/addresses/${address}`);
}

export interface GetAddressesParams {
page?: number;
limit?: number;
// Updated to align with backend API
address_type?: 'eoa' | 'contract' | 'erc20' | 'nft';
from_block?: number;
to_block?: number;
}

export async function getAddresses(params: GetAddressesParams = {}): Promise<PaginatedResponse<Address>> {
const response = await client.get<PaginatedResponse<Address>>('/addresses', { params });
return response.data;
return client.get<PaginatedResponse<Address>>('/addresses', { params: params as Record<string, unknown> });
}

// Etherscan-compatible endpoint for native balance: GET /api?module=account&action=balance&address=...
interface EtherscanLikeResponse<T> { result: T }

export async function getEthBalance(address: string): Promise<string> {
const response = await client.get<EtherscanLikeResponse<string>>('', {
const data = await client.get<EtherscanLikeResponse<string>>('', {
params: { module: 'account', action: 'balance', address },
});
// Returns Wei as string
return response.data.result ?? '0';
return data.result ?? '0';
}

// New: Address transfers (ERC-20 + NFT)
export interface GetAddressTransfersParams {
page?: number;
limit?: number;
transfer_type?: 'erc20' | 'nft';
}

export async function getAddressTransfers(address: string, params: GetAddressTransfersParams = {}): Promise<PaginatedResponse<AddressTransfer>> {
const response = await client.get<PaginatedResponse<AddressTransfer>>(`/addresses/${address}/transfers`, { params });
return response.data;
return client.get<PaginatedResponse<AddressTransfer>>(`/addresses/${address}/transfers`, { params: params as Record<string, unknown> });
}
14 changes: 4 additions & 10 deletions frontend/src/api/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,24 @@ export interface GetBlocksParams {

export async function getBlocks(params: GetBlocksParams = {}): Promise<PaginatedResponse<Block>> {
const { page = 1, limit = 20 } = params;
const response = await client.get<PaginatedResponse<Block>>('/blocks', {
params: { page, limit },
});
return response.data;
return client.get<PaginatedResponse<Block>>('/blocks', { params: { page, limit } });
}

export async function getBlockByNumber(blockNumber: number): Promise<Block> {
const response = await client.get<Block>(`/blocks/${blockNumber}`);
return response.data;
return client.get<Block>(`/blocks/${blockNumber}`);
}

export async function getBlockByHash(blockHash: string): Promise<Block> {
const response = await client.get<Block>(`/blocks/hash/${blockHash}`);
return response.data;
return client.get<Block>(`/blocks/hash/${blockHash}`);
}

export async function getBlockTransactions(
blockNumber: number,
params: GetBlocksParams = {}
): Promise<PaginatedResponse<Transaction>> {
const { page = 1, limit = 20 } = params;
const response = await client.get<PaginatedResponse<Transaction>>(
return client.get<PaginatedResponse<Transaction>>(
`/blocks/${blockNumber}/transactions`,
{ params: { page, limit } }
);
return response.data;
}
136 changes: 75 additions & 61 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,95 @@
import axios from 'axios';
import type { AxiosInstance, AxiosError } from 'axios';
import type { ApiError } from '../types';

export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';

const client: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});

// Response interceptor for error handling
client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response) {
const data = error.response.data as Partial<ApiError> | undefined;
const retryAfterSeconds = parseRetryAfterSeconds(error.response.headers, data);
const apiError: ApiError = {
error: data?.error || error.message,
status: error.response.status,
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
};
return Promise.reject(apiError);
}
const TIMEOUT_MS = 10_000;

if (error.request) {
const networkError: ApiError = {
error: 'Unable to connect to the API server',
};
return Promise.reject(networkError);
}
function buildUrl(path: string, params?: Record<string, unknown>): string {
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
let url = base + path;

return Promise.reject({
error: error.message,
} as ApiError);
}
);

function parseRetryAfterSeconds(
headers: unknown,
data: Partial<ApiError> | undefined
): number | undefined {
const bodyRetryAfter = (data as { retry_after_seconds?: unknown } | undefined)?.retry_after_seconds;
const headerRetryAfter = getHeaderValue(headers, 'retry-after');
const rawRetryAfter = bodyRetryAfter ?? headerRetryAfter;

if (typeof rawRetryAfter === 'number' && Number.isFinite(rawRetryAfter) && rawRetryAfter >= 0) {
return rawRetryAfter;
if (params) {
const qs = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
if (qs) url += '?' + qs;
}

if (typeof rawRetryAfter === 'string') {
const parsed = Number(rawRetryAfter);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
return url;
}

async function request<T>(
method: 'GET' | 'POST',
path: string,
options: { params?: Record<string, unknown>; body?: unknown } = {}
): Promise<T> {
const url = buildUrl(path, options.params);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);

const retryDate = Date.parse(rawRetryAfter);
if (!Number.isNaN(retryDate)) {
return Math.max(0, Math.ceil((retryDate - Date.now()) / 1000));
let response: Response;
try {
response = await fetch(url, {
method,
headers: options.body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err instanceof DOMException && err.name === 'AbortError') {
throw { error: 'Request timed out' } as ApiError;
}
throw { error: 'Unable to connect to the API server' } as ApiError;
}

return undefined;
clearTimeout(timer);

if (!response.ok) {
let data: { error?: string; retry_after_seconds?: unknown } = {};
try {
data = await response.json();
} catch { /* ignore */ }

const retryAfterSeconds = parseRetryAfterSeconds(
response.headers.get('retry-after'),
data.retry_after_seconds
);

throw {
error: data.error ?? response.statusText,
status: response.status,
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
} as ApiError;
}

return response.json() as Promise<T>;
}
Comment on lines +66 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle JSON parse failure on successful responses.

If the server returns a 2xx response with invalid JSON (or empty body), response.json() will reject with a SyntaxError. This would propagate as an unhandled error rather than a structured ApiError.

🛡️ Proposed defensive fix
-  return response.json() as Promise<T>;
+  try {
+    return await response.json() as T;
+  } catch {
+    throw { error: 'Invalid response from server' } as ApiError;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return response.json() as Promise<T>;
}
try {
return await response.json() as T;
} catch {
throw { error: 'Invalid response from server' } as ApiError;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/api/client.ts` around lines 66 - 68, The code currently returns
response.json() directly in the client function, which will throw a raw
SyntaxError for 2xx responses with invalid or empty JSON; wrap the JSON parsing
in a try/catch around the response.json() call (in the same function that
currently returns response.json() as Promise<T>) and on parse failure create and
throw the structured ApiError used elsewhere (include response.status,
response.statusText and the original parse error/message) so callers receive a
consistent ApiError instead of an unhandled SyntaxError.


function getHeaderValue(headers: unknown, key: string): unknown {
if (!headers || typeof headers !== 'object') return undefined;
const headerObject = headers as { get?: (name: string) => unknown } & Record<string, unknown>;
function parseRetryAfterSeconds(header: string | null, body: unknown): number | undefined {
const raw = body ?? header;

if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;

if (typeof raw === 'string') {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;

if (typeof headerObject.get === 'function') {
return headerObject.get(key);
const retryDate = Date.parse(raw);
if (!Number.isNaN(retryDate)) return Math.max(0, Math.ceil((retryDate - Date.now()) / 1000));
}

return headerObject[key] ?? headerObject[key.toLowerCase()] ?? headerObject[key.toUpperCase()];
return undefined;
}

const client = {
get<T>(path: string, options: { params?: Record<string, unknown> } = {}): Promise<T> {
return request<T>('GET', path, { params: options.params });
},
post<T>(path: string, body?: unknown): Promise<T> {
return request<T>('POST', path, { body });
},
};

export default client;
6 changes: 2 additions & 4 deletions frontend/src/api/faucet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import client from './client';
import type { FaucetInfo, FaucetRequestResponse } from '../types';

export async function getFaucetInfo(): Promise<FaucetInfo> {
const response = await client.get<FaucetInfo>('/faucet/info');
return response.data;
return client.get<FaucetInfo>('/faucet/info');
}

export async function requestFaucet(address: string): Promise<FaucetRequestResponse> {
const response = await client.post<FaucetRequestResponse>('/faucet', { address });
return response.data;
return client.post<FaucetRequestResponse>('/faucet', { address });
}
Loading
Loading