diff --git a/.gitignore b/.gitignore
index 022bcdd..31cefc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,4 +30,4 @@ build
*.tsbuildinfo
.env
*.db
-*.db-journal
+*.db-journal
\ No newline at end of file
diff --git a/package.json b/package.json
index 76c47a0..bf67992 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"name": "@ringdao/msgscan",
"version": "0.1.0",
"private": true,
+ "packageManager": "pnpm@10.15.0",
"scripts": {
"start:web": "pnpm --filter @ringdao/msgscan-ui start",
"build:web": "pnpm --filter @ringdao/msgscan-ui build",
diff --git a/packages/web/.eslintignore b/packages/web/.eslintignore
deleted file mode 100644
index 66a71b9..0000000
--- a/packages/web/.eslintignore
+++ /dev/null
@@ -1 +0,0 @@
-next.config.mjs
diff --git a/packages/web/.eslintrc.cjs b/packages/web/.eslintrc.cjs
new file mode 100644
index 0000000..b01891f
--- /dev/null
+++ b/packages/web/.eslintrc.cjs
@@ -0,0 +1,50 @@
+module.exports = {
+ extends: [
+ 'prettier',
+ 'plugin:@tanstack/eslint-plugin-query/recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'next/core-web-vitals'
+ ],
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json'],
+ tsconfigRootDir: __dirname
+ },
+ settings: {
+ 'import/parsers': {
+ '@typescript-eslint/parser': ['.ts', '.tsx']
+ },
+ 'import/resolver': {
+ typescript: true,
+ node: true
+ }
+ },
+ plugins: ['@typescript-eslint'],
+ parser: '@typescript-eslint/parser',
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'warn',
+ 'import/order': [
+ 'error',
+ {
+ groups: ['builtin', 'external', 'internal', 'parent', 'index', 'sibling', 'object', 'type'],
+ pathGroups: [
+ {
+ pattern: 'react',
+ group: 'external'
+ },
+ {
+ pattern: '@/**',
+ group: 'internal',
+ position: 'after'
+ }
+ ],
+ pathGroupsExcludedImportTypes: ['type'],
+ 'newlines-between': 'always'
+ }
+ ],
+ '@typescript-eslint/consistent-type-exports': 'error',
+ '@typescript-eslint/consistent-type-imports': 'error',
+ '@typescript-eslint/no-import-type-side-effects': 'error'
+ }
+};
diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json
deleted file mode 100644
index 7f1d383..0000000
--- a/packages/web/.eslintrc.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "extends": [
- "prettier",
- "plugin:@tanstack/eslint-plugin-query/recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:import/recommended",
- "plugin:import/typescript",
- "next/core-web-vitals"
- ],
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module",
- "project": "./tsconfig.json"
- },
- "settings": {
- "import/parsers": {
- "@typescript-eslint/parser": [
- ".ts",
- ".tsx"
- ]
- },
- "import/resolver": {
- "typescript": true,
- "node": true
- }
- },
- "plugins": [
- "@typescript-eslint"
- ],
- "parser": "@typescript-eslint/parser",
- "rules": {
- "@typescript-eslint/no-explicit-any": "warn",
- "import/order": [
- "error",
- {
- "groups": [
- "builtin",
- "external",
- "internal",
- "parent",
- "index",
- "sibling",
- "object",
- "type"
- ],
- "pathGroups": [
- {
- "pattern": "react",
- "group": "external"
- },
- {
- "pattern": "@/**",
- "group": "internal",
- "position": "after"
- }
- ],
- "pathGroupsExcludedImportTypes": [
- "type"
- ],
- "newlines-between": "always"
- }
- ],
- "@typescript-eslint/consistent-type-exports": "error",
- "@typescript-eslint/consistent-type-imports": "error",
- "@typescript-eslint/no-import-type-side-effects": "error"
- }
-}
\ No newline at end of file
diff --git a/packages/web/components.json b/packages/web/components.json
index 8c574b7..871ac2a 100644
--- a/packages/web/components.json
+++ b/packages/web/components.json
@@ -1,10 +1,10 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "default",
+ "style": "base-vega",
"rsc": true,
"tsx": true,
"tailwind": {
- "config": "tailwind.config.ts",
+ "config": "tailwind.config.mjs",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
@@ -12,6 +12,9 @@
},
"aliases": {
"components": "@/components",
- "utils": "@/lib/utils"
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
}
-}
\ No newline at end of file
+}
diff --git a/packages/web/eslint.config.mjs b/packages/web/eslint.config.mjs
new file mode 100644
index 0000000..b1ff0d5
--- /dev/null
+++ b/packages/web/eslint.config.mjs
@@ -0,0 +1,77 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
+import nextTypescript from 'eslint-config-next/typescript';
+import prettier from 'eslint-config-prettier';
+import tanstackQuery from '@tanstack/eslint-plugin-query';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const config = [
+ ...nextCoreWebVitals,
+ ...nextTypescript,
+ ...tanstackQuery.configs['flat/recommended'],
+ {
+ files: ['**/*.{js,jsx,ts,tsx}'],
+ languageOptions: {
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json'],
+ tsconfigRootDir: __dirname
+ }
+ },
+ settings: {
+ 'import/parsers': {
+ '@typescript-eslint/parser': ['.ts', '.tsx']
+ },
+ 'import/resolver': {
+ typescript: true,
+ node: true
+ }
+ },
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'warn',
+ '@typescript-eslint/no-empty-object-type': 'off',
+ 'react-hooks/set-state-in-effect': 'off',
+ 'import/order': [
+ 'error',
+ {
+ groups: [
+ 'builtin',
+ 'external',
+ 'internal',
+ 'parent',
+ 'index',
+ 'sibling',
+ 'object',
+ 'type'
+ ],
+ pathGroups: [
+ {
+ pattern: 'react',
+ group: 'external'
+ },
+ {
+ pattern: '@/**',
+ group: 'internal',
+ position: 'after'
+ }
+ ],
+ pathGroupsExcludedImportTypes: ['type'],
+ 'newlines-between': 'always'
+ }
+ ],
+ '@typescript-eslint/consistent-type-exports': 'error',
+ '@typescript-eslint/consistent-type-imports': 'error',
+ '@typescript-eslint/no-import-type-side-effects': 'error'
+ }
+ },
+ prettier,
+ {
+ ignores: ['node_modules/**', '.next/**', 'public/sw.js', 'next.config.mjs']
+ }
+];
+
+export default config;
diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs
index b9603bf..4678774 100644
--- a/packages/web/next.config.mjs
+++ b/packages/web/next.config.mjs
@@ -1,8 +1,4 @@
-import withSerwistInit from '@serwist/next';
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
-const withSerwist = withSerwistInit({
- swSrc: 'src/app/sw.ts',
- swDest: 'public/sw.js'
-});
-
-export default withSerwist({});
+export default nextConfig;
diff --git a/packages/web/package.json b/packages/web/package.json
index c61eb6b..ab0036e 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,66 +1,61 @@
{
"name": "@ringdao/msgscan-ui",
"version": "0.1.0",
+ "packageManager": "pnpm@10.15.0",
+ "engines": {
+ "node": "22.x",
+ "pnpm": "10.x"
+ },
"scripts": {
"dev": "next dev -p 3200",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
+ "test": "vitest run"
},
"dependencies": {
- "@radix-ui/react-avatar": "^1.0.4",
- "@radix-ui/react-checkbox": "^1.0.4",
- "@radix-ui/react-dialog": "^1.0.5",
- "@radix-ui/react-dropdown-menu": "^2.0.6",
- "@radix-ui/react-icons": "^1.3.0",
- "@radix-ui/react-popover": "^1.0.7",
- "@radix-ui/react-select": "^2.0.0",
- "@radix-ui/react-separator": "^1.0.3",
- "@radix-ui/react-slot": "^1.0.2",
- "@radix-ui/react-tooltip": "^1.0.7",
- "@serwist/next": "^9.0.3",
- "@tanstack/react-query": "^5.40.0",
- "@tanstack/react-table": "^8.17.3",
- "add": "^2.0.6",
- "class-variance-authority": "^0.7.0",
+ "@base-ui/react": "^1.2.0",
+ "@tanstack/react-query": "^5.90.21",
+ "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "cmdk": "^1.0.0",
- "dayjs": "^1.11.11",
- "framer-motion": "^11.2.9",
- "graphql-request": "^7.0.1",
- "immer": "^10.1.1",
- "lodash-es": "^4.17.21",
- "lucide-react": "^0.379.0",
- "next": "14.2.3",
- "next-themes": "^0.3.0",
- "nuqs": "^1.17.4",
- "pnpm": "^9.5.0",
- "react": "^18",
- "react-day-picker": "^8.10.1",
- "react-dom": "^18",
- "react-use": "^17.5.0",
- "tailwind-merge": "^2.3.0",
- "tailwindcss-animate": "^1.0.7",
- "zustand": "^4.5.2"
+ "cmdk": "^1.1.1",
+ "date-fns": "^4.1.0",
+ "framer-motion": "^12.34.0",
+ "graphql-request": "^7.4.0",
+ "lucide-react": "^0.563.0",
+ "next": "16.1.6",
+ "next-themes": "0.4.6",
+ "nuqs": "2.8.8",
+ "react": "19.2.4",
+ "react-day-picker": "^9.13.2",
+ "react-dom": "19.2.4",
+ "recharts": "^3.7.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
- "@tanstack/eslint-plugin-query": "^5.35.6",
- "@types/lodash-es": "^4.17.12",
- "@types/node": "^20",
- "@types/react": "^18",
- "@types/react-dom": "^18",
- "@typescript-eslint/eslint-plugin": "^7.16.0",
- "@typescript-eslint/parser": "^7.16.0",
- "eslint": "^8",
- "eslint-config-next": "14.2.3",
- "eslint-config-prettier": "^9.1.0",
- "eslint-import-resolver-typescript": "^3.6.1",
- "eslint-plugin-import": "^2.29.1",
- "postcss": "^8",
- "prettier": "^3.3.1",
- "prettier-plugin-tailwindcss": "^0.6.1",
- "serwist": "^9.0.3",
- "tailwindcss": "^3.4.1",
- "typescript": "^5"
+ "@tailwindcss/postcss": "4.1.18",
+ "@tanstack/eslint-plugin-query": "^5.91.4",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^25.2.3",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "@typescript-eslint/eslint-plugin": "8.55.0",
+ "@typescript-eslint/parser": "8.55.0",
+ "eslint": "9.39.2",
+ "eslint-config-next": "16.1.6",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-import-resolver-typescript": "^4.4.4",
+ "eslint-plugin-import": "^2.32.0",
+ "jsdom": "^28.0.0",
+ "postcss": "^8.5.6",
+ "prettier": "^3.8.1",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "tailwindcss": "4.1.18",
+ "typescript": "5.9.3",
+ "vite-tsconfig-paths": "^6.1.1",
+ "vitest": "^4.0.18"
}
}
diff --git a/packages/web/postcss.config.mjs b/packages/web/postcss.config.mjs
index 1a69fd2..d41354f 100644
--- a/packages/web/postcss.config.mjs
+++ b/packages/web/postcss.config.mjs
@@ -1,8 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
- tailwindcss: {},
- },
+ '@tailwindcss/postcss': {}
+ }
};
export default config;
diff --git a/packages/web/src/app/api/message-stats/route.test.ts b/packages/web/src/app/api/message-stats/route.test.ts
new file mode 100644
index 0000000..4391e5c
--- /dev/null
+++ b/packages/web/src/app/api/message-stats/route.test.ts
@@ -0,0 +1,237 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { chains } from '@/config/chains';
+
+const { fetchMessageStatsMock } = vi.hoisted(() => ({
+ fetchMessageStatsMock: vi.fn()
+}));
+
+vi.mock('@/graphql/services', () => ({
+ fetchMessageStats: fetchMessageStatsMock
+}));
+
+import {
+ GET,
+ MAX_CHAIN_IDS_TOKENS,
+ exceedsChainIdsTokenLimit,
+ parseChainIds,
+ readRawChainIds,
+ validateChainIds
+} from './route';
+
+beforeEach(() => {
+ fetchMessageStatsMock.mockReset();
+});
+
+describe('parseChainIds', () => {
+ it('returns empty result for missing query', () => {
+ expect(parseChainIds(null)).toEqual({
+ chainIds: [],
+ invalidTokens: []
+ });
+ });
+
+ it('deduplicates valid ids', () => {
+ expect(parseChainIds('1,2,2')).toEqual({
+ chainIds: [1, 2],
+ invalidTokens: []
+ });
+ });
+
+ it('marks empty tokens as invalid', () => {
+ expect(parseChainIds('1,,2')).toEqual({
+ chainIds: [1, 2],
+ invalidTokens: ['(empty)']
+ });
+ });
+
+ it('marks non-number token as invalid', () => {
+ expect(parseChainIds('1,abc')).toEqual({
+ chainIds: [1],
+ invalidTokens: ['abc']
+ });
+ });
+});
+
+describe('validateChainIds', () => {
+ it('returns invalid ids when chain list contains unknown ids', () => {
+ expect(validateChainIds('1,999', [1, 2])).toEqual({
+ chainIds: [1, 999],
+ invalidTokens: [],
+ invalidIds: [999]
+ });
+ });
+
+ it('keeps valid ids only when no invalid token and id found', () => {
+ expect(validateChainIds('1,2', [1, 2, 3])).toEqual({
+ chainIds: [1, 2],
+ invalidTokens: [],
+ invalidIds: []
+ });
+ });
+});
+
+describe('readRawChainIds', () => {
+ it('merges multiple chainIds query params', () => {
+ const searchParams = new URLSearchParams('chainIds=1&chainIds=abc');
+
+ expect(readRawChainIds(searchParams)).toBe('1,abc');
+ });
+});
+
+describe('exceedsChainIdsTokenLimit', () => {
+ it('returns true when token count exceeds limit', () => {
+ const raw = Array.from({ length: MAX_CHAIN_IDS_TOKENS + 1 }, (_, index) => `${index + 1}`).join(',');
+ expect(exceedsChainIdsTokenLimit(raw, MAX_CHAIN_IDS_TOKENS)).toBe(true);
+ });
+
+ it('returns false when token count equals limit', () => {
+ const raw = Array.from({ length: MAX_CHAIN_IDS_TOKENS }, (_, index) => `${index + 1}`).join(',');
+ expect(exceedsChainIdsTokenLimit(raw, MAX_CHAIN_IDS_TOKENS)).toBe(false);
+ });
+});
+
+describe('GET /api/message-stats', () => {
+ it('returns 400 when duplicated chainIds include invalid token', async () => {
+ const response = await GET(new Request('http://localhost/api/message-stats?chainIds=1&chainIds=abc'));
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({
+ message: 'Invalid chainIds query parameter',
+ invalidTokens: ['abc']
+ });
+ expect(fetchMessageStatsMock).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 when chainIds include unknown chain id', async () => {
+ const response = await GET(new Request('http://localhost/api/message-stats?chainIds=999999999'));
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({
+ message: 'Invalid chainIds query parameter',
+ invalidIds: [999999999]
+ });
+ expect(fetchMessageStatsMock).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 when chainIds value is empty', async () => {
+ const response = await GET(new Request('http://localhost/api/message-stats?chainIds='));
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({
+ message: 'Invalid chainIds query parameter',
+ invalidTokens: ['(empty)']
+ });
+ expect(fetchMessageStatsMock).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 when chainIds token count exceeds upper limit', async () => {
+ const oversized = Array.from(
+ { length: MAX_CHAIN_IDS_TOKENS + 1 },
+ () => `${chains[0]?.id ?? 1}`
+ ).join(',');
+ const response = await GET(
+ new Request(`http://localhost/api/message-stats?chainIds=${oversized}`)
+ );
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({
+ message: 'Invalid chainIds query parameter',
+ invalidTokens: ['(too_many_values)'],
+ maxAllowedValues: MAX_CHAIN_IDS_TOKENS
+ });
+ expect(fetchMessageStatsMock).not.toHaveBeenCalled();
+ });
+
+ it('returns both invalidTokens and invalidIds for mixed invalid query', async () => {
+ const validChainId = chains[0]?.id;
+ const response = await GET(
+ new Request(`http://localhost/api/message-stats?chainIds=${validChainId},abc,999999999`)
+ );
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({
+ message: 'Invalid chainIds query parameter',
+ invalidTokens: ['abc'],
+ invalidIds: [999999999]
+ });
+ expect(fetchMessageStatsMock).not.toHaveBeenCalled();
+ });
+
+ it('calls fetchMessageStats with configured chains when request is valid', async () => {
+ const expectedStats = {
+ total: 1,
+ success: 1,
+ failed: 0,
+ inflight: 0
+ };
+ fetchMessageStatsMock.mockResolvedValueOnce(expectedStats);
+
+ const chainId = chains[0]?.id;
+ const response = await GET(
+ new Request(`http://localhost/api/message-stats?chainIds=${chainId}`)
+ );
+
+ expect(response.status).toBe(200);
+ expect(fetchMessageStatsMock).toHaveBeenCalledTimes(1);
+ expect(fetchMessageStatsMock).toHaveBeenCalledWith(
+ chains.filter((chain) => chain.id === chainId)
+ );
+ await expect(response.json()).resolves.toEqual(expectedStats);
+ });
+
+ it('supports repeated chainIds query params for valid multi-value requests', async () => {
+ const expectedStats = {
+ total: 2,
+ success: 1,
+ failed: 1,
+ inflight: 0
+ };
+ fetchMessageStatsMock.mockResolvedValueOnce(expectedStats);
+
+ const mainnetId = chains.find((chain) => !chain.testnet)?.id;
+ const testnetId = chains.find((chain) => chain.testnet)?.id;
+ expect(mainnetId).toBeDefined();
+ expect(testnetId).toBeDefined();
+
+ const response = await GET(
+ new Request(
+ `http://localhost/api/message-stats?chainIds=${mainnetId}&chainIds=${testnetId}`
+ )
+ );
+
+ expect(response.status).toBe(200);
+ expect(fetchMessageStatsMock).toHaveBeenCalledWith(
+ chains.filter((chain) => [mainnetId, testnetId].includes(chain.id))
+ );
+ await expect(response.json()).resolves.toEqual(expectedStats);
+ });
+
+ it('uses non-testnet chains when chainIds is absent', async () => {
+ const expectedStats = {
+ total: 3,
+ success: 2,
+ failed: 1,
+ inflight: 0
+ };
+ fetchMessageStatsMock.mockResolvedValueOnce(expectedStats);
+
+ const response = await GET(new Request('http://localhost/api/message-stats'));
+
+ expect(response.status).toBe(200);
+ expect(fetchMessageStatsMock).toHaveBeenCalledTimes(1);
+ expect(fetchMessageStatsMock).toHaveBeenCalledWith(chains.filter((chain) => !chain.testnet));
+ await expect(response.json()).resolves.toEqual(expectedStats);
+ });
+
+ it('returns 500 when fetchMessageStats throws', async () => {
+ fetchMessageStatsMock.mockRejectedValueOnce(new Error('backend unavailable'));
+
+ const response = await GET(new Request('http://localhost/api/message-stats'));
+
+ expect(response.status).toBe(500);
+ await expect(response.json()).resolves.toEqual({
+ message: 'Failed to fetch message stats'
+ });
+ });
+});
diff --git a/packages/web/src/app/api/message-stats/route.ts b/packages/web/src/app/api/message-stats/route.ts
new file mode 100644
index 0000000..daedfec
--- /dev/null
+++ b/packages/web/src/app/api/message-stats/route.ts
@@ -0,0 +1,137 @@
+import { NextResponse } from 'next/server';
+
+import { chains } from '@/config/chains';
+import { fetchMessageStats } from '@/graphql/services';
+
+export const MAX_CHAIN_IDS_PARAM_LENGTH = 2048;
+export const MAX_CHAIN_IDS_TOKENS = Math.max(chains.length, 64);
+
+export interface ParsedChainIds {
+ chainIds: number[];
+ invalidTokens: string[];
+}
+
+export function parseChainIds(raw: string | null): ParsedChainIds {
+ if (raw === null) return { chainIds: [], invalidTokens: [] };
+
+ const tokens = raw.split(',').map((item) => item.trim());
+ if (tokens.length === 0) return { chainIds: [], invalidTokens: ['(empty)'] };
+
+ const invalidTokens: string[] = [];
+ const ids: number[] = [];
+
+ tokens.forEach((token) => {
+ if (!token) {
+ invalidTokens.push('(empty)');
+ return;
+ }
+
+ const value = Number(token);
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
+ invalidTokens.push(token);
+ return;
+ }
+
+ ids.push(value);
+ });
+
+ return { chainIds: Array.from(new Set(ids)), invalidTokens };
+}
+
+export interface ValidatedChainIds extends ParsedChainIds {
+ invalidIds: number[];
+}
+
+export function validateChainIds(raw: string | null, validIds: number[]): ValidatedChainIds {
+ const parsed = parseChainIds(raw);
+ const validIdSet = new Set(validIds);
+
+ return {
+ ...parsed,
+ invalidIds: parsed.chainIds.filter((id) => !validIdSet.has(id))
+ };
+}
+
+export function readRawChainIds(searchParams: URLSearchParams): string | null {
+ const values = searchParams.getAll('chainIds');
+ if (values.length === 0) return null;
+ return values.join(',');
+}
+
+export function exceedsChainIdsTokenLimit(raw: string, maxTokens = MAX_CHAIN_IDS_TOKENS): boolean {
+ let tokenCount = 1;
+ for (let index = 0; index < raw.length; index += 1) {
+ if (raw.charCodeAt(index) === 44) {
+ tokenCount += 1;
+ if (tokenCount > maxTokens) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+export async function GET(request: Request) {
+ try {
+ const url = new URL(request.url);
+ const rawChainIds = readRawChainIds(url.searchParams);
+ if (rawChainIds !== null) {
+ if (
+ rawChainIds.length > MAX_CHAIN_IDS_PARAM_LENGTH ||
+ exceedsChainIdsTokenLimit(rawChainIds)
+ ) {
+ return NextResponse.json(
+ {
+ message: 'Invalid chainIds query parameter',
+ invalidTokens: ['(too_many_values)'],
+ maxAllowedValues: MAX_CHAIN_IDS_TOKENS
+ },
+ { status: 400 }
+ );
+ }
+ }
+
+ const { chainIds, invalidTokens, invalidIds } = validateChainIds(
+ rawChainIds,
+ chains.map((chain) => chain.id)
+ );
+
+ if (rawChainIds !== null) {
+ if (invalidTokens.length > 0 || invalidIds.length > 0) {
+ return NextResponse.json(
+ {
+ message: 'Invalid chainIds query parameter',
+ invalidTokens: invalidTokens.length > 0 ? invalidTokens : undefined,
+ invalidIds: invalidIds.length > 0 ? invalidIds : undefined
+ },
+ { status: 400 }
+ );
+ }
+ }
+
+ if (rawChainIds !== null && chainIds.length === 0) {
+ return NextResponse.json(
+ {
+ message: 'Invalid chainIds query parameter'
+ },
+ { status: 400 }
+ );
+ }
+
+ const selectedChains =
+ chainIds.length > 0
+ ? chains.filter((chain) => chainIds.includes(chain.id))
+ : chains.filter((chain) => !chain.testnet);
+
+ const stats = await fetchMessageStats(selectedChains);
+ return NextResponse.json(stats, { status: 200 });
+ } catch (error) {
+ console.error('message-stats api failed:', error);
+ return NextResponse.json(
+ {
+ message: 'Failed to fetch message stats'
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/src/app/dapp/[address]/DappAddressHeader.tsx b/packages/web/src/app/dapp/[address]/DappAddressHeader.tsx
new file mode 100644
index 0000000..5c2f3db
--- /dev/null
+++ b/packages/web/src/app/dapp/[address]/DappAddressHeader.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { CodeFont } from '@/config/font';
+import { toShortText } from '@/utils';
+import ClipboardIconButton from '@/components/clipboard-icon-button';
+import AddressDisplayFilterDappRemark from '@/components/address-display-filter-dapp-remark';
+
+interface DappAddressHeaderProps {
+ address: string;
+ dappName: string | null | undefined;
+ effectiveNetwork: string;
+}
+
+export default function DappAddressHeader({ address, dappName, effectiveNetwork }: DappAddressHeaderProps) {
+ return (
+
+
+ Messages
+ ›
+ {dappName ? 'Dapp' : 'Sender'}
+ {effectiveNetwork}
+
+
+
+ );
+}
diff --git a/packages/web/src/app/dapp/[address]/page.tsx b/packages/web/src/app/dapp/[address]/page.tsx
index 7968d25..41a3309 100644
--- a/packages/web/src/app/dapp/[address]/page.tsx
+++ b/packages/web/src/app/dapp/[address]/page.tsx
@@ -1,49 +1,31 @@
-'use client';
-
-import SearchBar from '@/components/search-bar';
import { Separator } from '@/components/ui/separator';
-import { FlipWords } from '@/components/ui/flip-words';
-import { getChainsByNetwork } from '@/utils/network';
+import { getChainsByNetwork, getNetwork } from '@/utils/network';
import MessagePortTable from '@/components/message-port-table';
-import AddressDisplayFilterDappRemark from '@/components/address-display-filter-dapp-remark';
-import { CodeFont } from '@/config/font';
-import { cn } from '@/lib/utils';
import { getDAppInfo } from '@/utils';
+import DappAddressHeader from './DappAddressHeader';
+
interface PageProps {
- params: {
+ params: Promise<{
address: string;
- };
- searchParams: {
- network: string;
- };
+ }>;
+ searchParams: Promise<{
+ network?: string;
+ }>;
}
-export default function Page({ params, searchParams }: PageProps) {
- const chains = getChainsByNetwork(searchParams?.network);
- const { dappName } = getDAppInfo(params?.address);
+export default async function Page({ params, searchParams }: PageProps) {
+ const [{ address }, { network }] = await Promise.all([params, searchParams]);
+ const effectiveNetwork = getNetwork(network);
+ const chains = getChainsByNetwork(effectiveNetwork);
+ const { dappName } = getDAppInfo(address);
- const words = [params?.address];
return (
<>
-
-
-
-
-
{dappName ? 'Dapp' : 'Address'}
-
- {dappName ? (
-
- ) : (
-
- )}
-
-
-
-
+
+
+
+
+
>
);
}
diff --git a/packages/web/src/app/error.test.tsx b/packages/web/src/app/error.test.tsx
new file mode 100644
index 0000000..196c80e
--- /dev/null
+++ b/packages/web/src/app/error.test.tsx
@@ -0,0 +1,32 @@
+/** @vitest-environment jsdom */
+
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import ErrorComponent from './error';
+
+vi.mock('@/components/error-display', () => ({
+ default: (props: { title: string; description: string }) => (
+
+ {props.title}
+ {props.description}
+
+ )
+}));
+
+describe('global error boundary', () => {
+ it('renders retry action and calls reset', () => {
+ const reset = vi.fn();
+
+ render(
+
+ );
+
+ expect(screen.queryByTestId('error-display')).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx
index b912f0f..3e10a98 100644
--- a/packages/web/src/app/error.tsx
+++ b/packages/web/src/app/error.tsx
@@ -1,21 +1,32 @@
'use client';
import ErrorDisplay from '@/components/error-display';
+import { Button } from '@/components/ui/button';
-const ErrorComponent = () => {
+interface ErrorComponentProps {
+ error: Error & { digest?: string };
+ reset: () => void;
+}
+
+const ErrorComponent = ({ reset }: ErrorComponentProps) => {
return (
);
};
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index 200f372..3a28de1 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -1,119 +1,315 @@
-@tailwind base;
- @tailwind components;
- @tailwind utilities;
+@import "tailwindcss";
+@config "../../tailwind.config.mjs";
- @layer base {
- :root {
- --background: 0 0% 100%;
- --foreground: 0 0% 0%;
+@custom-variant dark (&:is(.dark *));
- --card: 0 0% 98%;
- --card-foreground: 0 0% 0%;
+:root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
- --popover: 0 0% 98%;
- --popover-foreground: 0 0% 0%;
+ --card: 0 0% 98%;
+ --card-foreground: 222.2 84% 4.9%;
+ --card-elevated: 0 0% 95%;
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
+ --popover: 0 0% 98%;
+ --popover-foreground: 222.2 84% 4.9%;
- --secondary: 0 0% 98%;
- --secondary-foreground: 0 0% 60%;
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
- --muted: 0 0% 98%;
- --muted-foreground: 0 0% 50%;
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 215.4 16.3% 46.9%;
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 210 40% 98%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
- --border: 214.3 31.8% 91.4%;
- --input: 0 0% 98%;
- --ring: 0 0% 90%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
- --radius: 0.25rem;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
- --header-height: 50px;
+ --radius: 0.625rem;
- --footer-height: 50px;
+ --header-height: 64px;
+ --footer-height: 50px;
- --inflight: 43 97% 59%;
- --success: 105 63% 62%;
- --failure: 0 100% 71%;
+ /* Semantic status colors */
+ --success: 142 71% 45%;
+ --success-foreground: 0 0% 100%;
+ --warning: 38 92% 50%;
+ --warning-foreground: 0 0% 100%;
+ --pending: 43 97% 59%;
+ --pending-foreground: 0 0% 0%;
+ --info: 217 91% 60%;
+ --info-foreground: 0 0% 100%;
+ --inflight: 43 97% 59%;
+ --failure: 0 84.2% 60.2%;
- --skeleton: 240 4.8% 95.9%;
+ /* Step indicator colors */
+ --step-1: 217 91% 60%;
+ --step-2: 262 83% 58%;
+ --step-3: 38 92% 50%;
+ --step-4-success: 142 71% 45%;
+ --step-4-failed: 0 84.2% 60.2%;
+ --step-incomplete: 215 20% 65%;
+ --step-connector: 214.3 31.8% 91.4%;
- }
+ --skeleton: 240 4.8% 95.9%;
+}
- .dark {
- --background: 0 0% 0%;
- --foreground: 0 0% 100%;
+.dark {
+ --background: 222.2 84% 1.4%;
+ --foreground: 210 40% 98%;
- --card: 0 0% 8.63%;
- --card-foreground: 0 0% 100%;
+ --card: 222.2 47.4% 11.2%;
+ --card-foreground: 210 40% 98%;
+ --card-elevated: 222.2 47.4% 14%;
- --popover: 0 0% 8.63%;
- --popover-foreground: 0 0% 100%;
+ --popover: 222.2 47.4% 11.2%;
+ --popover-foreground: 210 40% 98%;
- --primary: 210 40% 98%;
- --primary-foreground: 222.2 47.4% 11.2%;
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
- --secondary:0 0% 8.63%;
- --secondary-foreground: 0 0% 60%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
- --muted: 0 0% 8.63%;
- --muted-foreground: 0 0% 50%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20% 65%;
- --accent: 0 0% 15%;
- --accent-foreground: 210 40% 98%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
- --border: 0 0% 16.47%;
- --input: 0 0% 8.63%;
- --ring:0 0% 30%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
- --skeleton: 240 3.7% 15.9%;
+ /* Semantic status colors (dark) */
+ --success: 142 71% 45%;
+ --success-foreground: 0 0% 100%;
+ --warning: 38 92% 50%;
+ --warning-foreground: 0 0% 0%;
+ --pending: 43 97% 59%;
+ --pending-foreground: 0 0% 0%;
+ --info: 217 91% 60%;
+ --info-foreground: 0 0% 100%;
+ --inflight: 43 97% 59%;
+ --failure: 0 84.2% 60.2%;
- }
+ /* Step indicator colors (dark) */
+ --step-1: 217 91% 60%;
+ --step-2: 262 83% 58%;
+ --step-3: 38 92% 50%;
+ --step-4-success: 142 71% 45%;
+ --step-4-failed: 0 84.2% 60.2%;
+ --step-incomplete: 215 20% 45%;
+ --step-connector: 217.2 32.6% 17.5%;
+
+ --skeleton: 217.2 32.6% 17.5%;
+}
+
+@theme inline {
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+
+ --color-card: hsl(var(--card));
+ --color-card-foreground: hsl(var(--card-foreground));
+ --color-card-elevated: hsl(var(--card-elevated));
+
+ --color-popover: hsl(var(--popover));
+ --color-popover-foreground: hsl(var(--popover-foreground));
+
+ --color-primary: hsl(var(--primary));
+ --color-primary-foreground: hsl(var(--primary-foreground));
+
+ --color-secondary: hsl(var(--secondary));
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
+
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+
+ --color-accent: hsl(var(--accent));
+ --color-accent-foreground: hsl(var(--accent-foreground));
+
+ --color-destructive: hsl(var(--destructive));
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
+
+ --color-border: hsl(var(--border));
+ --color-input: hsl(var(--input));
+ --color-ring: hsl(var(--ring));
+
+ --color-success: hsl(var(--success));
+ --color-success-foreground: hsl(var(--success-foreground));
+ --color-warning: hsl(var(--warning));
+ --color-warning-foreground: hsl(var(--warning-foreground));
+ --color-pending: hsl(var(--pending));
+ --color-pending-foreground: hsl(var(--pending-foreground));
+ --color-info: hsl(var(--info));
+ --color-info-foreground: hsl(var(--info-foreground));
+ --color-inflight: hsl(var(--inflight));
+ --color-failure: hsl(var(--failure));
+ --color-skeleton: hsl(var(--skeleton));
+
+ --color-step-1: hsl(var(--step-1));
+ --color-step-2: hsl(var(--step-2));
+ --color-step-3: hsl(var(--step-3));
+ --color-step-4-success: hsl(var(--step-4-success));
+ --color-step-4-failed: hsl(var(--step-4-failed));
+ --color-step-incomplete: hsl(var(--step-incomplete));
+ --color-step-connector: hsl(var(--step-connector));
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+* {
+ @apply border-border;
+}
+
+body {
+ @apply bg-background text-foreground;
+}
+
+/* Step pulse animation */
+@keyframes step-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.5;
+ transform: scale(1.15);
+ }
+}
+
+.animate-step-pulse {
+ animation: step-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+/* Ellipsis animation */
+@keyframes ellipsis {
+ 0%,
+ 20% {
+ content: "";
+ }
+ 40% {
+ content: ".";
+ }
+ 60% {
+ content: "..";
}
+ 80%,
+ 100% {
+ content: "...";
+ }
+}
+
+.animate-ellipsis::after {
+ content: "";
+ animation: ellipsis 1.5s infinite;
+}
+
+/* Step progress bar — connection lines (large, detail page) */
+.step-item-lg {
+ position: relative;
+}
+
+.step-item-lg:not(:last-child)::after {
+ content: "";
+ position: absolute;
+ top: 8px;
+ left: calc(50% + 17px);
+ right: calc(-50% + 17px);
+ height: 3px;
+ border-radius: 9999px;
+ background: var(--color-step-incomplete);
+ pointer-events: none;
+}
+
+/* Connection line coloring — per-step colors */
+.step-item-lg.step-done-1:not(:last-child)::after {
+ background: var(--color-step-1);
+}
+
+.step-item-lg.step-done-2:not(:last-child)::after {
+ background: var(--color-step-2);
+}
+
+.step-item-lg.step-done-3:not(:last-child)::after {
+ background: var(--color-step-3);
+}
- @layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- }
+.step-item-lg.step-done-4:not(:last-child)::after {
+ background: var(--color-step-4-success);
+}
+
+/* Active step — dashed animated connector */
+.step-item-lg.step-active:not(:last-child)::after {
+ background: repeating-linear-gradient(
+ 90deg,
+ var(--color-step-incomplete) 0,
+ var(--color-step-incomplete) 6px,
+ transparent 6px,
+ transparent 10px
+ );
+}
+
+/* Incomplete steps keep default connector color (already set by base rule) */
+
+/* Dot pulse animation for active step (box-shadow glow) */
+@keyframes step-dot-pulse {
+ 0%, 100% {
+ box-shadow: 0 0 0 0 hsl(var(--step-3) / 0.4);
}
+ 50% {
+ box-shadow: 0 0 0 8px hsl(var(--step-3) / 0);
+ }
+}
+
+.animate-step-dot-pulse {
+ animation: step-dot-pulse 2s ease-in-out infinite;
+}
- @media (min-width: 1024px) {
- :root {
- --header-height: 80px;
- --footer-height: 60px;
- }
+/* Responsive adjustments for step connection lines */
+@media (max-width: 768px) {
+ .step-item-lg:not(:last-child)::after {
+ top: 7px;
+ left: calc(50% + 15px);
+ right: calc(-50% + 15px);
}
-
-
- @keyframes ellipsis {
- 0%, 20% {
- content: '';
- }
- 40% {
- content: '.';
- }
- 60% {
- content: '..';
- }
- 80%, 100% {
- content: '...';
- }
+}
+
+@media (max-width: 480px) {
+ .step-item-lg:not(:last-child)::after {
+ top: 6px;
+ left: calc(50% + 13px);
+ right: calc(-50% + 13px);
+ }
+}
+
+/* Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
}
-
- .animate-ellipsis::after {
- content: '';
- animation: ellipsis 1.5s infinite;
+
+ .animate-step-pulse {
+ animation: none;
}
-
\ No newline at end of file
+}
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index f277b76..67add70 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -1,8 +1,10 @@
+import { NuqsAdapter } from 'nuqs/adapters/next/app';
+
import { APP_DESCRIPTION, APP_KEYWORDS, APP_NAME } from '@/config/site';
-import { cn } from '@/lib/utils';
import { GlobalFont } from '@/config/font';
import Header from '@/components/header';
import Footer from '@/components/footer';
+import { cn } from '@/lib/utils';
import AppProvider from '@/provider/AppProvider';
import type { Metadata, Viewport } from 'next';
@@ -80,7 +82,10 @@ export const metadata: Metadata = {
};
export const viewport: Viewport = {
- themeColor: '#FFFFFF'
+ themeColor: [
+ { media: '(prefers-color-scheme: light)', color: '#FFFFFF' },
+ { media: '(prefers-color-scheme: dark)', color: '#020817' }
+ ]
};
export default function RootLayout({
children
@@ -90,22 +95,19 @@ export default function RootLayout({
return (
-
-
-
-
- {children}
+
+
+
+
+
+ {children}
+
+
-
-
-
+
+
);
diff --git a/packages/web/src/app/loading.test.tsx b/packages/web/src/app/loading.test.tsx
new file mode 100644
index 0000000..4cfebdd
--- /dev/null
+++ b/packages/web/src/app/loading.test.tsx
@@ -0,0 +1,16 @@
+/** @vitest-environment jsdom */
+
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import LoadingPage from './loading';
+
+describe('app loading', () => {
+ it('renders global skeleton layout', () => {
+ render(
);
+
+ expect(document.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0);
+ expect(screen.queryByRole('heading', { name: 'Loading page' })).toBeNull();
+ expect(screen.queryByText('Preparing data and network context...')).toBeNull();
+ });
+});
diff --git a/packages/web/src/app/loading.tsx b/packages/web/src/app/loading.tsx
index 22c6032..6f1773c 100644
--- a/packages/web/src/app/loading.tsx
+++ b/packages/web/src/app/loading.tsx
@@ -1,21 +1,51 @@
-import { Separator } from '@/components/ui/separator';
+import { Skeleton } from '@/components/ui/skeleton';
const Page = () => {
return (
-
-
-
Search
-
-
-
- Messages sometimes take up to a minute to be indexed.
-
+
+ {/* Stats row: 4 cards */}
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+ {/* Charts: 2 columns */}
+
+ {[0, 1].map((i) => (
+
+
+
+
+ ))}
+
+
+ {/* Table */}
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
);
};
+
export default Page;
diff --git a/packages/web/src/app/message/[id]/components/AddressInfo.test.tsx b/packages/web/src/app/message/[id]/components/AddressInfo.test.tsx
new file mode 100644
index 0000000..da24f00
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/AddressInfo.test.tsx
@@ -0,0 +1,49 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import AddressInfo from './AddressInfo';
+
+vi.mock('next/font/google', () => ({
+ Geist: () => ({
+ className: '',
+ variable: ''
+ }),
+ JetBrains_Mono: () => ({
+ className: '',
+ variable: ''
+ })
+}));
+
+vi.mock('@/components/clipboard-icon-button', () => ({
+ default: () =>
+}));
+
+vi.mock('@/components/explorer-link-button', () => ({
+ default: () =>
+}));
+
+describe('AddressInfo sender link', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('renders internal sender link when href is provided', () => {
+ const address = '0xebd9a48ed1128375eb4383ed4d53478b4fd85a8d';
+ const href = `/sender/${address}?network=mainnet`;
+
+ render(
);
+
+ const link = screen.getByRole('link', { name: /0xebd9/i });
+ expect(link).not.toBeNull();
+ expect(link.getAttribute('href')).toBe(href);
+ });
+
+ it('renders plain text when href is not provided', () => {
+ render(
);
+
+ expect(screen.queryByRole('link')).toBeNull();
+ expect(screen.queryByTestId('clipboard-btn')).not.toBeNull();
+ });
+});
diff --git a/packages/web/src/app/message/[id]/components/AddressInfo.tsx b/packages/web/src/app/message/[id]/components/AddressInfo.tsx
index 92f3dc1..7a2e473 100644
--- a/packages/web/src/app/message/[id]/components/AddressInfo.tsx
+++ b/packages/web/src/app/message/[id]/components/AddressInfo.tsx
@@ -1,6 +1,9 @@
+import Link from 'next/link';
+
import ClipboardIconButton from '@/components/clipboard-icon-button';
import ExplorerLinkButton from '@/components/explorer-link-button';
import { CodeFont } from '@/config/font';
+import { toShortText } from '@/utils';
import { cn } from '@/lib/utils';
import type { CHAIN } from '@/types/chains';
@@ -8,16 +11,25 @@ import type { CHAIN } from '@/types/chains';
interface AddressInfoProps {
address?: string;
chain?: CHAIN;
+ href?: string;
}
-const AddressInfo = ({ address, chain, children }: React.PropsWithChildren
) => {
- if (!address) return null;
+const AddressInfo = ({ address, chain, href, children }: React.PropsWithChildren) => {
+ if (!address) return - ;
+
+ const content = children ?? toShortText({ text: address, frontLength: 6, backLength: 4 });
return (
-
-
- {children ?? address}
-
-
+
+ {href ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
{chain ? (
) : null}
diff --git a/packages/web/src/app/message/[id]/components/ClientPage.test.tsx b/packages/web/src/app/message/[id]/components/ClientPage.test.tsx
new file mode 100644
index 0000000..e5f2373
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/ClientPage.test.tsx
@@ -0,0 +1,161 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import ClientPage from './ClientPage';
+
+import type { CHAIN } from '@/types/chains';
+
+const { useMessageMock, pendingPropsSpy, refetchMock, detailPropsSpy } = vi.hoisted(() => ({
+ useMessageMock: vi.fn(),
+ pendingPropsSpy: vi.fn(),
+ refetchMock: vi.fn(),
+ detailPropsSpy: vi.fn()
+}));
+
+vi.mock('@/hooks/services', () => ({
+ useMessage: useMessageMock
+}));
+
+vi.mock('./Pending', () => ({
+ default: (props: { onRetry?: () => Promise
| unknown }) => {
+ pendingPropsSpy(props);
+ return pending
;
+ }
+}));
+
+vi.mock('./TxDetail', () => ({
+ default: (props: Record) => {
+ detailPropsSpy(props);
+ return detail
;
+ }
+}));
+
+vi.mock('./NotFound', () => ({
+ default: () => not-found
+}));
+
+const emptyChains: CHAIN[] = [];
+
+describe('ClientPage', () => {
+ beforeEach(() => {
+ useMessageMock.mockReset();
+ pendingPropsSpy.mockReset();
+ refetchMock.mockReset();
+ detailPropsSpy.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('passes retry handler to Pending and delegates to refetch', async () => {
+ refetchMock.mockResolvedValue({ data: null });
+ useMessageMock.mockReturnValue({
+ data: null,
+ isPending: true,
+ isSuccess: false,
+ isError: false,
+ refetch: refetchMock
+ });
+
+ render( );
+
+ expect(screen.queryByTestId('pending-view')).not.toBeNull();
+ const onRetry = pendingPropsSpy.mock.calls[0]?.[0]?.onRetry as (() => Promise) | undefined;
+ expect(typeof onRetry).toBe('function');
+ await onRetry?.();
+ expect(refetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders not-found view when query resolves empty with terminal status', () => {
+ useMessageMock.mockReturnValue({
+ data: null,
+ isPending: false,
+ isSuccess: true,
+ isError: false,
+ refetch: refetchMock
+ });
+
+ render( );
+
+ expect(screen.queryByTestId('not-found-view')).not.toBeNull();
+ });
+
+ it('renders detail when message data exists', () => {
+ const chains = [
+ { id: 1, name: 'Ethereum', iconUrl: '/eth.svg' },
+ { id: 42161, name: 'Arbitrum', iconUrl: '/arb.svg' }
+ ] as CHAIN[];
+ const message = {
+ fromChainId: '1',
+ toChainId: '42161',
+ accepted: {
+ toChainId: '42161'
+ }
+ };
+ useMessageMock.mockReturnValue({
+ data: message,
+ isPending: false,
+ isSuccess: true,
+ isError: false,
+ refetch: refetchMock
+ });
+
+ render( );
+
+ expect(screen.queryByTestId('detail-view')).not.toBeNull();
+ expect(detailPropsSpy).toHaveBeenCalledTimes(1);
+ const props = detailPropsSpy.mock.calls[0]?.[0] as Record | undefined;
+ expect(props?.sourceChain).toBe(chains[0]);
+ expect(props?.targetChain).toBe(chains[1]);
+ expect(props?.acceptedTargetChain).toBe(chains[1]);
+ expect(props?.message).toBe(message);
+ });
+
+ it('shows stale-data error banner when refresh fails but cached data exists', () => {
+ const message = {
+ fromChainId: '1',
+ toChainId: '42161',
+ accepted: {
+ toChainId: '42161'
+ }
+ };
+ useMessageMock.mockReturnValue({
+ data: message,
+ isPending: false,
+ isSuccess: false,
+ isError: true,
+ refetch: refetchMock
+ });
+
+ render( );
+
+ expect(
+ screen.queryByText('Failed to refresh latest message data. Showing the last known result.')
+ ).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeNull();
+ expect(screen.queryByTestId('detail-view')).not.toBeNull();
+ });
+
+ it('retries from stale-data banner', () => {
+ refetchMock.mockResolvedValue({ data: null });
+ const message = {
+ fromChainId: '1',
+ toChainId: '42161'
+ };
+ useMessageMock.mockReturnValue({
+ data: message,
+ isPending: false,
+ isSuccess: false,
+ isError: true,
+ refetch: refetchMock
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+
+ expect(refetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/web/src/app/message/[id]/components/ClientPage.tsx b/packages/web/src/app/message/[id]/components/ClientPage.tsx
index f5c0b85..5a3fc0b 100644
--- a/packages/web/src/app/message/[id]/components/ClientPage.tsx
+++ b/packages/web/src/app/message/[id]/components/ClientPage.tsx
@@ -1,9 +1,8 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useCallback } from 'react';
import { useMessage } from '@/hooks/services';
-import useBreakpoint from '@/hooks/breakpoint';
import Pending from './Pending';
import TxDetail from './TxDetail';
@@ -16,38 +15,61 @@ interface ClientPageProps {
chains: CHAIN[];
}
export default function ClientPage({ id, chains }: ClientPageProps) {
- const breakpoint = useBreakpoint();
- const [iconSize, setIconSize] = useState(22);
- const { data, isPending, isSuccess, isError } = useMessage(id as string, chains);
+ const { data, isPending, isSuccess, isError, refetch } = useMessage(id as string, chains);
+
+ const handleRetry = useCallback(async () => {
+ await refetch();
+ }, [refetch]);
const sourceChain = chains?.find(
- (chain) => chain.id === (Number(data?.sourceChainId) as unknown as ChAIN_ID)
+ (chain) => chain.id === (Number(data?.fromChainId) as unknown as ChAIN_ID)
);
const targetChain = chains?.find(
- (chain) => chain.id === (Number(data?.targetChainId) as unknown as ChAIN_ID)
+ (chain) => chain.id === (Number(data?.toChainId) as unknown as ChAIN_ID)
+ );
+ const acceptedTargetChain = chains?.find(
+ (chain) => chain.id === (Number(data?.accepted?.toChainId) as unknown as ChAIN_ID)
);
-
- useEffect(() => {
- if (breakpoint === 'desktop') {
- setIconSize(22);
- } else {
- setIconSize(18);
- }
- }, [breakpoint]);
if ((isSuccess || isError) && !data) {
return ;
}
if (isPending) {
- return ;
+ return ;
}
- return data ? (
-
- ) : null;
+
+ return (
+ <>
+ {isError && data ? (
+
+
+ Failed to refresh latest message data. Showing the last known result.
+
+ {
+ void handleRetry();
+ }}
+ >
+ Retry
+
+
+ ) : null}
+ {data ? (
+
+ ) : (
+
+ )}
+ >
+ );
}
diff --git a/packages/web/src/app/message/[id]/components/NotFound.tsx b/packages/web/src/app/message/[id]/components/NotFound.tsx
index 06da177..b3c8d94 100644
--- a/packages/web/src/app/message/[id]/components/NotFound.tsx
+++ b/packages/web/src/app/message/[id]/components/NotFound.tsx
@@ -1,31 +1,29 @@
+'use client';
+
import Link from 'next/link';
-import { Button } from '@/components/ui/button';
-import { Separator } from '@/components/ui/separator';
+import { buttonVariants } from '@/components/ui/button-variants';
+import { useNetworkFromQuery } from '@/hooks/useNetwork';
+import ErrorDisplay from '@/components/error-display';
+import { cn } from '@/lib/utils';
const NotFound = () => {
+ const network = useNetworkFromQuery();
+
return (
-
-
Message Not Found
-
-
-
- The transaction details you are looking for cannot be found or may no longer exist.
-
-
- Please verify the params or try another search.
-
-
-
- Back Home
-
-
-
-
+
+
+ Back Home
+
);
};
diff --git a/packages/web/src/app/message/[id]/components/OrmpInfo.tsx b/packages/web/src/app/message/[id]/components/OrmpInfo.tsx
deleted file mode 100644
index a6a3e98..0000000
--- a/packages/web/src/app/message/[id]/components/OrmpInfo.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { CodeFont } from '@/config/font';
-import { cn } from '@/lib/utils';
-
-import type { ORMPMessageAccepted } from '@/graphql/type';
-
-interface OrmpInfoProps {
- ormpInfo: ORMPMessageAccepted;
-}
-
-const OrmpInfo = ({ ormpInfo }: OrmpInfoProps) => {
- const data = [
- {
- title: 'msgHash',
- value: ormpInfo?.msgHash || '-'
- },
- {
- title: 'index',
- value: ormpInfo?.index || '-'
- },
- {
- title: 'gaslimit',
- value: ormpInfo?.gasLimit || '-'
- },
- {
- title: 'payload',
- value: ormpInfo?.encoded || '-'
- },
- {
- title: 'channel',
- value: ormpInfo?.channel || '-'
- }
- ];
-
- return (
-
- {data?.map((item, index) => (
-
-
- {item?.title}
-
-
- {item?.value}
-
-
- ))}
-
- );
-};
-export default OrmpInfo;
diff --git a/packages/web/src/app/message/[id]/components/Pending.test.tsx b/packages/web/src/app/message/[id]/components/Pending.test.tsx
new file mode 100644
index 0000000..3029d4a
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/Pending.test.tsx
@@ -0,0 +1,95 @@
+/** @vitest-environment jsdom */
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { Network } from '@/types/network';
+
+import Pending, {
+ PENDING_ACTIONABLE_PHASE_MS,
+ PENDING_INDEXING_PHASE_MS
+} from './Pending';
+
+vi.mock('@/hooks/useNetwork', () => ({
+ useNetworkFromQuery: () => Network.MAINNET
+}));
+
+describe('Pending', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('shows phased feedback and actions after timeout window', async () => {
+ render( Promise.resolve()} />);
+
+ expect(screen.queryByText('Looking up the message on mainnet network…')).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(PENDING_INDEXING_PHASE_MS + 1);
+ });
+ expect(screen.queryByText('Messages may take up to a minute to be indexed.')).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(PENDING_ACTIONABLE_PHASE_MS - PENDING_INDEXING_PHASE_MS + 1);
+ });
+ expect(screen.queryByText('Still not found. You can retry or go back.')).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeNull();
+ const backHomeLink = screen.getByRole('link', { name: 'Back Home' });
+ expect(backHomeLink.getAttribute('href')).toBe('/?network=mainnet');
+ });
+
+ it('shows back-home action only when retry callback is missing', () => {
+ render( );
+
+ act(() => {
+ vi.advanceTimersByTime(PENDING_ACTIONABLE_PHASE_MS + 1);
+ });
+
+ expect(screen.queryByText('Still not found. Go back and try again later.')).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+ expect(screen.queryByRole('link', { name: 'Back Home' })).not.toBeNull();
+ });
+
+ it('runs retry callback and restores button state', async () => {
+ let resolveRetry: (() => void) | null = null;
+ const onRetry = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveRetry = resolve;
+ })
+ );
+ render( );
+
+ act(() => {
+ vi.advanceTimersByTime(PENDING_ACTIONABLE_PHASE_MS);
+ });
+
+ const retryButton = screen.getByRole('button', { name: 'Retry' });
+ fireEvent.click(retryButton);
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ expect((screen.getByRole('button', { name: 'Retrying...' }) as HTMLButtonElement).disabled).toBe(
+ true
+ );
+
+ await act(async () => {
+ resolveRetry?.();
+ await Promise.resolve();
+ });
+ expect(screen.queryByText('Looking up the message on mainnet network…')).not.toBeNull();
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(PENDING_ACTIONABLE_PHASE_MS + 1);
+ });
+ expect((screen.getByRole('button', { name: 'Retry' }) as HTMLButtonElement).disabled).toBe(false);
+ });
+});
diff --git a/packages/web/src/app/message/[id]/components/Pending.tsx b/packages/web/src/app/message/[id]/components/Pending.tsx
index f935dbe..6e65ee5 100644
--- a/packages/web/src/app/message/[id]/components/Pending.tsx
+++ b/packages/web/src/app/message/[id]/components/Pending.tsx
@@ -1,29 +1,148 @@
+'use client';
+
import Link from 'next/link';
+import { Loader2, RotateCcw, Search } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { buttonVariants } from '@/components/ui/button-variants';
import { Button } from '@/components/ui/button';
-import { Separator } from '@/components/ui/separator';
+import { useNetworkFromQuery } from '@/hooks/useNetwork';
+import { cn } from '@/lib/utils';
+
+type PendingPhase = 'initial' | 'indexing' | 'actionable';
+
+export const PENDING_INDEXING_PHASE_MS = 3_000;
+export const PENDING_ACTIONABLE_PHASE_MS = 10_000;
+
+interface PendingProps {
+ onRetry?: () => Promise | unknown;
+}
+
+const Pending = ({ onRetry }: PendingProps) => {
+ const network = useNetworkFromQuery();
+ const [phase, setPhase] = useState('initial');
+ const [isRetrying, setIsRetrying] = useState(false);
+ const indexingTimerRef = useRef | null>(null);
+ const actionableTimerRef = useRef | null>(null);
+ const isMountedRef = useRef(true);
+ const canRetry = Boolean(onRetry);
+
+ const phaseCopy: Record = {
+ initial: `Looking up the message on ${network} network…`,
+ indexing: 'Messages may take up to a minute to be indexed.',
+ actionable: 'Still not found. You can retry or go back.'
+ };
+
+ const phaseCopyWithoutRetry: Record = {
+ initial: `Looking up the message on ${network} network…`,
+ indexing: 'Messages may take up to a minute to be indexed.',
+ actionable: 'Still not found. Go back and try again later.'
+ };
+
+ const clearPhaseTimers = useCallback(() => {
+ if (indexingTimerRef.current) {
+ clearTimeout(indexingTimerRef.current);
+ indexingTimerRef.current = null;
+ }
+ if (actionableTimerRef.current) {
+ clearTimeout(actionableTimerRef.current);
+ actionableTimerRef.current = null;
+ }
+ }, []);
+
+ const restartPhaseTimers = useCallback(() => {
+ clearPhaseTimers();
+ setPhase('initial');
+ indexingTimerRef.current = setTimeout(() => setPhase('indexing'), PENDING_INDEXING_PHASE_MS);
+ actionableTimerRef.current = setTimeout(() => setPhase('actionable'), PENDING_ACTIONABLE_PHASE_MS);
+ }, [clearPhaseTimers]);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ restartPhaseTimers();
+ return () => {
+ isMountedRef.current = false;
+ clearPhaseTimers();
+ };
+ }, [clearPhaseTimers, restartPhaseTimers]);
+
+ const handleRetry = useCallback(async () => {
+ if (!onRetry || isRetrying) return;
+ try {
+ restartPhaseTimers();
+ if (isMountedRef.current) {
+ setIsRetrying(true);
+ }
+ await onRetry();
+ } finally {
+ if (isMountedRef.current) {
+ setIsRetrying(false);
+ }
+ }
+ }, [isRetrying, onRetry, restartPhaseTimers]);
+
+ const statusText = (canRetry ? phaseCopy : phaseCopyWithoutRetry)[phase];
+ const isActionable = phase === 'actionable' || isRetrying;
+ const backHomeHref = useMemo(() => `/?network=${network}`, [network]);
-const Pending = () => {
return (
-
-
Search
-
-
-
- Messages sometimes take up to a minute to be indexed.
-
-
- please wait or try again later.
+
+
+
+
+
+
+
+
+
+
+
Searching message
+
+
+ {statusText}
-
-
- Back Home
-
-
+
+ {phase !== 'initial' ? (
+
You can stay on this page while indexing catches up.
+ ) : null}
+
+
+ {isActionable ? (
+ <>
+ {canRetry ? (
+ {
+ void handleRetry();
+ }}
+ disabled={isRetrying}
+ className="animate-in fade-in duration-300"
+ >
+ {isRetrying ? (
+ <>
+
+ Retrying...
+ >
+ ) : (
+ <>
+
+ Retry
+ >
+ )}
+
+ ) : null}
+
+ Back Home
+
+ >
+ ) : (
+
+ )}
+
diff --git a/packages/web/src/app/message/[id]/components/ProtocolInfo.tsx b/packages/web/src/app/message/[id]/components/ProtocolInfo.tsx
index 3cc9114..5ee8f90 100644
--- a/packages/web/src/app/message/[id]/components/ProtocolInfo.tsx
+++ b/packages/web/src/app/message/[id]/components/ProtocolInfo.tsx
@@ -1,9 +1,9 @@
import { protocols } from '@/config/protocols';
-import type { MessagePort } from '@/graphql/type';
+import type { CompositeMessage } from '@/types/messages';
interface ProtocolInfoProps {
- protocol?: MessagePort['protocol'];
+ protocol?: CompositeMessage['protocol'];
}
const ProtocolInfo = ({ protocol }: ProtocolInfoProps) => {
const currentProtocol = protocols?.find((item) => item.value === protocol);
diff --git a/packages/web/src/app/message/[id]/components/TransactionHashInfo.tsx b/packages/web/src/app/message/[id]/components/TransactionHashInfo.tsx
index d00ae1f..d8a4de9 100644
--- a/packages/web/src/app/message/[id]/components/TransactionHashInfo.tsx
+++ b/packages/web/src/app/message/[id]/components/TransactionHashInfo.tsx
@@ -1,4 +1,6 @@
-import ChainTxDisplay from '@/components/chain-tx-display';
+import { cn } from '@/lib/utils';
+import { CodeFont } from '@/config/font';
+import { toShortText } from '@/utils';
import ClipboardIconButton from '@/components/clipboard-icon-button';
import ExplorerLinkButton from '@/components/explorer-link-button';
@@ -10,25 +12,20 @@ interface TransactionHashInfoProps {
}
const TransactionHashInfo = ({ chain, hash }: TransactionHashInfoProps) => {
- let _url = `${chain?.blockExplorers?.default?.url}/tx/${hash}`;
- if (chain?.name.includes("Tron")) {
- _url = `${chain?.blockExplorers?.default?.url}/#/transaction/${hash?.replace('0x', '')}`
+ if (!hash) return
- ;
+
+ let explorerUrl = `${chain?.blockExplorers?.default?.url}/tx/${hash}`;
+ if (chain?.name.includes('Tron')) {
+ explorerUrl = `${chain?.blockExplorers?.default?.url}/#/transaction/${hash.replace('0x', '')}`;
}
+
return (
-
-
- {hash && }
- {hash && chain ? (
-
- ) : null}
-
+
+
+ {toShortText({ text: hash, frontLength: 6, backLength: 4 })}
+
+ {chain && }
+
);
};
diff --git a/packages/web/src/app/message/[id]/components/TxDetail.tsx b/packages/web/src/app/message/[id]/components/TxDetail.tsx
index 4ed8434..97c4230 100644
--- a/packages/web/src/app/message/[id]/components/TxDetail.tsx
+++ b/packages/web/src/app/message/[id]/components/TxDetail.tsx
@@ -1,152 +1,82 @@
-import {
- ArrowRightFromLine,
- ArrowRightToLine,
- LayoutGrid,
- MessageSquareCode,
- MessageSquareQuote,
- MessageSquareWarning,
- PackageSearch,
- SquareUser,
- Unplug
-} from 'lucide-react';
+'use client';
-import { FlipWords } from '@/components/ui/flip-words';
-import { cn } from '@/lib/utils';
-import { CodeFont } from '@/config/font';
-import OrmpIcon from '@/components/icon/ormp';
-import MessageStatus from '@/components/message-status';
-import FadeInDown from '@/components/ui/fade-in-down';
import BackToTop from '@/components/ui/back-to-top';
-import AddressDisplayFilterDappRemark from '@/components/address-display-filter-dapp-remark';
+import DetailHeader from './sections/DetailHeader';
+import OverviewPanel from './sections/OverviewPanel';
+import TransactionSummary from './sections/TransactionSummary';
+import MessageInfoSection from './sections/MessageInfoSection';
+import ProtocolDetailsSection from './sections/ProtocolDetailsSection';
+import RawDataSection from './sections/RawDataSection';
-import TransactionHashInfo from './TransactionHashInfo';
-import AddressInfo from './AddressInfo';
-import ProtocolInfo from './ProtocolInfo';
-import OrmpInfo from './OrmpInfo';
-import Card from './Card';
-
-import type { MessagePort } from '@/graphql/type';
import type { CHAIN } from '@/types/chains';
-
-
-const words = ['Transaction Details'];
+import type { CompositeMessage } from '@/types/messages';
interface TxDetailProps {
- iconSize?: number;
- message: MessagePort;
+ message: CompositeMessage;
sourceChain?: CHAIN;
targetChain?: CHAIN;
+ acceptedTargetChain?: CHAIN;
}
-export default function TxDetail({ iconSize, sourceChain, targetChain, message }: TxDetailProps) {
- return (
-
-
-
-
-
}>
-
{message?.id}
-
-
-
}>
- {typeof message?.status !== 'undefined' &&
}
-
-
-
}
- >
-
-
-
-
}
- >
-
-
-
-
}
- >
-
-
-
}
- >
- {message?.payload ? (
-
- {message?.payload}
-
- ) : null}
-
-
-
}
- >
- {message?.params ? (
-
- {message?.params}
-
- ) : null}
-
+export default function TxDetail({
+ sourceChain,
+ targetChain,
+ acceptedTargetChain,
+ message,
+}: TxDetailProps) {
+ return (
+
+
+
+ {/* Overview panel */}
+
+
+
-
}
- >
-
-
+ {/* Transaction summary */}
+
+
+
-
}
- >
-
-
-
- ({message?.sourceDappAddress})
-
-
-
-
+ {/* Message information */}
+
+
+
-
}>
-
-
-
}>
-
-
-
}
- >
-
-
-
- ({message?.targetDappAddress})
-
-
-
-
-
}>
- {message?.ormp ?
: null}
-
+ {/* Protocol details (collapsible) */}
+ {message.accepted && (
+
-
-
+ )}
+
+ {/* Raw data (collapsible) */}
+ {(message.message || message.params) && (
+
+
+ )}
+
+
+
-
+
);
}
diff --git a/packages/web/src/app/message/[id]/components/sections/DetailHeader.tsx b/packages/web/src/app/message/[id]/components/sections/DetailHeader.tsx
new file mode 100644
index 0000000..721f15b
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/DetailHeader.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import Link from 'next/link';
+import { ArrowLeft } from 'lucide-react';
+import { useSearchParams } from 'next/navigation';
+
+import { toShortText } from '@/utils';
+import { CodeFont } from '@/config/font';
+import { cn } from '@/lib/utils';
+
+interface DetailHeaderProps {
+ msgId?: string;
+}
+
+export default function DetailHeader({ msgId }: DetailHeaderProps) {
+ const searchParams = useSearchParams();
+ const network = searchParams.get('network');
+ const backHref = network ? `/?network=${network}` : '/';
+
+ return (
+
+
+
+ Back to Messages
+
+
+
Message Detail
+ {msgId && (
+
+ {toShortText({ text: msgId, frontLength: 10, backLength: 4 })}
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/EventEvidencePanel.tsx b/packages/web/src/app/message/[id]/components/sections/EventEvidencePanel.tsx
new file mode 100644
index 0000000..441368f
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/EventEvidencePanel.tsx
@@ -0,0 +1,77 @@
+import { cn } from '@/lib/utils';
+
+import TransactionHashInfo from '../TransactionHashInfo';
+
+import { OBSERVED_EVENT_STATE_META, buildObservedEvidence } from './observed-events-model';
+
+import type { CHAIN } from '@/types/chains';
+import type { CompositeMessage } from '@/types/messages';
+
+interface EventEvidencePanelProps {
+ message: CompositeMessage;
+ sourceChain?: CHAIN;
+ targetChain?: CHAIN;
+}
+
+const UNKNOWN_TONE_META = {
+ label: 'Unknown',
+ evidencePillClass: 'bg-muted text-muted-foreground'
+};
+
+export default function EventEvidencePanel({
+ message,
+ sourceChain,
+ targetChain,
+}: EventEvidencePanelProps) {
+ const items = buildObservedEvidence(message, sourceChain, targetChain);
+
+ return (
+
+ {items.map((item) => {
+ const toneMeta =
+ OBSERVED_EVENT_STATE_META[
+ item.tone as keyof typeof OBSERVED_EVENT_STATE_META
+ ] ?? UNKNOWN_TONE_META;
+
+ return (
+
+
+ {item.label}
+
+ {toneMeta.label}
+
+
+
+
+ Time
+ {item.time}
+
+
+
Tx
+
+ {item.hash ? (
+
+ ) : (
+ Not observed
+ )}
+
+
+
+ Rule
+ {item.rule}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/MessageInfoSection.tsx b/packages/web/src/app/message/[id]/components/sections/MessageInfoSection.tsx
new file mode 100644
index 0000000..fc5adb8
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/MessageInfoSection.tsx
@@ -0,0 +1,87 @@
+import { cn } from '@/lib/utils';
+import { CodeFont } from '@/config/font';
+import { toShortText } from '@/utils';
+import ClipboardIconButton from '@/components/clipboard-icon-button';
+import AddressDisplayFilterDappRemark from '@/components/address-display-filter-dapp-remark';
+
+import AddressInfo from '../AddressInfo';
+
+import { SectionLabel, DetailRow } from './shared';
+
+import type { CHAIN } from '@/types/chains';
+import type { CompositeMessage } from '@/types/messages';
+
+interface MessageInfoSectionProps {
+ message: CompositeMessage;
+ sourceChain?: CHAIN;
+ targetChain?: CHAIN;
+ acceptedTargetChain?: CHAIN;
+}
+
+function DappDisplay({ address, chain }: { address?: string; chain?: CHAIN }) {
+ if (!address) return
- ;
+
+ return (
+
+
+ toShortText({ text: a, frontLength: 6, backLength: 4 })}
+ >
+ ({toShortText({ text: address, frontLength: 6, backLength: 4 })})
+
+
+
+ );
+}
+
+export default function MessageInfoSection({
+ message,
+ sourceChain,
+ targetChain,
+ acceptedTargetChain,
+}: MessageInfoSectionProps) {
+ return (
+
+
Message Info
+
+
+
+ {/* Message ID — full width */}
+
+
+
+ {toShortText({ text: message.msgId, frontLength: 10, backLength: 4 })}
+
+
+
+
+
+ {/* Source Dapp | Target Dapp */}
+
+
+
+
+
+
+
+
+ {/* Source Port | Target Port */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/OverviewPanel.tsx b/packages/web/src/app/message/[id]/components/sections/OverviewPanel.tsx
new file mode 100644
index 0000000..04a0fca
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/OverviewPanel.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useId, useState } from 'react';
+import { ChevronDown } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+import StepProgressBar, { computeObservedSummary } from './StepProgressBar';
+import QuickInfoBar from './QuickInfoBar';
+import EventEvidencePanel from './EventEvidencePanel';
+
+import type { CHAIN } from '@/types/chains';
+import type { CompositeMessage } from '@/types/messages';
+
+interface OverviewPanelProps {
+ message: CompositeMessage;
+ sourceChain?: CHAIN;
+ targetChain?: CHAIN;
+}
+
+const summaryPillStyles = {
+ neutral: 'border-info/20 bg-info/15 text-info',
+ success: 'border-success/20 bg-success/15 text-success',
+ failure: 'border-failure/20 bg-failure/15 text-failure',
+} as const;
+
+export default function OverviewPanel({ message, sourceChain, targetChain }: OverviewPanelProps) {
+ const summary = computeObservedSummary(message);
+ const [showEvidence, setShowEvidence] = useState(false);
+ const evidencePanelId = useId();
+
+ return (
+
+ {/* Header */}
+
+
+ Observed Events
+
+
+ {summary.label}
+
+
+
+ {/* Progress bar */}
+
+
+
+ Indexed event evidence
+
+
setShowEvidence((prev) => !prev)}
+ aria-expanded={showEvidence}
+ aria-controls={evidencePanelId}
+ className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-muted/60 px-2 py-1 text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted"
+ >
+ {showEvidence ? 'Hide evidence' : 'View evidence'}
+
+
+
+
+ {showEvidence && (
+
+
+
+ )}
+
+
+ {/* Quick info */}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/ProtocolDetailsSection.tsx b/packages/web/src/app/message/[id]/components/sections/ProtocolDetailsSection.tsx
new file mode 100644
index 0000000..b292409
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/ProtocolDetailsSection.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { Layers, ChevronDown } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+import { CodeFont } from '@/config/font';
+import { toShortText } from '@/utils';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
+import ClipboardIconButton from '@/components/clipboard-icon-button';
+
+import AddressInfo from '../AddressInfo';
+
+import { SectionLabel, DetailRow } from './shared';
+
+import type { CHAIN } from '@/types/chains';
+import type { ORMPMessageAccepted } from '@/graphql/type';
+
+interface ProtocolDetailsSectionProps {
+ accepted: ORMPMessageAccepted;
+ targetChain?: CHAIN;
+}
+
+export default function ProtocolDetailsSection({
+ accepted,
+ targetChain,
+}: ProtocolDetailsSectionProps) {
+ return (
+
+
Protocol Details
+
+
+
+
+
+ ORMP Details
+
+
+
+
+
+
+
+
+ {toShortText({ text: accepted.msgHash, frontLength: 6, backLength: 4 }) || '-'}
+
+ {accepted.msgHash && }
+
+
+
+
+
+ {accepted.index ? Number(accepted.index).toLocaleString() : '-'}
+
+
+
+
+
+ {accepted.fromChainId || '-'}
+
+
+
+
+
+ {accepted.toChainId || '-'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/QuickInfoBar.test.tsx b/packages/web/src/app/message/[id]/components/sections/QuickInfoBar.test.tsx
new file mode 100644
index 0000000..eb9b0e0
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/QuickInfoBar.test.tsx
@@ -0,0 +1,116 @@
+/** @vitest-environment jsdom */
+
+import { act, cleanup, render, screen } from '@testing-library/react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { MESSAGE_STATUS } from '@/types/message';
+
+import QuickInfoBar from './QuickInfoBar';
+
+import type { CompositeMessage } from '@/types/messages';
+
+function createMessage(overrides: Partial
= {}): CompositeMessage {
+ return {
+ msgId: '0xmsg',
+ protocol: 'ormp',
+ status: MESSAGE_STATUS.PENDING,
+ transactionHash: '0xtx',
+ targetTransactionHash: undefined,
+ transactionFrom: '0xfrom',
+ fromChainId: '1',
+ toChainId: '2',
+ fromDapp: 'from',
+ toDapp: 'to',
+ portAddress: '0xport',
+ message: '0xdata',
+ params: '0x',
+ sentBlockTimestampSec: 1700000000,
+ dispatchedBlockTimestampSec: undefined,
+ accepted: undefined,
+ dispatched: undefined,
+ sent: {} as CompositeMessage['sent'],
+ ...overrides
+ };
+}
+
+describe('QuickInfoBar', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-02-14T12:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it('renders stable timestamp during server-side render', () => {
+ const message = createMessage({
+ sentBlockTimestampSec: 1739534400
+ });
+
+ const markup = renderToStaticMarkup( );
+
+ expect(markup).toContain('Updated');
+ expect(markup).toContain('2025-02-14 12:00:00 UTC');
+ });
+
+ it('prefers dispatched timestamp over accepted timestamp', () => {
+ const message = createMessage({
+ accepted: {
+ blockTimestamp: '1739534400000'
+ } as CompositeMessage['accepted'],
+ dispatched: {
+ blockTimestamp: '1739620800000'
+ } as CompositeMessage['dispatched']
+ });
+
+ const markup = renderToStaticMarkup( );
+
+ expect(markup).toContain('2025-02-15 12:00:00 UTC');
+ expect(markup).not.toContain('2025-02-14 12:00:00 UTC');
+ });
+
+ it('falls back to accepted timestamp when dispatched is unavailable', () => {
+ const message = createMessage({
+ accepted: {
+ blockTimestamp: '1739534400000'
+ } as CompositeMessage['accepted'],
+ dispatched: undefined
+ });
+
+ const markup = renderToStaticMarkup( );
+
+ expect(markup).toContain('2025-02-14 12:00:00 UTC');
+ });
+
+ it('upgrades stable timestamp to relative time after client mount', async () => {
+ const message = createMessage({
+ dispatched: {
+ blockTimestamp: String(new Date('2026-02-13T12:00:00.000Z').getTime())
+ } as CompositeMessage['dispatched']
+ });
+
+ render( );
+
+ await act(async () => {
+ vi.runOnlyPendingTimers();
+ await Promise.resolve();
+ });
+
+ expect(screen.queryByText(/ago$/)).not.toBeNull();
+ });
+
+ it('does not render updated field when timestamp is unavailable', () => {
+ const message = createMessage({
+ sentBlockTimestampSec: 0,
+ accepted: undefined,
+ dispatched: undefined
+ });
+
+ render( );
+
+ expect(screen.queryByText('Updated')).toBeNull();
+ });
+});
diff --git a/packages/web/src/app/message/[id]/components/sections/QuickInfoBar.tsx b/packages/web/src/app/message/[id]/components/sections/QuickInfoBar.tsx
new file mode 100644
index 0000000..e9e4aa8
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/QuickInfoBar.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import Image from 'next/image';
+import { useEffect, useState } from 'react';
+import { Check, Clock, XCircle } from 'lucide-react';
+
+import { formatTimeAgo, formatTimestampStable } from '@/utils/date';
+import { MESSAGE_STATUS } from '@/types/message';
+
+import type { CHAIN } from '@/types/chains';
+import type { CompositeMessage } from '@/types/messages';
+
+interface QuickInfoBarProps {
+ message: CompositeMessage;
+ sourceChain?: CHAIN;
+ targetChain?: CHAIN;
+}
+
+function resolveUpdatedTimestampSec(message: CompositeMessage) {
+ if (message.dispatched?.blockTimestamp) {
+ return String(Math.floor(Number(message.dispatched.blockTimestamp) / 1000));
+ }
+ if (message.accepted?.blockTimestamp) {
+ return String(Math.floor(Number(message.accepted.blockTimestamp) / 1000));
+ }
+ if (message.sentBlockTimestampSec) {
+ return String(message.sentBlockTimestampSec);
+ }
+ return '';
+}
+
+function QiSeparator() {
+ return
;
+}
+
+function QiLabel({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Status badge with icon */
+const statusStyles = {
+ [MESSAGE_STATUS.SUCCESS]: {
+ label: 'Success',
+ className: 'bg-success/15 text-success',
+ Icon: Check,
+ },
+ [MESSAGE_STATUS.PENDING]: {
+ label: 'Pending',
+ className: 'bg-info/15 text-info',
+ Icon: Clock,
+ },
+ [MESSAGE_STATUS.FAILED]: {
+ label: 'Failed',
+ className: 'bg-failure/15 text-failure',
+ Icon: XCircle,
+ },
+} as const;
+
+export default function QuickInfoBar({ message, sourceChain, targetChain }: QuickInfoBarProps) {
+ const [hydrated, setHydrated] = useState(false);
+ const updatedTimestampSec = resolveUpdatedTimestampSec(message);
+ const stableUpdated = updatedTimestampSec ? formatTimestampStable(updatedTimestampSec) : '';
+ const relativeUpdated = hydrated && updatedTimestampSec ? formatTimeAgo(updatedTimestampSec) : '';
+ const updatedDisplay = relativeUpdated || stableUpdated;
+
+ useEffect(() => {
+ setHydrated(true);
+ }, []);
+
+ const statusCfg =
+ typeof message.status !== 'undefined' && message.status !== -1
+ ? statusStyles[message.status as MESSAGE_STATUS]
+ : null;
+
+ return (
+
+ {/* Chain route capsule — flex-1 pushes remaining items to the right */}
+
+
+ {sourceChain?.iconUrl && (
+
+ )}
+ {sourceChain?.name ?? 'Unknown'}
+ →
+ {targetChain?.iconUrl && (
+
+ )}
+ {targetChain?.name ?? 'Unknown'}
+
+
+
+
+
+ {/* Status */}
+
+ Status
+ {statusCfg && (
+
+
+ {statusCfg.label}
+
+ )}
+
+
+ {updatedDisplay ? (
+ <>
+
+ {/* Updated */}
+
+ Updated
+ {updatedDisplay}
+
+
+ >
+ ) : (
+
+ )}
+
+ {/* Protocol */}
+
+ Protocol
+ {message.protocol?.toUpperCase() ?? '-'}
+
+
+ );
+}
+
diff --git a/packages/web/src/app/message/[id]/components/sections/RawDataSection.tsx b/packages/web/src/app/message/[id]/components/sections/RawDataSection.tsx
new file mode 100644
index 0000000..30b92fa
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/RawDataSection.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { Code, ChevronDown } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+import { CodeFont } from '@/config/font';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
+import ClipboardIconButton from '@/components/clipboard-icon-button';
+
+import { SectionLabel } from './shared';
+
+interface RawDataSectionProps {
+ message?: string;
+ params?: string;
+}
+
+function RawField({ label, value }: { label: string; value: string }) {
+ return (
+
+
+
+ {label}
+
+
+
+
+ {value}
+
+
+ );
+}
+
+export default function RawDataSection({ message, params }: RawDataSectionProps) {
+ if (!message && !params) return null;
+
+ return (
+
+
Raw Data
+
+
+
+
+
+ Payload & Params
+
+
+
+
+
+ {message && }
+ {params && }
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/StepProgressBar.test.ts b/packages/web/src/app/message/[id]/components/sections/StepProgressBar.test.ts
new file mode 100644
index 0000000..e65d6f0
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/StepProgressBar.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+
+import { MESSAGE_STATUS } from '@/types/message';
+
+import { buildObservedEvents, computeObservedSummary } from './StepProgressBar';
+
+import type { CompositeMessage } from '@/types/messages';
+
+function createMessage(overrides: Partial = {}): CompositeMessage {
+ return {
+ msgId: '0xmsg',
+ protocol: 'ormp',
+ status: MESSAGE_STATUS.PENDING,
+ transactionHash: '0xtx',
+ targetTransactionHash: undefined,
+ transactionFrom: '0xfrom',
+ fromChainId: '1',
+ toChainId: '2',
+ fromDapp: 'from',
+ toDapp: 'to',
+ portAddress: '0xport',
+ message: '0xdata',
+ params: '0x',
+ sentBlockTimestampSec: 1700000000,
+ dispatchedBlockTimestampSec: undefined,
+ accepted: undefined,
+ dispatched: undefined,
+ sent: {} as CompositeMessage['sent'],
+ ...overrides,
+ };
+}
+
+describe('buildObservedEvents', () => {
+ it('keeps outcome pending when dispatched exists but final status is not success', () => {
+ const message = createMessage({
+ dispatched: {} as CompositeMessage['dispatched'],
+ status: MESSAGE_STATUS.PENDING,
+ });
+
+ const events = buildObservedEvents(message);
+
+ expect(events[3]?.label).toBe('Outcome');
+ expect(events[3]?.state).toBe('pending');
+ });
+
+ it('marks outcome success only when final status is success', () => {
+ const message = createMessage({
+ dispatched: {} as CompositeMessage['dispatched'],
+ status: MESSAGE_STATUS.SUCCESS,
+ });
+
+ const events = buildObservedEvents(message);
+
+ expect(events[3]?.state).toBe('success');
+ });
+
+ it('marks accepted as inferred when accepted event is missing but dispatched exists', () => {
+ const message = createMessage({
+ dispatched: {} as CompositeMessage['dispatched'],
+ accepted: undefined,
+ status: MESSAGE_STATUS.PENDING,
+ });
+
+ const events = buildObservedEvents(message);
+
+ expect(events[1]?.label).toBe('Accepted');
+ expect(events[1]?.state).toBe('inferred');
+ });
+});
+
+describe('computeObservedSummary', () => {
+ it('returns success summary when message status is success', () => {
+ const message = createMessage({
+ status: MESSAGE_STATUS.SUCCESS,
+ dispatched: {} as CompositeMessage['dispatched'],
+ });
+
+ const summary = computeObservedSummary(message);
+
+ expect(summary.tone).toBe('success');
+ });
+
+ it('returns failure summary when message status is failed', () => {
+ const message = createMessage({
+ dispatched: {} as CompositeMessage['dispatched'],
+ status: MESSAGE_STATUS.FAILED,
+ });
+
+ const summary = computeObservedSummary(message);
+
+ expect(summary.tone).toBe('failure');
+ });
+});
diff --git a/packages/web/src/app/message/[id]/components/sections/StepProgressBar.tsx b/packages/web/src/app/message/[id]/components/sections/StepProgressBar.tsx
new file mode 100644
index 0000000..a288bf8
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/StepProgressBar.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { Check, CircleDashed, HelpCircle, X } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+import { OBSERVED_EVENT_STATE_META, buildObservedEvents } from './observed-events-model';
+
+import type { CompositeMessage } from '@/types/messages';
+
+export { buildObservedEvents, computeObservedSummary } from './observed-events-model';
+
+interface StepProgressBarProps {
+ message: CompositeMessage;
+}
+
+const UNKNOWN_STATE_META = {
+ label: 'Unknown',
+ stepDotClass: 'bg-step-incomplete text-muted-foreground'
+};
+
+export default function StepProgressBar({ message }: StepProgressBarProps) {
+ const events = buildObservedEvents(message);
+
+ return (
+
+ {events.map((event, index) => {
+ const isFailed = event.state === 'failed';
+ const isSuccess = event.state === 'success';
+ const isObserved = event.state === 'observed';
+ const isInferred = event.state === 'inferred';
+ const isPending = event.state === 'pending';
+ const stateMeta =
+ OBSERVED_EVENT_STATE_META[
+ event.state as keyof typeof OBSERVED_EVENT_STATE_META
+ ] ?? UNKNOWN_STATE_META;
+ const stateLabel = stateMeta.label;
+
+ return (
+
+ {index > 0 && (
+
+ )}
+
+
+ {(isSuccess || isObserved) && (
+
+ )}
+ {isFailed && }
+ {isInferred && }
+ {isPending && }
+
+
+ {event.label}
+
+
{stateLabel}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/TransactionSummary.tsx b/packages/web/src/app/message/[id]/components/sections/TransactionSummary.tsx
new file mode 100644
index 0000000..32c7c87
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/TransactionSummary.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { isValid } from 'date-fns';
+import { useEffect, useState } from 'react';
+
+import { cn } from '@/lib/utils';
+import { CodeFont } from '@/config/font';
+import { MESSAGE_STATUS } from '@/types/message';
+import { Badge } from '@/components/ui/badge';
+import { formatTimeAgo } from '@/utils/date';
+import { useNetworkFromQuery } from '@/hooks/useNetwork';
+
+import TransactionHashInfo from '../TransactionHashInfo';
+import AddressInfo from '../AddressInfo';
+
+import { SectionLabel, DetailRow } from './shared';
+
+import type { CHAIN } from '@/types/chains';
+import type { CompositeMessage } from '@/types/messages';
+
+interface TransactionSummaryProps {
+ message: CompositeMessage;
+ sourceChain?: CHAIN;
+ targetChain?: CHAIN;
+}
+
+function blockTimestampMsToString(value: string | undefined): string | null {
+ if (!value) return null;
+ try {
+ const ms = Number(value);
+ if (!Number.isFinite(ms)) return null;
+ const d = new Date(ms);
+ if (!isValid(d)) return null;
+ // Format as YYYY-MM-DD HH:mm:ss UTC
+ const yyyy = d.getUTCFullYear();
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(d.getUTCDate()).padStart(2, '0');
+ const hh = String(d.getUTCHours()).padStart(2, '0');
+ const min = String(d.getUTCMinutes()).padStart(2, '0');
+ const ss = String(d.getUTCSeconds()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
+ } catch {
+ return null;
+ }
+}
+
+function blockTimestampMsToSecString(value: string | undefined): string | null {
+ if (!value) return null;
+ const ms = Number(value);
+ if (!Number.isFinite(ms)) return null;
+ return String(Math.floor(ms / 1000));
+}
+
+export default function TransactionSummary({
+ message,
+ sourceChain,
+ targetChain,
+}: TransactionSummaryProps) {
+ const [hydrated, setHydrated] = useState(false);
+ const network = useNetworkFromQuery();
+
+ useEffect(() => {
+ setHydrated(true);
+ }, []);
+
+ const isPending =
+ message.status !== MESSAGE_STATUS.SUCCESS && message.status !== MESSAGE_STATUS.FAILED;
+ const isFailed = message.status === MESSAGE_STATUS.FAILED;
+
+ const sourceAbsoluteTime = blockTimestampMsToString(message.sent?.blockTimestamp);
+ const targetAbsoluteTime = blockTimestampMsToString(message.dispatched?.blockTimestamp);
+
+ const sourceSec = blockTimestampMsToSecString(message.sent?.blockTimestamp);
+ const targetSec = blockTimestampMsToSecString(message.dispatched?.blockTimestamp);
+
+ const sourceRelativeTime = hydrated && sourceSec ? formatTimeAgo(sourceSec) : null;
+ const targetRelativeTime = hydrated && targetSec ? formatTimeAgo(targetSec) : null;
+ const senderAddress = message.transactionFrom ?? undefined;
+ const senderHref = senderAddress
+ ? `/sender/${encodeURIComponent(senderAddress)}?network=${network}`
+ : undefined;
+
+ return (
+
+
Transaction Summary
+
+
+ {/* Source card */}
+
+
+
+ Source
+
+ {sourceChain?.name ?? 'Unknown'}
+
+
+
+
+
+
+
+ {message.sent?.blockNumber ? Number(message.sent.blockNumber).toLocaleString() : '-'}
+
+
+ {sourceAbsoluteTime && (
+
+
+ {sourceRelativeTime ?? sourceAbsoluteTime}
+
+
+ )}
+
+
+
+
+
+
+ {/* Target card */}
+
+
+
+ Target
+
+ {targetChain?.name ?? 'Unknown'}
+ {isFailed && (
+ Reverted
+ )}
+
+ {isPending ? (
+
+
+ Waiting for message dispatch
+
+
+ ) : (
+
+
+
+
+
+
+ {message.dispatched?.blockNumber ? Number(message.dispatched.blockNumber).toLocaleString() : '-'}
+
+
+ {targetAbsoluteTime && (
+
+
+ {targetRelativeTime ?? targetAbsoluteTime}
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/observed-events-model.ts b/packages/web/src/app/message/[id]/components/sections/observed-events-model.ts
new file mode 100644
index 0000000..f6c9a78
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/observed-events-model.ts
@@ -0,0 +1,224 @@
+import { formatTimeAgo } from '@/utils/date';
+import { MESSAGE_STATUS } from '@/types/message';
+
+import type { CHAIN } from '@/types/chains';
+import type { CompositeMessage } from '@/types/messages';
+
+export type ObservedEventState = 'observed' | 'inferred' | 'pending' | 'success' | 'failed';
+export type ObservedEventId = 'sent' | 'accepted' | 'dispatched' | 'outcome';
+
+interface ObservedEventStateMeta {
+ label: string;
+ stepDotClass: string;
+ evidencePillClass: string;
+}
+
+export const OBSERVED_EVENT_STATE_META: Record = {
+ observed: {
+ label: 'Observed',
+ stepDotClass: 'bg-step-1 text-white',
+ evidencePillClass: 'bg-step-1/15 text-step-1',
+ },
+ inferred: {
+ label: 'Inferred',
+ stepDotClass: 'bg-step-3 text-white',
+ evidencePillClass: 'bg-step-3/15 text-step-3',
+ },
+ pending: {
+ label: 'Pending',
+ stepDotClass: 'bg-step-incomplete text-muted-foreground',
+ evidencePillClass: 'bg-muted text-muted-foreground',
+ },
+ success: {
+ label: 'Success',
+ stepDotClass: 'bg-step-4-success text-white',
+ evidencePillClass: 'bg-success/15 text-success',
+ },
+ failed: {
+ label: 'Failed',
+ stepDotClass: 'bg-step-4-failed text-white',
+ evidencePillClass: 'bg-failure/15 text-failure',
+ },
+};
+
+export interface ObservedEvent {
+ id: ObservedEventId;
+ label: string;
+ subLabel: string;
+ state: ObservedEventState;
+}
+
+export type OverviewTone = 'neutral' | 'success' | 'failure';
+
+export interface EventEvidenceItem {
+ id: ObservedEventId;
+ label: string;
+ tone: ObservedEventState;
+ time: string;
+ hash?: string;
+ chain?: CHAIN;
+ rule: string;
+}
+
+interface ObservedSnapshot {
+ hasSent: boolean;
+ hasAccepted: boolean;
+ hasDispatched: boolean;
+ isFailed: boolean;
+ isSuccess: boolean;
+}
+
+function deriveObservedSnapshot(message: CompositeMessage): ObservedSnapshot {
+ return {
+ hasSent: Boolean(message.sent),
+ hasAccepted: Boolean(message.accepted),
+ hasDispatched: Boolean(message.dispatched),
+ isFailed: message.status === MESSAGE_STATUS.FAILED,
+ isSuccess: message.status === MESSAGE_STATUS.SUCCESS,
+ };
+}
+
+function msTimestampToAgo(timestamp?: string): string {
+ if (!timestamp) return 'Not observed';
+ const ms = Number(timestamp);
+ if (!Number.isFinite(ms)) return 'Not observed';
+ return formatTimeAgo(String(Math.floor(ms / 1000))) || 'Not observed';
+}
+
+function secTimestampToAgo(timestampSec?: number): string {
+ if (!timestampSec) return 'Not observed';
+ return formatTimeAgo(String(timestampSec)) || 'Not observed';
+}
+
+export function buildObservedEvents(message: CompositeMessage): ObservedEvent[] {
+ const { hasSent, hasAccepted, hasDispatched, isFailed, isSuccess } = deriveObservedSnapshot(message);
+
+ const acceptedState: ObservedEventState = hasAccepted
+ ? 'observed'
+ : hasDispatched
+ ? 'inferred'
+ : 'pending';
+ const acceptedSubLabel = hasAccepted
+ ? 'Accepted event observed'
+ : hasDispatched
+ ? 'Inferred from dispatched event'
+ : 'Waiting for accepted event';
+
+ const outcomeState: ObservedEventState = isSuccess ? 'success' : isFailed ? 'failed' : 'pending';
+ const outcomeSubLabel = isSuccess
+ ? 'Execution succeeded'
+ : isFailed
+ ? 'Execution failed'
+ : hasDispatched
+ ? 'Waiting for final result'
+ : 'Waiting for dispatch event';
+
+ return [
+ {
+ id: 'sent',
+ label: 'Sent',
+ subLabel: hasSent ? 'Sent event observed' : 'Waiting for sent event',
+ state: hasSent ? 'observed' : 'pending',
+ },
+ {
+ id: 'accepted',
+ label: 'Accepted',
+ subLabel: acceptedSubLabel,
+ state: acceptedState,
+ },
+ {
+ id: 'dispatched',
+ label: 'Dispatched',
+ subLabel: hasDispatched ? 'Dispatched event observed' : 'Waiting for dispatched event',
+ state: hasDispatched ? 'observed' : 'pending',
+ },
+ {
+ id: 'outcome',
+ label: 'Outcome',
+ subLabel: outcomeSubLabel,
+ state: outcomeState,
+ },
+ ];
+}
+
+export function computeObservedSummary(message: CompositeMessage): { label: string; tone: OverviewTone } {
+ const { hasSent, hasAccepted, hasDispatched, isFailed, isSuccess } = deriveObservedSnapshot(message);
+
+ if (isSuccess) {
+ return { label: 'Observed · Success', tone: 'success' };
+ }
+ if (isFailed) {
+ return { label: 'Observed · Failed', tone: 'failure' };
+ }
+ if (hasDispatched) {
+ return { label: 'Observed · Pending final result', tone: 'neutral' };
+ }
+ if (hasAccepted) {
+ return { label: 'Observed · In transit', tone: 'neutral' };
+ }
+ if (hasSent) {
+ return { label: 'Observed · Submitted', tone: 'neutral' };
+ }
+
+ return { label: 'Observed · Awaiting data', tone: 'neutral' };
+}
+
+export function buildObservedEvidence(
+ message: CompositeMessage,
+ sourceChain?: CHAIN,
+ targetChain?: CHAIN
+): EventEvidenceItem[] {
+ const { hasSent, hasAccepted, hasDispatched, isFailed, isSuccess } = deriveObservedSnapshot(message);
+ const inferredAcceptedHash = message.targetTransactionHash ?? message.dispatched?.transactionHash;
+
+ return [
+ {
+ id: 'sent',
+ label: 'Sent',
+ tone: hasSent ? 'observed' : 'pending',
+ time: secTimestampToAgo(message.sentBlockTimestampSec),
+ hash: message.transactionHash,
+ chain: sourceChain,
+ rule: hasSent ? 'Observed from MsgportMessageSent' : 'Awaiting MsgportMessageSent event',
+ },
+ {
+ id: 'accepted',
+ label: 'Accepted',
+ tone: hasAccepted ? 'observed' : hasDispatched ? 'inferred' : 'pending',
+ time: hasAccepted
+ ? msTimestampToAgo(message.accepted?.blockTimestamp)
+ : hasDispatched
+ ? msTimestampToAgo(message.dispatched?.blockTimestamp)
+ : 'Not observed',
+ hash: hasAccepted ? message.accepted?.transactionHash : inferredAcceptedHash,
+ chain: targetChain,
+ rule: hasAccepted
+ ? 'Observed from ORMPMessageAccepted'
+ : hasDispatched
+ ? 'Inferred from ORMPMessageDispatched (accepted event not indexed)'
+ : 'Awaiting ORMPMessageAccepted event',
+ },
+ {
+ id: 'dispatched',
+ label: 'Dispatched',
+ tone: hasDispatched ? 'observed' : 'pending',
+ time: msTimestampToAgo(message.dispatched?.blockTimestamp),
+ hash: message.targetTransactionHash ?? message.dispatched?.transactionHash,
+ chain: targetChain,
+ rule: hasDispatched
+ ? 'Observed from ORMPMessageDispatched'
+ : 'Awaiting ORMPMessageDispatched event',
+ },
+ {
+ id: 'outcome',
+ label: 'Outcome',
+ tone: isSuccess ? 'success' : isFailed ? 'failed' : 'pending',
+ time: hasDispatched ? msTimestampToAgo(message.dispatched?.blockTimestamp) : 'Not observed',
+ hash: message.targetTransactionHash ?? message.dispatched?.transactionHash,
+ chain: targetChain,
+ rule: hasDispatched
+ ? 'Derived from dispatchResult on ORMPMessageDispatched'
+ : 'Awaiting dispatchResult from ORMPMessageDispatched',
+ },
+ ];
+}
diff --git a/packages/web/src/app/message/[id]/components/sections/shared.tsx b/packages/web/src/app/message/[id]/components/sections/shared.tsx
new file mode 100644
index 0000000..39231bb
--- /dev/null
+++ b/packages/web/src/app/message/[id]/components/sections/shared.tsx
@@ -0,0 +1,42 @@
+import { cn } from '@/lib/utils';
+
+interface SectionLabelProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function SectionLabel({ className, children }: SectionLabelProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface DetailRowProps {
+ label: string;
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function DetailRow({ label, className, children }: DetailRowProps) {
+ return (
+
+
{label}
+
+ {children}
+
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/error.test.tsx b/packages/web/src/app/message/[id]/error.test.tsx
new file mode 100644
index 0000000..261569d
--- /dev/null
+++ b/packages/web/src/app/message/[id]/error.test.tsx
@@ -0,0 +1,32 @@
+/** @vitest-environment jsdom */
+
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import MessageDetailError from './error';
+
+vi.mock('@/components/error-display', () => ({
+ default: (props: { title: string; description: string }) => (
+
+ {props.title}
+ {props.description}
+
+ )
+}));
+
+describe('message detail route error boundary', () => {
+ it('renders and supports retry through reset', () => {
+ const reset = vi.fn();
+
+ render(
+
+ );
+
+ expect(screen.queryByTestId('error-display')).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/web/src/app/message/[id]/error.tsx b/packages/web/src/app/message/[id]/error.tsx
new file mode 100644
index 0000000..cff7999
--- /dev/null
+++ b/packages/web/src/app/message/[id]/error.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import ErrorDisplay from '@/components/error-display';
+import { Button } from '@/components/ui/button';
+
+interface MessageDetailErrorProps {
+ error: Error & { digest?: string };
+ reset: () => void;
+}
+
+export default function MessageDetailError({ reset }: MessageDetailErrorProps) {
+ return (
+
+ );
+}
diff --git a/packages/web/src/app/message/[id]/loading.test.tsx b/packages/web/src/app/message/[id]/loading.test.tsx
new file mode 100644
index 0000000..1ddb1af
--- /dev/null
+++ b/packages/web/src/app/message/[id]/loading.test.tsx
@@ -0,0 +1,16 @@
+/** @vitest-environment jsdom */
+
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import LoadingPage from './loading';
+
+describe('message detail route loading', () => {
+ it('renders detail skeleton layout', () => {
+ render( );
+
+ expect(document.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0);
+ expect(screen.queryByRole('heading', { name: 'Searching message' })).toBeNull();
+ expect(screen.queryByText('Preparing detail view for this message...')).toBeNull();
+ });
+});
diff --git a/packages/web/src/app/message/[id]/loading.tsx b/packages/web/src/app/message/[id]/loading.tsx
new file mode 100644
index 0000000..77cd538
--- /dev/null
+++ b/packages/web/src/app/message/[id]/loading.tsx
@@ -0,0 +1,95 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+const Page = () => {
+ return (
+
+ {/* DetailHeader */}
+
+
+
+
+
+ {/* OverviewPanel */}
+
+
+
+
+
+
+
+
+
+
+ {/* StepProgressBar skeleton — matches flex-nowrap with connector lines */}
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/* TransactionSummary */}
+
+
+
+ {[0, 1].map((i) => (
+
+
+
+
+
+
+ {[0, 1, 2].map((j) => (
+
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* MessageInfoSection */}
+
+
+
+ {/* Grid matching sm:grid-cols-2 — Message ID full width, then pairs */}
+
+ {/* Message ID — full width */}
+
+
+
+
+ {/* Source Dapp / Target Dapp */}
+ {[0, 1].map((i) => (
+
+
+
+
+ ))}
+ {/* Source Port / Target Port */}
+ {[0, 1].map((i) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Page;
diff --git a/packages/web/src/app/message/[id]/network-policy.test.ts b/packages/web/src/app/message/[id]/network-policy.test.ts
new file mode 100644
index 0000000..4d0128d
--- /dev/null
+++ b/packages/web/src/app/message/[id]/network-policy.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+
+import { Network } from '@/types/network';
+
+import { resolveDetailNetworkPolicy } from './network-policy';
+
+describe('resolveDetailNetworkPolicy', () => {
+ it('treats missing network as default mainnet and missing param', () => {
+ const result = resolveDetailNetworkPolicy(undefined);
+
+ expect(result).toEqual({
+ hasNetworkParam: false,
+ isValidNetworkParam: false,
+ effectiveNetwork: Network.MAINNET
+ });
+ });
+
+ it('accepts normalized valid network', () => {
+ const result = resolveDetailNetworkPolicy(' testnet ');
+
+ expect(result).toEqual({
+ hasNetworkParam: true,
+ isValidNetworkParam: true,
+ effectiveNetwork: Network.TESTNET
+ });
+ });
+
+ it('treats invalid network as explicit invalid and fallback to mainnet', () => {
+ const result = resolveDetailNetworkPolicy('foo');
+
+ expect(result).toEqual({
+ hasNetworkParam: true,
+ isValidNetworkParam: false,
+ effectiveNetwork: Network.MAINNET
+ });
+ });
+
+ it('treats empty network value as invalid explicit parameter', () => {
+ const result = resolveDetailNetworkPolicy('');
+
+ expect(result).toEqual({
+ hasNetworkParam: true,
+ isValidNetworkParam: false,
+ effectiveNetwork: Network.MAINNET
+ });
+ });
+});
+
diff --git a/packages/web/src/app/message/[id]/network-policy.ts b/packages/web/src/app/message/[id]/network-policy.ts
new file mode 100644
index 0000000..8215af0
--- /dev/null
+++ b/packages/web/src/app/message/[id]/network-policy.ts
@@ -0,0 +1,23 @@
+import { defaultNetwork, networkList } from '@/config/network';
+import { normalizeNetwork } from '@/utils/network';
+
+import type { Network } from '@/types/network';
+
+export interface DetailNetworkPolicy {
+ hasNetworkParam: boolean;
+ isValidNetworkParam: boolean;
+ effectiveNetwork: Network;
+}
+
+export function resolveDetailNetworkPolicy(network?: string): DetailNetworkPolicy {
+ const normalizedNetwork = normalizeNetwork(network);
+ const hasNetworkParam = typeof network !== 'undefined';
+ const isValidNetworkParam =
+ typeof normalizedNetwork !== 'undefined' && networkList.includes(normalizedNetwork as Network);
+
+ return {
+ hasNetworkParam,
+ isValidNetworkParam,
+ effectiveNetwork: isValidNetworkParam ? (normalizedNetwork as Network) : defaultNetwork
+ };
+}
diff --git a/packages/web/src/app/message/[id]/page.tsx b/packages/web/src/app/message/[id]/page.tsx
index 0883af2..29bd422 100644
--- a/packages/web/src/app/message/[id]/page.tsx
+++ b/packages/web/src/app/message/[id]/page.tsx
@@ -1,31 +1,51 @@
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
+import { redirect } from 'next/navigation';
-import { fetchMessage } from '@/graphql/services';
+import { fetchMessageDetail } from '@/graphql/services';
+import { Network } from '@/types/network';
+import { defaultNetwork } from '@/config/network';
import { getChainsByNetwork } from '@/utils/network';
import ClientPage from './components/ClientPage';
+import { resolveDetailNetworkPolicy } from './network-policy';
interface PageProps {
- params: {
+ params: Promise<{
id: string;
- };
- searchParams: {
- network: string;
- };
+ }>;
+ searchParams: Promise<{
+ network?: string;
+ }>;
}
export default async function Page({ params, searchParams }: PageProps) {
- const chains = getChainsByNetwork(searchParams?.network);
+ const [{ id }, { network }] = await Promise.all([params, searchParams]);
+ const { hasNetworkParam, isValidNetworkParam, effectiveNetwork } = resolveDetailNetworkPolicy(network);
+
+ if (hasNetworkParam && !isValidNetworkParam) {
+ redirect(`/message/${encodeURIComponent(id)}?network=${defaultNetwork}`);
+ }
+
+ const testnetChains = getChainsByNetwork('testnet');
+ const scopedChains = getChainsByNetwork(effectiveNetwork);
const queryClient = new QueryClient();
+ const message = await fetchMessageDetail(id, scopedChains);
+
+ // Keep default-network behavior (mainnet first), but fallback to testnet when
+ // network param is absent and the message is not found on mainnet.
+ if (!hasNetworkParam && effectiveNetwork === Network.MAINNET && !message) {
+ const fallbackMessage = await fetchMessageDetail(id, testnetChains);
+ if (fallbackMessage) {
+ redirect(`/message/${encodeURIComponent(id)}?network=${Network.TESTNET}`);
+ }
+ }
- await queryClient.prefetchQuery({
- queryKey: ['message', params.id, chains],
- queryFn: async () => fetchMessage(params.id, chains)
- });
+ const chainKey = scopedChains.map((chain) => chain.id).join(',') || 'all';
+ queryClient.setQueryData(['message', id, chainKey], message);
return (
-
+
);
}
diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx
index 15a25fa..479845d 100644
--- a/packages/web/src/app/not-found.tsx
+++ b/packages/web/src/app/not-found.tsx
@@ -1,9 +1,12 @@
+import Link from 'next/link';
+
import ErrorDisplay from '@/components/error-display';
+import { buttonVariants } from '@/components/ui/button-variants';
const NotFound = () => {
return (
{
title="404 Page not found"
svgPath="/images/common/404.svg"
svgPathLight="/images/common/404-light.svg"
- description="The resource reguested could not be found on this server!"
+ description="This page doesn't exist."
/>
+
+ Back Home
+
);
};
diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx
index 40a5538..7ef744f 100644
--- a/packages/web/src/app/page.tsx
+++ b/packages/web/src/app/page.tsx
@@ -1,24 +1,22 @@
-import { getChainsByNetwork } from '@/utils/network';
-import SearchBar from '@/components/search-bar';
-import { Separator } from '@/components/ui/separator';
+import { getChainsByNetwork, getNetwork } from '@/utils/network';
import MessagePortTable from '@/components/message-port-table';
import MessageProgressStats from '@/components/message-progress-stats';
+import ChartContainer from '@/components/charts/chart-container';
interface PageProps {
- searchParams: {
- network: string;
- };
+ searchParams: Promise<{
+ network?: string;
+ }>;
}
-export default function Page({ searchParams }: PageProps) {
- const chains = getChainsByNetwork(searchParams?.network);
+export default async function Page({ searchParams }: PageProps) {
+ const { network } = await searchParams;
+ const effectiveNetwork = getNetwork(network);
+ const chains = getChainsByNetwork(effectiveNetwork);
return (
- <>
-
-
-
+
-
-
- >
+
+
+
);
}
diff --git a/packages/web/src/app/sender/[address]/page.tsx b/packages/web/src/app/sender/[address]/page.tsx
index 256a5d6..247cfc1 100644
--- a/packages/web/src/app/sender/[address]/page.tsx
+++ b/packages/web/src/app/sender/[address]/page.tsx
@@ -1,37 +1,48 @@
-'use client';
+import Link from 'next/link';
+import { ChevronRight } from 'lucide-react';
-import SearchBar from '@/components/search-bar';
-import { Separator } from '@/components/ui/separator';
-import { FlipWords } from '@/components/ui/flip-words';
-import { getChainsByNetwork } from '@/utils/network';
+import { getChainsByNetwork, getNetwork } from '@/utils/network';
import MessagePortTable from '@/components/message-port-table';
+import ClipboardIconButton from '@/components/clipboard-icon-button';
import { CodeFont } from '@/config/font';
import { cn } from '@/lib/utils';
interface PageProps {
- params: {
+ params: Promise<{
address: string;
- };
- searchParams: {
- network: string;
- };
+ }>;
+ searchParams: Promise<{
+ network?: string;
+ }>;
}
-export default function Page({ params, searchParams }: PageProps) {
- const chains = getChainsByNetwork(searchParams?.network);
- const words = [params?.address];
+export default async function Page({ params, searchParams }: PageProps) {
+ const [{ address }, { network }] = await Promise.all([params, searchParams]);
+ const effectiveNetwork = getNetwork(network);
+ const chains = getChainsByNetwork(effectiveNetwork);
+
return (
- <>
-
-
-
-
-
Address
-
+
+ {/* Breadcrumb */}
+
+
+ Messages
+
+
+ Sender
+
+
+ {/* Address header */}
+
+
Sender Address
+
+
+ {address}
+
+
+
-
-
- >
+
+
+
);
}
diff --git a/packages/web/src/app/sw.ts b/packages/web/src/app/sw.ts
deleted file mode 100644
index 2a47239..0000000
--- a/packages/web/src/app/sw.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { defaultCache } from '@serwist/next/worker';
-import { Serwist } from 'serwist';
-
-import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
-
-// This declares the value of `injectionPoint` to TypeScript.
-// `injectionPoint` is the string that will be replaced by the
-// actual precache manifest. By default, this string is set to
-// `"self.__SW_MANIFEST"`.
-declare global {
- interface WorkerGlobalScope extends SerwistGlobalConfig {
- __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
- }
-}
-
-declare const self: ServiceWorkerGlobalScope;
-
-const serwist = new Serwist({
- precacheEntries: self.__SW_MANIFEST,
- skipWaiting: true,
- clientsClaim: true,
- navigationPreload: true,
- runtimeCaching: defaultCache
-});
-
-serwist.addEventListeners();
diff --git a/packages/web/src/components/address-display-filter-dapp-remark.tsx b/packages/web/src/components/address-display-filter-dapp-remark.tsx
index 979be3f..d62c7ea 100644
--- a/packages/web/src/components/address-display-filter-dapp-remark.tsx
+++ b/packages/web/src/components/address-display-filter-dapp-remark.tsx
@@ -1,9 +1,8 @@
import React from 'react';
import Image from 'next/image';
-import { capitalize } from 'lodash-es';
import { dappRemark } from '@/config/dapp_remark';
-import { getDAppInfo } from '@/utils/dapp';
+import { capitalizeText, getDAppInfo } from '@/utils/dapp';
import { cn } from '@/lib/utils';
interface AddressDisplayFilterDappRemarkProps {
@@ -25,11 +24,11 @@ const AddressDisplayFilterDappRemark = ({
const displayAddress = formatAddress ? formatAddress(address) : address;
return dappName && dappLogo ? (
-
-
- {capitalize(dappName)}
+
+
+ {capitalizeText(dappName)}
{children}
-
+
) : (
displayAddress
);
diff --git a/packages/web/src/components/chain-tx-display.tsx b/packages/web/src/components/chain-tx-display.tsx
index 224362a..1ebd58d 100644
--- a/packages/web/src/components/chain-tx-display.tsx
+++ b/packages/web/src/components/chain-tx-display.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; // 假设shadcn已经提供了Tooltip组件
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { toShortText } from '@/utils';
import { CodeFont } from '@/config/font';
@@ -15,6 +15,7 @@ interface ChainTxDisplayProps {
isLink?: boolean;
href?: string;
isFullText?: boolean;
+ showIcon?: boolean;
rootClassName?: string;
className?: string;
iconClassName?: string;
@@ -26,16 +27,18 @@ const ChainTxDisplay = ({
isLink = true,
href,
isFullText = false,
+ showIcon = true,
rootClassName,
className,
iconClassName,
children
}: React.PropsWithChildren) => {
const renderContent = () => {
- let txLink = `${chain?.blockExplorers?.default?.url}/tx/${value}`;
+ const explorerBaseUrl = chain?.blockExplorers?.default?.url;
+ let txLink = explorerBaseUrl ? `${explorerBaseUrl}/tx/${value}` : undefined;
- if (chain?.name.includes("Tron")) {
- txLink = `${chain?.blockExplorers?.default?.url}/#/transaction/${value?.replace('0x', '')}`
+ if (chain?.name?.includes('Tron') && explorerBaseUrl) {
+ txLink = `${explorerBaseUrl}/#/transaction/${value?.replace('0x', '')}`;
}
if (isLink) {
if (href) {
@@ -55,23 +58,25 @@ const ChainTxDisplay = ({
);
}
- return (
-
- {isFullText
- ? value
- : toShortText({
- text: value,
- frontLength: 6,
- backLength: 4
- })}
-
- );
+ if (txLink) {
+ return (
+
+ {isFullText
+ ? value
+ : toShortText({
+ text: value,
+ frontLength: 6,
+ backLength: 4
+ })}
+
+ );
+ }
} else {
return (
@@ -81,21 +86,33 @@ const ChainTxDisplay = ({
text: value,
frontLength: 6,
backLength: 4
- })}
+ })}
);
}
+
+ return (
+
+ {isFullText
+ ? value
+ : toShortText({
+ text: value,
+ frontLength: 6,
+ backLength: 4
+ })}
+
+ );
};
return (
-
- {chain ? (
+
+ {showIcon && chain ? (
-
+ }>
diff --git a/packages/web/src/components/charts/chain-distribution-chart.tsx b/packages/web/src/components/charts/chain-distribution-chart.tsx
new file mode 100644
index 0000000..488e336
--- /dev/null
+++ b/packages/web/src/components/charts/chain-distribution-chart.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ LabelList,
+ Rectangle,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis
+} from 'recharts';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+import { formatNumber } from '@/utils';
+
+import type { ChainDistributionItem, ChainDirection } from '@/hooks/use-chain-distribution';
+
+interface ChainDistributionChartProps {
+ data?: ChainDistributionItem[];
+ targetData?: ChainDistributionItem[];
+ isLoading?: boolean;
+ isTargetLoading?: boolean;
+}
+
+function renderActiveBar(props: {
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+ fill?: string;
+}) {
+ const x = props.x ?? 0;
+ const y = props.y ?? 0;
+ const width = props.width ?? 0;
+ const height = props.height ?? 0;
+
+ // Keep hover feedback subtle to avoid layout jitter in compact cards.
+ return (
+
+ );
+}
+
+export default function ChainDistributionChart({
+ data,
+ targetData,
+ isLoading,
+ isTargetLoading
+}: ChainDistributionChartProps) {
+ const MAX_VISIBLE_CHAINS = 5;
+ const [direction, setDirection] = useState('source');
+ const [isMounted, setIsMounted] = useState(false);
+
+ const activeData = direction === 'source' ? data : targetData;
+ const activeLoading = direction === 'source' ? isLoading : isTargetLoading;
+ const chartData = (activeData ?? []).slice(0, MAX_VISIBLE_CHAINS);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ if (activeLoading) {
+ return (
+
+
+ Chain Distribution
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ setDirection('source')}
+ aria-pressed={direction === 'source'}
+ className={cn(
+ 'rounded px-3 py-1 text-xs font-medium transition-colors cursor-pointer',
+ direction === 'source'
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+ Source Chain
+
+ setDirection('target')}
+ aria-pressed={direction === 'target'}
+ className={cn(
+ 'rounded px-3 py-1 text-xs font-medium transition-colors cursor-pointer',
+ direction === 'target'
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+ Target Chain
+
+
+
+
+ {chartData.length === 0 ? (
+
+ No data available
+
+ ) : (
+
+ {isMounted ? (
+
+
+
+ formatNumber(Number(v))}
+ />
+
+ [formatNumber(Number(value)), 'Messages']}
+ />
+
+ formatNumber(Number(v))}
+ />
+
+
+
+ ) : null}
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/src/components/charts/chart-container.tsx b/packages/web/src/components/charts/chart-container.tsx
new file mode 100644
index 0000000..27c1747
--- /dev/null
+++ b/packages/web/src/components/charts/chart-container.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { useMessageTrend } from '@/hooks/use-message-trend';
+import { useChainDistribution } from '@/hooks/use-chain-distribution';
+
+import MessageTrendChart from './message-trend-chart';
+import ChainDistributionChart from './chain-distribution-chart';
+
+import type { CHAIN } from '@/types/chains';
+
+interface ChartContainerProps {
+ chains: CHAIN[];
+}
+
+export default function ChartContainer({ chains }: ChartContainerProps) {
+ const { data: trendData, isLoading: trendLoading } = useMessageTrend(chains);
+ const { data: distributionData, isLoading: distributionLoading } = useChainDistribution(chains);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/charts/message-trend-chart.tsx b/packages/web/src/components/charts/message-trend-chart.tsx
new file mode 100644
index 0000000..ce61a1c
--- /dev/null
+++ b/packages/web/src/components/charts/message-trend-chart.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis
+} from 'recharts';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+
+import type { TrendDataPoint } from '@/hooks/use-message-trend';
+
+interface MessageTrendChartProps {
+ data?: TrendDataPoint[];
+ isLoading?: boolean;
+}
+
+function ChartLegend() {
+ return (
+
+
+
+ Total
+
+
+ );
+}
+
+function computeYTicks(data: TrendDataPoint[] | undefined): { domain: [number, number]; ticks: number[] } {
+ const maxCount = data && data.length > 0 ? Math.max(...data.map((d) => d.count), 0) : 0;
+ const yMax = Math.max(Math.ceil(maxCount * 1.25), 4);
+ const step = Math.ceil(yMax / 4);
+ const ticks = Array.from({ length: 5 }, (_, i) => i * step);
+ return { domain: [0, ticks[4]], ticks };
+}
+
+export default function MessageTrendChart({ data, isLoading }: MessageTrendChartProps) {
+ const [isMounted, setIsMounted] = useState(false);
+ const hasData = Boolean(data && data.length > 0);
+ const { domain, ticks } = computeYTicks(data);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ if (isLoading) {
+ return (
+
+
+ Message Volume (7 days)
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Message Volume (7 days)
+ {hasData ? : null}
+
+
+
+
+ {hasData ? (
+ isMounted ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null
+ ) : (
+
+ No data available
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/web/src/components/clipboard-icon-button.test.tsx b/packages/web/src/components/clipboard-icon-button.test.tsx
new file mode 100644
index 0000000..a2af861
--- /dev/null
+++ b/packages/web/src/components/clipboard-icon-button.test.tsx
@@ -0,0 +1,91 @@
+/** @vitest-environment jsdom */
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import ClipboardIconButton from './clipboard-icon-button';
+
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+
+const { toastSuccessMock, toastErrorMock } = vi.hoisted(() => ({
+ toastSuccessMock: vi.fn(),
+ toastErrorMock: vi.fn()
+}));
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: toastSuccessMock,
+ error: toastErrorMock
+ }
+}));
+
+vi.mock('./ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => {children}
,
+ TooltipTrigger: ({
+ children,
+ ...props
+ }: {
+ children: ReactNode;
+ } & ButtonHTMLAttributes) => {children} ,
+ TooltipContent: ({ children }: { children: ReactNode }) => {children}
+}));
+
+describe('ClipboardIconButton', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ toastSuccessMock.mockReset();
+ toastErrorMock.mockReset();
+
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
+ configurable: true
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing when text is empty', () => {
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('copies text and resets copied state after timeout', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith('0xabc');
+ expect(toastSuccessMock).toHaveBeenCalledWith('Copied!');
+ expect(screen.queryByText('Copied!')).not.toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(1_000);
+ });
+
+ expect(screen.queryByText('Copy to clipboard')).not.toBeNull();
+ });
+
+ it('clears copied reset timer on unmount', async () => {
+ const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
+ const { unmount } = render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ unmount();
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/src/components/clipboard-icon-button.tsx b/packages/web/src/components/clipboard-icon-button.tsx
index 32f3da2..b864b4c 100644
--- a/packages/web/src/components/clipboard-icon-button.tsx
+++ b/packages/web/src/components/clipboard-icon-button.tsx
@@ -1,6 +1,8 @@
+'use client';
+
import { Copy, Check } from 'lucide-react';
import { useCallback, useEffect, useState, useRef } from 'react';
-import { useCopyToClipboard } from 'react-use';
+import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -12,25 +14,31 @@ interface ClipboardIconButtonProps {
}
const ClipboardIconButton = ({ text = '', size }: ClipboardIconButtonProps) => {
- const [state, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(false);
- const enterTimeout = useRef();
- const leaveTimeout = useRef();
+ const enterTimeout = useRef | undefined>(undefined);
+ const leaveTimeout = useRef | undefined>(undefined);
+ const copiedTimeout = useRef | undefined>(undefined);
const handleCopy = useCallback(() => {
if (!text) return;
- copyToClipboard(text);
- setCopied(true);
- setTimeout(() => setCopied(false), 1000);
- }, [copyToClipboard, text]);
-
- useEffect(() => {
- if (state.error) {
- console.error('Copy failed:', state.error);
- }
- }, [state]);
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ setCopied(true);
+ toast.success('Copied!');
+ clearTimeout(copiedTimeout.current);
+ copiedTimeout.current = setTimeout(() => {
+ setCopied(false);
+ copiedTimeout.current = undefined;
+ }, 1000);
+ })
+ .catch((error) => {
+ console.error('Copy failed:', error);
+ toast.error('Failed to copy');
+ });
+ }, [text]);
const handleMouseEnter = useCallback(() => {
clearTimeout(leaveTimeout.current);
@@ -50,6 +58,7 @@ const ClipboardIconButton = ({ text = '', size }: ClipboardIconButtonProps) => {
return () => {
clearTimeout(enterTimeout.current);
clearTimeout(leaveTimeout.current);
+ clearTimeout(copiedTimeout.current);
};
}, []);
@@ -57,30 +66,30 @@ const ClipboardIconButton = ({ text = '', size }: ClipboardIconButtonProps) => {
return (
-
-
-
-
-
+
+
+
{copied ? 'Copied!' : 'Copy to clipboard'}
diff --git a/packages/web/src/components/data-table/DesktopFilterToolbar/TableChainFilter.tsx b/packages/web/src/components/data-table/DesktopFilterToolbar/TableChainFilter.tsx
index 779d8a8..0a57bab 100644
--- a/packages/web/src/components/data-table/DesktopFilterToolbar/TableChainFilter.tsx
+++ b/packages/web/src/components/data-table/DesktopFilterToolbar/TableChainFilter.tsx
@@ -7,9 +7,15 @@ import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import SelectedLabels from '@/components/selected-labels';
+import { shouldShowAllOption } from '@/components/data-table/filter-option-policy';
import useChainFilterLogic from '../hooks/useChainFilterLogic';
+import {
+ FILTER_TRIGGER_BASE_CLASSNAME,
+ FILTER_TRIGGER_FOCUS_CLASSNAME
+} from './filterTriggerStyles';
+
import type { TableFilterOption } from '@/types/helper';
interface TableChainFilterProps {
@@ -32,84 +38,137 @@ const TableChainFilter = ({
contentClassName
}: TableChainFilterProps) => {
const [open, setOpen] = useState(false);
- const { sortedOptions, toggleItem, handleSelectAll, checkedAll } = useChainFilterLogic({
+ const optionFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+ const actionFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+ const showAllOption = shouldShowAllOption(options.length);
+ const { sortedOptions, toggleItem, handleSelectAll } = useChainFilterLogic({
options,
value,
onChange,
- limit
+ limit,
+ normalizeFullSelectionToAll: false
});
+ const isNoFilter = value.length === 0;
+ const isAllChecked = limit > 0 && value.length === limit;
+ const clearActionDisabled = isNoFilter;
+ const clearActionLabel = isNoFilter ? 'No filter (All)' : 'Clear to All';
+ const selectionSummary = isNoFilter
+ ? 'No filter'
+ : isAllChecked
+ ? 'All selected'
+ : `${value.length} / ${limit} Selected`;
return (
-
-
- {title}:
-
-
-
-
-
+
+ }
+ >
+ {title}:
+
+
+
+
-
-
-
-
- All
-
+ {showAllOption && (
+
+
+
+ {clearActionLabel}
+
+ {selectionSummary}
+
+
-
- {value.length || '0'} / {limit} Selected
-
-
-
-
+ )}
+
{sortedOptions.map(({ value: optionValue, label }) => {
const isSelected = value.includes(optionValue as number);
+ const isDisabled = value.length === limit && !isSelected;
return (
-
toggleItem(optionValue as number)}
+ disabled={isDisabled}
className={cn(
- 'flex items-center gap-[0.62rem] py-[0.62rem]',
- value.length < limit || isSelected ? 'cursor-pointer' : 'cursor-not-allowed'
+ 'flex items-center gap-[0.62rem] rounded-md px-1 py-[0.62rem] text-left transition-colors duration-200 hover:bg-muted/50',
+ optionFocusClassName,
+ isSelected && 'bg-accent hover:bg-accent/80',
+ isDisabled ? 'cursor-not-allowed opacity-40 hover:bg-transparent' : 'cursor-pointer'
)}
+ aria-pressed={isSelected}
+ aria-disabled={isDisabled}
>
-
+
{label}
-
-
+
+
);
})}
+ {!showAllOption && (
+ <>
+
+ {selectionSummary}
+
+
+
+
+ {clearActionLabel}
+
+
+ >
+ )}
);
diff --git a/packages/web/src/components/data-table/DesktopFilterToolbar/TableDateFilter.tsx b/packages/web/src/components/data-table/DesktopFilterToolbar/TableDateFilter.tsx
index 26e57ab..bac34b2 100644
--- a/packages/web/src/components/data-table/DesktopFilterToolbar/TableDateFilter.tsx
+++ b/packages/web/src/components/data-table/DesktopFilterToolbar/TableDateFilter.tsx
@@ -6,7 +6,12 @@ import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
-import type { DateRange, SelectRangeEventHandler } from 'react-day-picker';
+import {
+ FILTER_TRIGGER_BASE_CLASSNAME,
+ FILTER_TRIGGER_FOCUS_CLASSNAME
+} from './filterTriggerStyles';
+
+import type { DateRange } from 'react-day-picker';
interface TableDateFilterProps {
date?: DateRange;
@@ -23,8 +28,8 @@ const TableDateFilter = ({
}: TableDateFilterProps) => {
const [open, setOpen] = useState(false);
- const handleChange = useCallback
(
- (selectedDate) => {
+ const handleChange = useCallback(
+ (selectedDate: DateRange | undefined) => {
if (!selectedDate) return;
const { from, to } = selectedDate;
if (onChange) {
@@ -39,32 +44,35 @@ const TableDateFilter = ({
return (
-
-
- Date:
-
-
- {!date?.from && !date?.to
- ? 'All'
- : `${date.from?.toLocaleDateString() ?? ''} - ${date.to?.toLocaleDateString() ?? ''}`}
-
+
+ }
+ >
+
Date:
+
+
+ {!date?.from && !date?.to
+ ? 'All'
+ : `${date.from?.toLocaleDateString() ?? ''} - ${date.to?.toLocaleDateString() ?? ''}`}
+
-
-
-
+
+
-
+
{
onChange: (newValue: T[]) => void;
title: React.ReactNode;
onClearFilters?: () => void;
+ showAllOption?: boolean;
buttonClassName?: string;
contentClassName?: string;
}
@@ -26,89 +32,143 @@ const TableMultiSelectFilter = ({
onChange,
title,
onClearFilters,
+ showAllOption,
buttonClassName,
contentClassName
}: TableMultiSelectFilterProps) => {
const [open, setOpen] = useState(false);
+ const optionFocusClassName = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50';
+ const actionFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+ const safeValue = value ?? [];
+ const groupAriaLabel = typeof title === 'string' ? `${title} filter options` : 'Filter options';
const toggleItem = (itemValue: T) => {
- if (value?.includes(itemValue)) {
- onChange(value?.filter((s) => s !== itemValue));
+ if (safeValue.includes(itemValue)) {
+ onChange(safeValue.filter((s) => s !== itemValue));
} else {
- onChange([...(value || []), itemValue]);
+ onChange([...safeValue, itemValue]);
+ }
+ };
+
+ const handleClearToAll = () => {
+ if (onClearFilters) {
+ onClearFilters();
+ return;
}
+ onChange([]);
};
+ const isAllSelected = safeValue.length === 0;
+ const resolvedShowAllOption = showAllOption ?? shouldShowAllOption(options.length);
+ const clearActionDisabled = isAllSelected;
+
return (
-
-
- {title}:
-
-
-
-
-
+
+ }
+ >
+ {title}:
+
+
+
+
-
-
-
-
- {options.map(({ value: optionValue, label }) => {
- const isSelected = value?.includes(optionValue as T);
- return (
- toggleItem(optionValue as T)}
- className="cursor-pointer px-[1.25rem] py-[0.62rem]"
+
+
+ {resolvedShowAllOption && (
+
+
+
+ {isAllSelected ? 'No filter (All)' : 'Clear to All'}
+
+ {isAllSelected ? 'No filter' : `${safeValue.length} / ${options.length} Selected`}
+
+
+
+ )}
+
+ {options.map(({ value: optionValue, label }) => {
+ const isSelected = safeValue.includes(optionValue as T);
+ return (
+
toggleItem(optionValue as T)}
+ className={cn(
+ 'flex w-full cursor-pointer items-center gap-2 px-[1.25rem] py-[0.62rem] text-left transition-colors duration-200 hover:bg-muted/50',
+ optionFocusClassName,
+ isSelected && 'bg-accent hover:bg-accent/80'
+ )}
+ >
+
-
-
-
-
- {label}
-
-
- );
- })}
-
- {value?.length > 0 && (
- <>
-
-
+
+
- Clear filters
-
-
- >
- )}
-
-
+ {label}
+
+
+ );
+ })}
+
+ {!resolvedShowAllOption && onClearFilters && (
+ <>
+
+
+
+ {isAllSelected ? 'No filter (All)' : 'Clear to All'}
+
+
+ >
+ )}
+
);
diff --git a/packages/web/src/components/data-table/DesktopFilterToolbar/filterTriggerStyles.ts b/packages/web/src/components/data-table/DesktopFilterToolbar/filterTriggerStyles.ts
new file mode 100644
index 0000000..7862186
--- /dev/null
+++ b/packages/web/src/components/data-table/DesktopFilterToolbar/filterTriggerStyles.ts
@@ -0,0 +1,5 @@
+export const FILTER_TRIGGER_BASE_CLASSNAME =
+ 'flex items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium shadow-none transition-colors duration-200 dark:border-border dark:bg-background hover:bg-accent hover:text-foreground active:bg-accent active:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground dark:hover:bg-accent dark:active:bg-accent dark:aria-expanded:bg-accent dark:data-[state=open]:bg-accent cursor-pointer';
+
+export const FILTER_TRIGGER_FOCUS_CLASSNAME =
+ 'focus-visible:outline-none focus-visible:border-border focus-visible:ring-0 focus-visible:bg-accent focus-visible:text-foreground';
diff --git a/packages/web/src/components/data-table/DesktopFilterToolbar/index.tsx b/packages/web/src/components/data-table/DesktopFilterToolbar/index.tsx
index 9857633..a3ecb98 100644
--- a/packages/web/src/components/data-table/DesktopFilterToolbar/index.tsx
+++ b/packages/web/src/components/data-table/DesktopFilterToolbar/index.tsx
@@ -1,4 +1,3 @@
-import { Button } from '@/components/ui/button';
import { MESSAGE_STATUS_LIST } from '@/config/status';
import { cn } from '@/lib/utils';
import { getDappOptions } from '@/utils';
@@ -45,65 +44,70 @@ const TableFilterToolbar = ({ chains, className, hideDappFilter }: TableFilterTo
} = useQueryParamState();
const limit = CHAIN_OPTIONS?.length;
+
+ const hasActiveFilters =
+ (selectedDapps?.length ?? 0) > 0 ||
+ (selectedStatuses?.length ?? 0) > 0 ||
+ Boolean(dateFrom) ||
+ Boolean(dateTo) ||
+ (selectedSourceChains?.length ?? 0) > 0 ||
+ (selectedTargetChains?.length ?? 0) > 0;
+
return (
-
-
Messages
-
+
+
{!hideDappFilter && (
)}
-
-
-
-
-
-
- Reset
-
+ {hasActiveFilters &&
}
+ {hasActiveFilters && (
+
+ Reset
+
+ )}
);
diff --git a/packages/web/src/components/data-table/MobileFilterToolbar/DropdownButton.tsx b/packages/web/src/components/data-table/MobileFilterToolbar/DropdownButton.tsx
index 99a3ada..39b9ac1 100644
--- a/packages/web/src/components/data-table/MobileFilterToolbar/DropdownButton.tsx
+++ b/packages/web/src/components/data-table/MobileFilterToolbar/DropdownButton.tsx
@@ -21,13 +21,17 @@ const DropdownButton = ({
children,
className
}: React.PropsWithChildren
) => {
+ const triggerFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+
return (
onOpenChange?.(true)}
className={cn(
- 'flex items-center gap-[0.31rem] border-none text-sm font-normal focus-visible:ring-0',
+ 'flex items-center gap-[0.31rem] border-none text-sm font-normal cursor-pointer transition-colors duration-200 hover:bg-muted/50',
+ triggerFocusClassName,
className
)}
>
diff --git a/packages/web/src/components/data-table/MobileFilterToolbar/FilterBack.tsx b/packages/web/src/components/data-table/MobileFilterToolbar/FilterBack.tsx
index ddcb32f..3dc4551 100644
--- a/packages/web/src/components/data-table/MobileFilterToolbar/FilterBack.tsx
+++ b/packages/web/src/components/data-table/MobileFilterToolbar/FilterBack.tsx
@@ -7,13 +7,15 @@ interface FilterBackProps {
}
const MobileFilterBack = ({ onClick, title, isShowIcon = true }: FilterBackProps) => {
return (
-
- {isShowIcon ?
: null}
+ {isShowIcon ?
: null}
{title}
-
+
);
};
diff --git a/packages/web/src/components/data-table/MobileFilterToolbar/TableChainFilter.tsx b/packages/web/src/components/data-table/MobileFilterToolbar/TableChainFilter.tsx
index 6bb57cc..b4fdc59 100644
--- a/packages/web/src/components/data-table/MobileFilterToolbar/TableChainFilter.tsx
+++ b/packages/web/src/components/data-table/MobileFilterToolbar/TableChainFilter.tsx
@@ -1,6 +1,10 @@
+import { useState, useEffect } from 'react';
+
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
+import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
+import { shouldShowAllOption } from '@/components/data-table/filter-option-policy';
import useChainFilterLogic from '../hooks/useChainFilterLogic';
@@ -11,65 +15,128 @@ interface TableChainFilterProps {
value: number[];
onChange: (newValue: number[]) => void;
limit: number;
+ title?: string;
}
-const TableChainFilter = ({ options, value, onChange, limit }: TableChainFilterProps) => {
- const { sortedOptions, toggleItem, handleSelectAll, checkedAll } = useChainFilterLogic({
+const TableChainFilter = ({ options, value: valueProp, onChange, limit, title }: TableChainFilterProps) => {
+ // Local state for immediate visual feedback, avoiding nuqs startTransition flicker
+ const [value, setValue] = useState(valueProp);
+
+ useEffect(() => {
+ setValue(valueProp);
+ }, [valueProp]);
+
+ const handleChange = (newValue: number[]) => {
+ setValue(newValue);
+ onChange(newValue);
+ };
+ const optionFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+ const actionFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+ const showAllOption = shouldShowAllOption(options.length);
+
+ const { sortedOptions, toggleItem, handleSelectAll } = useChainFilterLogic({
options,
value,
- onChange,
- limit
+ onChange: handleChange,
+ limit,
+ normalizeFullSelectionToAll: false
});
+ const isNoFilter = value.length === 0;
+ const isAllChecked = limit > 0 && value.length === limit;
+ const clearActionDisabled = isNoFilter;
+ const clearActionLabel = isNoFilter ? 'No filter (All)' : 'Clear to All';
+ const selectionSummary = isNoFilter
+ ? 'No filter'
+ : isAllChecked
+ ? 'All selected'
+ : `${value.length} / ${limit} Selected`;
+ const regionAriaLabel = title ? `${title} chain filter options` : 'Chain filter options';
return (
-
-
-
-
-
- All
-
-
-
- {value.length || '0'} / {limit} Selected
-
-
-
+
+ {showAllOption && (
+ <>
+
+
+ {clearActionLabel}
+
+ {selectionSummary}
+
+
+ >
+ )}
{sortedOptions.map(({ value: optionValue, label }) => {
const isSelected = value.includes(optionValue as number);
+ const isDisabled = value.length === limit && !isSelected;
return (
-
toggleItem(optionValue as number)}
+ disabled={isDisabled}
className={cn(
- 'flex h-[3.125rem] items-center gap-[0.62rem]',
- value.length < limit || isSelected ? 'cursor-pointer' : 'cursor-not-allowed'
+ 'flex h-[3.125rem] items-center gap-[0.62rem] rounded-sm text-left transition-colors duration-200',
+ optionFocusClassName,
+ isDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'
)}
+ aria-pressed={isSelected}
+ aria-disabled={isDisabled}
>
-
+
{label}
-
-
+
+
);
})}
+ {!showAllOption && (
+ <>
+
+ {selectionSummary}
+
+
+
+ {clearActionLabel}
+
+ >
+ )}
);
};
diff --git a/packages/web/src/components/data-table/MobileFilterToolbar/TableDateFilter.tsx b/packages/web/src/components/data-table/MobileFilterToolbar/TableDateFilter.tsx
index 34b4fd9..530045d 100644
--- a/packages/web/src/components/data-table/MobileFilterToolbar/TableDateFilter.tsx
+++ b/packages/web/src/components/data-table/MobileFilterToolbar/TableDateFilter.tsx
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { Calendar } from '@/components/ui/calendar';
-import type { DateRange, SelectRangeEventHandler } from 'react-day-picker';
+import type { DateRange } from 'react-day-picker';
interface TableDateFilterProps {
date?: DateRange;
@@ -10,8 +10,8 @@ interface TableDateFilterProps {
}
const TableDateFilter = ({ date, onChange }: TableDateFilterProps) => {
- const handleChange = useCallback
(
- (selectedDate) => {
+ const handleChange = useCallback(
+ (selectedDate: DateRange | undefined) => {
if (!selectedDate) return;
const { from, to } = selectedDate;
if (onChange) {
@@ -25,15 +25,15 @@ const TableDateFilter = ({ date, onChange }: TableDateFilterProps) => {
);
return (
-
+
);
diff --git a/packages/web/src/components/data-table/MobileFilterToolbar/TableMultiSelectFilter.tsx b/packages/web/src/components/data-table/MobileFilterToolbar/TableMultiSelectFilter.tsx
index 47b22f2..e947c3e 100644
--- a/packages/web/src/components/data-table/MobileFilterToolbar/TableMultiSelectFilter.tsx
+++ b/packages/web/src/components/data-table/MobileFilterToolbar/TableMultiSelectFilter.tsx
@@ -1,7 +1,10 @@
-import { CheckIcon } from '@radix-ui/react-icons';
+import { useState, useEffect } from 'react';
+import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
+import { Separator } from '@/components/ui/separator';
+import { shouldShowAllOption } from '@/components/data-table/filter-option-policy';
import type { TableFilterOption } from '@/types/helper';
@@ -10,61 +13,133 @@ interface TableMultiSelectFilterProps
{
value: T[];
onChange: (newValue: T[]) => void;
onClearFilters?: () => void;
+ showAllOption?: boolean;
+ title?: string;
}
const TableMultiSelectFilter = ({
options,
- value,
+ value: valueProp,
onChange,
- onClearFilters
+ onClearFilters,
+ showAllOption,
+ title
}: TableMultiSelectFilterProps) => {
- const toggleItem = (itemValue: T) => {
- if (value.includes(itemValue)) {
- onChange(value.filter((s) => s !== itemValue));
+ // Local state for immediate visual feedback, avoiding nuqs startTransition flicker
+ const [value, setValue] = useState(valueProp);
+
+ useEffect(() => {
+ setValue(valueProp);
+ }, [valueProp]);
+
+ const handleChange = (newValue: T[]) => {
+ setValue(newValue);
+ onChange(newValue);
+ };
+
+ const handleClearWithLocal = () => {
+ setValue([]);
+ if (onClearFilters) {
+ onClearFilters();
} else {
- onChange([...(value || []), itemValue]);
+ onChange([]);
}
};
+ const optionFocusClassName = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50';
+ const actionFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
+ const safeValue = value ?? [];
+ const groupAriaLabel = title ? `${title} filter options` : 'Filter options';
+
+ const toggleItem = (itemValue: T) => {
+ const next = safeValue.includes(itemValue)
+ ? safeValue.filter((s) => s !== itemValue)
+ : [...safeValue, itemValue];
+ handleChange(next);
+ };
+
+ const isAllSelected = safeValue.length === 0;
+ const resolvedShowAllOption = showAllOption ?? shouldShowAllOption(options.length);
+ const clearActionDisabled = isAllSelected;
return (
<>
-
+
+ {resolvedShowAllOption && (
+ <>
+
+
+ {isAllSelected ? 'No filter (All)' : 'Clear to All'}
+
+ {isAllSelected ? 'No filter' : `${safeValue.length} / ${options.length} Selected`}
+
+
+ >
+ )}
{options.map(({ value: optionValue, label }) => {
- const isSelected = value.includes(optionValue as T);
+ const isSelected = safeValue.includes(optionValue as T);
return (
-
toggleItem(optionValue as T)}
- className="flex items-center gap-2 py-[0.62rem]"
+ role="checkbox"
+ aria-checked={isSelected}
+ className={cn(
+ 'flex w-full items-center gap-2 rounded-md px-2 py-[0.62rem] text-left transition-colors duration-200',
+ optionFocusClassName,
+ isSelected && 'bg-accent'
+ )}
>
-
-
-
+
+
+
{label}
-
+
);
})}
-
- Reset
-
+ {!resolvedShowAllOption && onClearFilters && (
+
+ {isAllSelected ? 'No filter (All)' : 'Clear to All'}
+
+ )}
>
);
diff --git a/packages/web/src/components/data-table/MobileFilterToolbar/index.tsx b/packages/web/src/components/data-table/MobileFilterToolbar/index.tsx
index 9edad9d..305b93f 100644
--- a/packages/web/src/components/data-table/MobileFilterToolbar/index.tsx
+++ b/packages/web/src/components/data-table/MobileFilterToolbar/index.tsx
@@ -102,6 +102,19 @@ const TableFilterToolbar = ({ chains, className, hideDappFilter }: TableFilterTo
});
}, []);
+ const handleStatusChangeAndBack = useCallback(
+ (newStatuses: number[]) => {
+ handleStatusChange(newStatuses);
+ handleFilterBack();
+ },
+ [handleStatusChange, handleFilterBack]
+ );
+
+ const handleResetStatusAndBack = useCallback(() => {
+ handleResetStatus();
+ handleFilterBack();
+ }, [handleResetStatus, handleFilterBack]);
+
const selectedNumber = useMemo(() => {
const dappNumber = selectedDapps?.length ? 1 : 0;
const dateNumber = dateFrom || dateTo ? 1 : 0;
@@ -123,137 +136,155 @@ const TableFilterToolbar = ({ chains, className, hideDappFilter }: TableFilterTo
selectedSourceChains,
selectedTargetChains
]);
+ const triggerFocusClassName =
+ 'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50';
return (
<>
-
Messages
-
Messages
+
setOpen(true)}
- className="flex items-center gap-[0.31rem] border-none text-sm font-normal"
+ aria-haspopup="dialog"
+ aria-expanded={open}
+ className={cn(
+ 'flex items-center gap-[0.31rem] rounded-full border px-3 py-1.5 text-sm font-normal transition-colors',
+ triggerFocusClassName,
+ selectedNumber > 0
+ ? 'border-primary/50 bg-primary/10 text-foreground'
+ : 'border-border text-secondary-foreground'
+ )}
>
-
-
- Filters {selectedNumber ? `(${selectedNumber})` : ''}
-
-
+
+
Filters
+ {selectedNumber > 0 && (
+
+ {selectedNumber}
+
+ )}
+
-
- {
-
- {currentFilterInfo?.value === CURRENT_FILTERS.DEFAULT && (
- <>
- {!hideDappFilter && (
- <>
-
-
- >
- )}
-
-
-
-
- {!dateFrom && !dateTo
- ? 'All'
- : `${dateFrom?.toLocaleDateString() ?? ''} - ${dateTo?.toLocaleDateString() ?? ''}`}
-
-
-
-
-
-
-
-
- Reset
-
- >
- )}
+
+ {/* Header */}
+
+
+
+ {/* Scrollable content */}
+
+ {currentFilterInfo?.value === CURRENT_FILTERS.DEFAULT && (
+ <>
+ {!hideDappFilter && (
+ <>
+
+
+ >
+ )}
- {currentFilterInfo?.value === CURRENT_FILTERS.DAPP && (
-
- )}
- {currentFilterInfo?.value === CURRENT_FILTERS.STATUS && (
-
- )}
+
+
+ {!dateFrom && !dateTo
+ ? 'All'
+ : `${dateFrom?.toLocaleDateString() ?? ''} - ${dateTo?.toLocaleDateString() ?? ''}`}
+
+
- {currentFilterInfo?.value === CURRENT_FILTERS.DATE && (
-
- )}
-
- {currentFilterInfo?.value === CURRENT_FILTERS.SOURCE_CHAIN && (
-
- )}
- {currentFilterInfo?.value === CURRENT_FILTERS.TARGET_CHAIN && (
-
+
- )}
-
- }
- {
-
- }
+
+
+ Reset
+
+ >
+ )}
+
+ {currentFilterInfo?.value === CURRENT_FILTERS.DAPP && (
+
+ )}
+ {currentFilterInfo?.value === CURRENT_FILTERS.STATUS && (
+
+ )}
+
+ {currentFilterInfo?.value === CURRENT_FILTERS.DATE && (
+
+ )}
+
+ {currentFilterInfo?.value === CURRENT_FILTERS.SOURCE_CHAIN && (
+
+ )}
+ {currentFilterInfo?.value === CURRENT_FILTERS.TARGET_CHAIN && (
+
+ )}
+
>
diff --git a/packages/web/src/components/data-table/columns.tsx b/packages/web/src/components/data-table/columns.tsx
index ce52b9a..3bd7eda 100644
--- a/packages/web/src/components/data-table/columns.tsx
+++ b/packages/web/src/components/data-table/columns.tsx
@@ -1,175 +1,281 @@
'use client';
+import Image from 'next/image';
import Link from 'next/link';
+import { ExternalLink } from 'lucide-react';
import MessageStatus from '@/components/message-status';
import { chains } from '@/config/chains';
-import { protocols } from '@/config/protocols';
-import { formatTimeAgo, formatTimeDifference } from '@/utils';
-import ChainTxDisplay from '@/components/chain-tx-display';
-import BlockchainAddressLink from '@/components/blockchain-address-link';
+import AddressDisplayFilterDappRemark from '@/components/address-display-filter-dapp-remark';
+import { formatTimeAgo, formatTimeAgoShort, toShortText } from '@/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { CodeFont } from '@/config/font';
import { cn } from '@/lib/utils';
import { getNetwork } from '@/utils/network';
+import ClipboardIconButton from '@/components/clipboard-icon-button';
import type { ChAIN_ID } from '@/types/chains';
-import type { MessagePort } from '@/graphql/type';
+import type { CompositeMessage } from '@/types/messages';
+
+type ColumnDataIndex =
+ | 'status'
+ | 'msgId'
+ | 'fromChainId'
+ | 'toChainId'
+ | 'fromDapp'
+ | 'transactionFrom'
+ | 'transactionHash'
+ | 'targetTransactionHash'
+ | 'age';
+
+type ColumnRenderValue = string | undefined;
export type Column = {
- dataIndex: string;
+ dataIndex: ColumnDataIndex;
title: string;
width?: string;
- render: (value: any, record: MessagePort, index: number, network: string) => React.ReactNode;
+ hiddenClass?: string;
+ align?: 'left' | 'right' | 'center';
+ render: (
+ value: ColumnRenderValue,
+ record: CompositeMessage,
+ index: number,
+ network: string
+ ) => React.ReactNode;
};
+function findChain(chainId: string | undefined) {
+ if (!chainId) return undefined;
+ return chains?.find((c) => c.id === (Number(chainId) as unknown as ChAIN_ID));
+}
+
+function buildTxLink(chain: ReturnType, txHash: string) {
+ if (!chain?.blockExplorers?.default?.url) return undefined;
+ if (chain.name.includes('Tron')) {
+ return `${chain.blockExplorers.default.url}/#/transaction/${txHash.replace('0x', '')}`;
+ }
+ return `${chain.blockExplorers.default.url}/tx/${txHash}`;
+}
+
+function renderChainCell(chainId: string | undefined) {
+ const chain = findChain(chainId);
+ if (!chain) return null;
+
+ return (
+
+
+
+ {chain.name}
+
+
+ );
+}
+
export const columns: Column[] = [
{
dataIndex: 'status',
title: 'Status',
- width: '6rem',
- render(value, record) {
+ width: '5.5rem',
+ render(_value, record) {
if (record?.status === -1) {
return ;
}
- return ;
+ return ;
}
},
{
- dataIndex: 'id',
+ dataIndex: 'msgId',
title: 'Msg ID',
- width: '10rem',
- render(value, record, index, network) {
+ width: '9rem',
+ render(value, record) {
if (record?.status === -1) {
return ;
}
return (
-
- {value}
-
+
+
+ {toShortText({ text: value, frontLength: 6, backLength: 4 })}
+
+
+
);
}
},
{
- dataIndex: 'protocol',
- title: 'Protocol',
- width: '5rem',
+ dataIndex: 'fromChainId',
+ title: 'Source',
+ width: '7rem',
render(value, record) {
if (record?.status === -1) {
return ;
}
- const protocol = protocols?.find((protocol) => protocol.value === value);
- if (protocol) {
- const Icon = protocol.icon;
- return (
-
-
- {protocol.title}
-
- );
- }
+ return renderChainCell(value);
}
},
{
- dataIndex: 'sender',
- title: 'Original Sender',
- width: '8rem',
- render(value, record, index, network) {
+ dataIndex: 'toChainId',
+ title: 'Target',
+ width: '7rem',
+ render(value, record) {
if (record?.status === -1) {
return ;
}
- if (!value) return '';
- const chain = chains?.find(
- (chain) => chain.id === (Number(record?.sourceChainId) as unknown as ChAIN_ID)
- );
- const href = `/sender/${value}?network=${getNetwork(network)}`;
- return ;
+ return renderChainCell(value);
}
},
{
- dataIndex: 'sourceTransactionHash',
- title: 'Source Tx Hash',
+ dataIndex: 'fromDapp',
+ title: 'Dapp',
width: '8rem',
- render(value, record, index, network) {
+ render(value, record, _index, network) {
if (record?.status === -1) {
return ;
}
- if (!value) return '';
- const chain = chains?.find(
- (chain) => chain.id === (Number(record?.sourceChainId) as unknown as ChAIN_ID)
- );
+ if (!value) return -- ;
+
+ const href = `/dapp/${encodeURIComponent(value)}?network=${getNetwork(network)}`;
+
return (
-
+ e.stopPropagation()}
+ >
+ toShortText({ text: address, frontLength: 6, backLength: 4 })}
+ />
+
);
}
},
{
- dataIndex: 'targetTransactionHash',
- title: 'Target Tx Hash',
+ dataIndex: 'transactionFrom',
+ title: 'Original Sender',
width: '8rem',
- render(value, record) {
+ render(value, record, _index, network) {
if (record?.status === -1) {
return ;
}
- const chain = chains?.find(
- (chain) => chain.id === (Number(record?.targetChainId) as unknown as ChAIN_ID)
+ if (!value) return -- ;
+
+ const href = `/sender/${encodeURIComponent(value)}?network=${getNetwork(network)}`;
+
+ return (
+ e.stopPropagation()}
+ >
+ {toShortText({ text: value, frontLength: 6, backLength: 4 })}
+
);
- return ;
}
},
{
- dataIndex: 'sourceDappAddress',
- title: 'Dapp',
- width: '8rem',
- render(value, record, index, network) {
+ dataIndex: 'transactionHash',
+ title: 'Source Tx Hash',
+ width: '9rem',
+ hiddenClass: 'hidden md:table-cell',
+ render(value, record) {
if (record?.status === -1) {
return ;
}
- if (!value) return '';
- const chain = chains?.find(
- (chain) => chain.id === (Number(record?.sourceChainId) as unknown as ChAIN_ID)
+ if (!value) return -- ;
+ const chain = findChain(record?.fromChainId);
+ const href = buildTxLink(chain, value);
+ const short = toShortText({ text: value, frontLength: 6, backLength: 4 });
+ if (href) {
+ return (
+ e.stopPropagation()}
+ >
+ {short}
+
+
+ );
+ }
+ return (
+
+ {short}
+
);
- const href = `/dapp/${value}?network=${getNetwork(network)}`;
- return ;
}
},
{
- dataIndex: 'age',
- title: 'Age',
- width: '6rem',
+ dataIndex: 'targetTransactionHash',
+ title: 'Target Tx Hash',
+ width: '9rem',
+ hiddenClass: 'hidden lg:table-cell',
render(value, record) {
if (record?.status === -1) {
return ;
}
- return record?.sourceBlockTimestamp
- ? formatTimeAgo(String(record?.sourceBlockTimestamp))
- : '';
+ if (!value) return -- ;
+ const chain = findChain(record?.toChainId);
+ const href = buildTxLink(chain, value);
+ const short = toShortText({ text: value, frontLength: 6, backLength: 4 });
+ if (href) {
+ return (
+ e.stopPropagation()}
+ >
+ {short}
+
+
+ );
+ }
+ return (
+
+ {short}
+
+ );
}
},
{
- dataIndex: 'timeSpent',
- title: 'Time Spent',
- width: '6rem',
-
- render(value, record) {
+ dataIndex: 'age',
+ title: 'Age',
+ width: '5rem',
+ align: 'right',
+ render(_value, record) {
if (record?.status === -1) {
return ;
}
- return record.sourceBlockTimestamp && record?.targetBlockTimestamp
- ? formatTimeDifference(
- String(record.sourceBlockTimestamp),
- String(record?.targetBlockTimestamp)
- )
+ const fullAge = record?.sentBlockTimestampSec
+ ? formatTimeAgo(String(record?.sentBlockTimestampSec))
+ : '';
+ const shortAge = record?.sentBlockTimestampSec
+ ? formatTimeAgoShort(String(record?.sentBlockTimestampSec))
: '';
+ return (
+
+ {shortAge}
+
+ );
}
}
];
diff --git a/packages/web/src/components/data-table/filter-option-policy.test.ts b/packages/web/src/components/data-table/filter-option-policy.test.ts
new file mode 100644
index 0000000..91a5104
--- /dev/null
+++ b/packages/web/src/components/data-table/filter-option-policy.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+
+import { MULTISELECT_ALL_THRESHOLD, shouldShowAllOption } from './filter-option-policy';
+
+describe('filter-option-policy', () => {
+ it('hides All option below threshold', () => {
+ expect(shouldShowAllOption(MULTISELECT_ALL_THRESHOLD - 1)).toBe(false);
+ });
+
+ it('shows All option at threshold', () => {
+ expect(shouldShowAllOption(MULTISELECT_ALL_THRESHOLD)).toBe(true);
+ });
+
+ it('shows All option above threshold', () => {
+ expect(shouldShowAllOption(MULTISELECT_ALL_THRESHOLD + 3)).toBe(true);
+ });
+});
diff --git a/packages/web/src/components/data-table/filter-option-policy.ts b/packages/web/src/components/data-table/filter-option-policy.ts
new file mode 100644
index 0000000..515373c
--- /dev/null
+++ b/packages/web/src/components/data-table/filter-option-policy.ts
@@ -0,0 +1,5 @@
+export const MULTISELECT_ALL_THRESHOLD = 6;
+
+export function shouldShowAllOption(optionsCount: number): boolean {
+ return optionsCount >= MULTISELECT_ALL_THRESHOLD;
+}
diff --git a/packages/web/src/components/data-table/hooks/useChainFilterLogic.ts b/packages/web/src/components/data-table/hooks/useChainFilterLogic.ts
index 25b3e0d..a6d8929 100644
--- a/packages/web/src/components/data-table/hooks/useChainFilterLogic.ts
+++ b/packages/web/src/components/data-table/hooks/useChainFilterLogic.ts
@@ -1,6 +1,5 @@
import { useMemo, useCallback } from 'react';
-import type { CheckedState } from '@radix-ui/react-checkbox';
import type { TableFilterOption } from '@/types/helper';
type UseChainFilterLogicType = {
@@ -8,50 +7,68 @@ type UseChainFilterLogicType = {
value: number[];
onChange: (newValue: number[]) => void;
limit: number;
+ normalizeFullSelectionToAll?: boolean;
};
-function useChainFilterLogic({ options, value, onChange, limit }: UseChainFilterLogicType) {
+function useChainFilterLogic({
+ options,
+ value,
+ onChange,
+ limit,
+ normalizeFullSelectionToAll = true
+}: UseChainFilterLogicType) {
const sortedOptions = useMemo(() => {
return [...options].sort((a, b) => (a?.label as string)?.localeCompare(b.label as string));
}, [options]);
const toggleItem = useCallback(
(itemValue: number) => {
- if (value.length >= limit && !value.includes(itemValue)) {
+ // Empty value means "All" (no filter). Picking any option switches to custom filtering.
+ if (value.length === 0) {
+ onChange([itemValue]);
return;
}
+
if (value.includes(itemValue)) {
onChange(value.filter((s) => s !== itemValue));
- } else {
- onChange([...value, itemValue]);
+ return;
}
+
+ if (value.length >= limit) return;
+
+ const next = [...value, itemValue];
+ // If user ends up selecting every option, normalize back to "All" to keep URL/state clean.
+ if (normalizeFullSelectionToAll && limit > 0 && options.length === limit && next.length === limit) {
+ onChange([]);
+ return;
+ }
+
+ onChange(next);
},
- [value, onChange, limit]
+ [value, onChange, limit, options.length, normalizeFullSelectionToAll]
);
const handleSelectAll = useCallback(() => {
- if (value.length === limit) {
- onChange([]);
- } else {
- const newValue = new Set(value);
- for (const option of sortedOptions) {
- if (newValue.size >= limit) break;
- newValue.add(option.value as number);
- }
- onChange(Array.from(newValue));
- }
- }, [value, onChange, limit, sortedOptions]);
+ // "All" is represented by an empty array.
+ onChange([]);
+ }, [onChange]);
+
+ const checkedAll = useMemo(() => {
+ return (
+ value.length === 0 ||
+ (normalizeFullSelectionToAll && options.length === limit && value.length === limit)
+ );
+ }, [value, limit, options.length, normalizeFullSelectionToAll]);
- const checkedAll = useMemo(() => {
- if (value.length === limit) return true;
- if (value.length !== 0) return 'indeterminate';
- return false;
- }, [value, limit]);
+ const indeterminateAll = useMemo(() => {
+ return !checkedAll && value.length > 0 && value.length < limit;
+ }, [checkedAll, value.length, limit]);
return {
sortedOptions,
toggleItem,
handleSelectAll,
- checkedAll
+ checkedAll,
+ indeterminateAll
};
}
diff --git a/packages/web/src/components/data-table/hooks/useFilter.ts b/packages/web/src/components/data-table/hooks/useFilter.ts
index e9e8bd8..be1d924 100644
--- a/packages/web/src/components/data-table/hooks/useFilter.ts
+++ b/packages/web/src/components/data-table/hooks/useFilter.ts
@@ -1,5 +1,4 @@
import { useCallback } from 'react';
-import { useQueryClient } from '@tanstack/react-query';
import useQueryParamState from '@/hooks/useQueryParamState';
@@ -7,101 +6,74 @@ import type { DateRange } from 'react-day-picker';
import type { DAppConfigKeys } from '@/utils';
function useFilter() {
- const queryClient = useQueryClient();
-
const {
setSelectedDapps,
setSelectedStatuses,
- setDateFrom,
- setDateTo,
+ setDateRange,
setSelectedSourceChains,
setSelectedTargetChains
} = useQueryParamState();
const handleDappChange = useCallback(
(newDapps: DAppConfigKeys[]) => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setSelectedDapps(newDapps);
+ // Empty array means "All" (no filter) -> remove query param to keep URL clean.
+ setSelectedDapps(newDapps.length ? newDapps : null);
},
- [setSelectedDapps, queryClient]
+ [setSelectedDapps]
);
const handleStatusChange = useCallback(
(newStatuses: number[]) => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setSelectedStatuses(newStatuses);
+ // Empty array means "All" (no filter) -> remove query param to keep URL clean.
+ setSelectedStatuses(newStatuses.length ? newStatuses : null);
},
- [setSelectedStatuses, queryClient]
+ [setSelectedStatuses]
);
const handleDateChange = useCallback(
(newDate: DateRange) => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setDateFrom(newDate?.from ?? null);
- setDateTo(newDate?.to ?? null);
+ setDateRange(newDate?.from ?? null, newDate?.to ?? null);
},
- [setDateFrom, setDateTo, queryClient]
+ [setDateRange]
);
const handleSourceChainChange = useCallback(
(newSourceChains: number[]) => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setSelectedSourceChains(newSourceChains);
+ // Empty array means "All" (no filter) -> remove query param to keep URL clean.
+ setSelectedSourceChains(newSourceChains.length ? newSourceChains : null);
},
- [setSelectedSourceChains, queryClient]
+ [setSelectedSourceChains]
);
const handleTargetChainChange = useCallback(
(newTargetChains: number[]) => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setSelectedTargetChains(newTargetChains);
+ // Empty array means "All" (no filter) -> remove query param to keep URL clean.
+ setSelectedTargetChains(newTargetChains.length ? newTargetChains : null);
},
- [setSelectedTargetChains, queryClient]
+ [setSelectedTargetChains]
);
const handleReset = useCallback(() => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
setSelectedDapps(null);
setSelectedStatuses(null);
setSelectedSourceChains(null);
setSelectedTargetChains(null);
- setDateFrom(null);
- setDateTo(null);
+ setDateRange(null, null);
}, [
setSelectedDapps,
- setDateFrom,
- setDateTo,
+ setDateRange,
setSelectedSourceChains,
setSelectedStatuses,
- setSelectedTargetChains,
- queryClient
+ setSelectedTargetChains
]);
const handleResetStatus = useCallback(() => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setSelectedStatuses([]);
- }, [setSelectedStatuses, queryClient]);
+ setSelectedStatuses(null);
+ }, [setSelectedStatuses]);
const handleResetDapps = useCallback(() => {
- queryClient.resetQueries({
- queryKey: ['messagePort']
- });
- setSelectedDapps([]);
- }, [setSelectedDapps, queryClient]);
+ setSelectedDapps(null);
+ }, [setSelectedDapps]);
return {
handleDappChange,
diff --git a/packages/web/src/components/data-table/index.test.tsx b/packages/web/src/components/data-table/index.test.tsx
new file mode 100644
index 0000000..1d72559
--- /dev/null
+++ b/packages/web/src/components/data-table/index.test.tsx
@@ -0,0 +1,143 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import DataTable from './index';
+
+import type { CompositeMessage } from '@/types/messages';
+
+const { pushMock } = vi.hoisted(() => ({
+ pushMock: vi.fn()
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: pushMock
+ })
+}));
+
+vi.mock('next/font/google', () => ({
+ Geist: () => ({
+ className: '',
+ variable: ''
+ }),
+ JetBrains_Mono: () => ({
+ className: '',
+ variable: ''
+ })
+}));
+
+const message = {
+ msgId: '0xdaf33c177c251e906a3cdf38572b049fcc49403a27deac44635bf675611899b4',
+ protocol: 'ormp',
+ status: 3,
+ transactionHash: '0x530d7d7273781f2fd9a6afea43bbcdc4db1824794f730d6b631cf567e44feef9',
+ targetTransactionHash: '0x18a9251d1989b4f12953f8aab32311f273737c1386a78135c5e16771eb823bbf',
+ transactionFrom: '0xebd9a48ed1128375eb4383ed4d53478b4fd85a8d',
+ fromChainId: '46',
+ toChainId: '1',
+ fromDapp: '0x682294d1c00a9ca13290b53b7544b8f734d6501f',
+ toDapp: '0x02e5c0a36fb0c83ccebcd4d6177a7e223d6f0b7c',
+ portAddress: '0x2cd1867fb8016f93710b6386f7f9f1d540a60812',
+ message: '0x',
+ params: '0x',
+ sentBlockTimestampSec: 1700000000,
+ sent: {}
+} as unknown as CompositeMessage;
+
+describe('DataTable row interaction', () => {
+ beforeEach(() => {
+ pushMock.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('does not trigger row navigation when Enter is pressed on nested link', () => {
+ render(
+ {}}
+ onNextPageClick={() => {}}
+ />
+ );
+
+ const table = screen.getByRole('table');
+ const sourceTxLink = within(table).getByRole('link', { name: /0x530d/i });
+ sourceTxLink.focus();
+ fireEvent.keyDown(sourceTxLink, { key: 'Enter' });
+
+ expect(pushMock).not.toHaveBeenCalled();
+ });
+
+ it('still supports row keyboard navigation when row itself is focused', () => {
+ render(
+ {}}
+ onNextPageClick={() => {}}
+ />
+ );
+
+ const table = screen.getByRole('table');
+ const rowLink = within(table).getByRole('link', { name: /open message .* details/i });
+ rowLink.focus();
+ fireEvent.keyDown(rowLink, { key: 'Enter' });
+
+ expect(pushMock).toHaveBeenCalledWith(
+ '/message/0xdaf33c177c251e906a3cdf38572b049fcc49403a27deac44635bf675611899b4?network=mainnet'
+ );
+ });
+
+ it('keeps table body visible while loading when real data exists', () => {
+ const { container } = render(
+ {}}
+ onNextPageClick={() => {}}
+ />
+ );
+
+ const tableBody = container.querySelector('tbody');
+ expect(tableBody).not.toBeNull();
+ expect(tableBody?.className.includes('opacity-100')).toBe(true);
+ expect(tableBody?.className.includes('opacity-0')).toBe(false);
+ });
+
+ it('renders original sender link to sender page', () => {
+ const { container } = render(
+ {}}
+ onNextPageClick={() => {}}
+ />
+ );
+
+ const senderLink = container.querySelector(
+ `a[title="${message.transactionFrom}"]`
+ );
+
+ expect(senderLink).not.toBeNull();
+ expect(senderLink?.getAttribute('href')).toBe(
+ `/sender/${encodeURIComponent(message.transactionFrom)}?network=mainnet`
+ );
+ });
+});
diff --git a/packages/web/src/components/data-table/index.tsx b/packages/web/src/components/data-table/index.tsx
index d9b24bc..b2dd90e 100644
--- a/packages/web/src/components/data-table/index.tsx
+++ b/packages/web/src/components/data-table/index.tsx
@@ -1,6 +1,7 @@
'use client';
import { memo, useCallback, useEffect, useState } from 'react';
-import { motion } from 'framer-motion';
+import { useRouter } from 'next/navigation';
+import { Inbox } from 'lucide-react';
import {
Table,
@@ -17,67 +18,101 @@ import {
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination';
-import { Separator } from '@/components/ui/separator';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
+import { getNetwork } from '@/utils/network';
+import MessageCard from '@/components/message/message-card';
import { columns } from './columns';
-import DesktopFilterToolbar from './DesktopFilterToolbar';
-import MobileFilterToolbar from './MobileFilterToolbar';
-import type { CHAIN } from '@/types/chains';
-import type { MessagePort, MessagePortQueryParams } from '@/graphql/type';
-
-const fadeInOut = {
- hidden: { opacity: 0 },
- visible: { opacity: 1, transition: { duration: 0.5 } }
-};
+import type { CompositeMessage } from '@/types/messages';
interface TableProps {
loading: boolean;
- dataSource: MessagePort[];
+ network: string;
+ dataSource: CompositeMessage[];
+ pageSize: number;
+ offset: number;
+ onPreviousPageClick: () => void;
+ onNextPageClick: () => void;
+ onResetFilters?: () => void;
}
-interface TableProps {
- hideDappFilter?: boolean;
- loading: boolean;
- network: string;
- chains: CHAIN[];
- dataSource: MessagePort[];
- offset: MessagePortQueryParams['offset'];
- onPreviousPageClick: React.MouseEventHandler;
- onNextPageClick: React.MouseEventHandler;
+function isInteractiveDescendant(target: EventTarget | null, container: HTMLElement): boolean {
+ if (!(target instanceof Element)) return false;
+ const interactive = target.closest('a,button,input,select,textarea,[role="link"],[data-interactive]');
+ return Boolean(interactive && interactive !== container);
+}
+
+function getColumnValue(message: CompositeMessage, dataIndex: string): string | undefined {
+ if (dataIndex === 'age') {
+ return typeof message.sentBlockTimestampSec === 'undefined'
+ ? undefined
+ : String(message.sentBlockTimestampSec);
+ }
+
+ const value = message[dataIndex as keyof CompositeMessage];
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number') return String(value);
+ return undefined;
}
const DataTable = ({
- hideDappFilter,
loading,
network,
- chains,
dataSource,
+ pageSize,
offset,
onPreviousPageClick,
- onNextPageClick
+ onNextPageClick,
+ onResetFilters
}: TableProps) => {
+ const router = useRouter();
const [activePageType, setActivePageType] = useState<'previous' | 'next' | ''>('');
+ const realRowCount = dataSource.filter((message) => message.status !== -1).length;
+ const visibleRowCount = realRowCount > 0 ? realRowCount : dataSource.length;
- const showPagination = Boolean(dataSource?.length || offset !== 0);
+ const showPagination = Boolean(visibleRowCount || offset !== 0);
const enablePreviousPage = offset !== 0;
- const enableNextPage = Boolean(dataSource?.length);
+ const enableNextPage = !loading && realRowCount >= pageSize;
+ const hasData = dataSource?.length > 0;
+
+ const paginationLabel = !enableNextPage && offset > 0
+ ? 'End of results'
+ : !enableNextPage && offset === 0
+ ? `${visibleRowCount} message${visibleRowCount === 1 ? '' : 's'}`
+ : `Showing ${offset + 1}–${offset + visibleRowCount}`;
+
+ const handlePreviousPageClick = useCallback(() => {
+ setActivePageType('previous');
+ onPreviousPageClick();
+ }, [onPreviousPageClick]);
- const handlePreviousPageClick = useCallback>(
- (e) => {
- setActivePageType('previous');
- onPreviousPageClick(e);
+ const handleNextPageClick = useCallback(() => {
+ setActivePageType('next');
+ onNextPageClick();
+ }, [onNextPageClick]);
+
+ const handleRowClick = useCallback(
+ (message: CompositeMessage, event?: React.MouseEvent) => {
+ if (message.status === -1) return;
+ if (event && isInteractiveDescendant(event.target, event.currentTarget)) return;
+ router.push(`/message/${message.msgId}?network=${getNetwork(network)}`);
},
- [onPreviousPageClick]
+ [router, network]
);
- const handleNextPageClick = useCallback>(
- (e) => {
- setActivePageType('next');
- onNextPageClick(e);
+ const handleRowKeyDown = useCallback(
+ (e: React.KeyboardEvent, message: CompositeMessage) => {
+ if (message.status === -1) return;
+ if (e.defaultPrevented || isInteractiveDescendant(e.target, e.currentTarget)) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleRowClick(message);
+ }
},
- [onNextPageClick]
+ [handleRowClick]
);
useEffect(() => {
@@ -87,124 +122,166 @@ const DataTable = ({
}, [loading]);
return (
-
-
-
-
-
-
-
- {columns.map((column, index) => (
-
- {column.title}
-
- ))}
-
-
-
- {dataSource?.length ? (
- dataSource.map((message) => (
-
- {columns.map((column, index) => (
-
-
- {column.render(
- (message as unknown as any)[column.dataIndex],
- message,
- index,
- network
+
+ {/* Mobile: Card list */}
+
+ {hasData ? (
+ dataSource.map((message) =>
+ message.status === -1 ? (
+
+
+
+
+
+ ) : (
+
+ )
+ )
+ ) : (
+
+
+
+
+
No messages found
+
Try adjusting your filters or check back later
+ {onResetFilters && (
+
+ Reset Filters
+
+ )}
+
+ )}
+
+
+ {/* Desktop: Table */}
+
+
+
+
+ {columns.map((column, index) => (
+
+ {column.title}
+
+ ))}
+
+
+
+ {hasData ? (
+ dataSource.map((message) => (
+ handleRowClick(message, e)}
+ onKeyDown={(e) => handleRowKeyDown(e, message)}
+ tabIndex={message.status !== -1 ? 0 : undefined}
+ >
+ {columns.map((column, index) => (
+
-
- ))}
+ style={column.width ? { minWidth: column.width } : undefined}
+ >
+
+ {column.render(
+ getColumnValue(message, column.dataIndex),
+ message,
+ 0,
+ network
+ )}
+
+
+ ))}
+
+ ))
+ ) : (
+
+
+
+
+
+
+
No messages found
+
Try adjusting your filters or check back later
+ {onResetFilters && (
+
+ Reset Filters
+
+ )}
+
+
- ))
- ) : (
-
-
- Sorry, there's no data available with your current filters selection, please
- try a different one.
-
-
- )}
-
-
+ )}
+
+
+
+
{showPagination ? (
-
-
- {
-
+
+
+ {paginationLabel}
+
+
+
+
- }
- {
-
+ {paginationLabel}
+
+
- }
-
-
+
+
+
) : null}
);
diff --git a/packages/web/src/components/error-display.tsx b/packages/web/src/components/error-display.tsx
index 72f67c1..22bbc68 100644
--- a/packages/web/src/components/error-display.tsx
+++ b/packages/web/src/components/error-display.tsx
@@ -16,10 +16,10 @@ const ErrorDisplay = ({ title, description, svgPath, svgPathLight }: Props) => {
-
+
{title}
-
-
+
+
{description}
diff --git a/packages/web/src/components/explorer-link-button.test.tsx b/packages/web/src/components/explorer-link-button.test.tsx
new file mode 100644
index 0000000..a7f0886
--- /dev/null
+++ b/packages/web/src/components/explorer-link-button.test.tsx
@@ -0,0 +1,28 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import ExplorerLinkButton from './explorer-link-button';
+
+describe('ExplorerLinkButton', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('renders accessible explorer link', () => {
+ render( );
+
+ const link = screen.getByRole('link', {
+ name: 'Open transaction in block explorer'
+ });
+ expect(link).not.toBeNull();
+ expect(link.getAttribute('href')).toBe('https://example.com/tx/0xabc');
+ });
+
+ it('renders nothing without url', () => {
+ render( );
+
+ expect(screen.queryByRole('link')).toBeNull();
+ });
+});
diff --git a/packages/web/src/components/explorer-link-button.tsx b/packages/web/src/components/explorer-link-button.tsx
index dbea5f9..338c650 100644
--- a/packages/web/src/components/explorer-link-button.tsx
+++ b/packages/web/src/components/explorer-link-button.tsx
@@ -18,11 +18,19 @@ const ExplorerLinkButton: React.FC = ({ url, size = 16
return (
-
-
+ }
+ >
+
diff --git a/packages/web/src/components/footer.tsx b/packages/web/src/components/footer.tsx
index 0181826..ab7742b 100644
--- a/packages/web/src/components/footer.tsx
+++ b/packages/web/src/components/footer.tsx
@@ -5,27 +5,26 @@ const currentYear = new Date().getUTCFullYear();
const Footer = () => {
return (
-