From 8c1776b93f219834bd1bfd59ae0cc8627b93b0fe Mon Sep 17 00:00:00 2001 From: snoopy1412 Date: Mon, 23 Feb 2026 21:11:37 +0800 Subject: [PATCH 1/6] feat(web): overhaul UI architecture and harden search flow - refactor app/pages/components structure for the web v2 redesign - modernize lint/build/test setup and supporting config - improve message search UX, including mobile overlay close-on-navigate - expand test coverage across routes, components, hooks, and API handlers --- .gitignore | 2 +- packages/web/.eslintignore | 1 - packages/web/.eslintrc.cjs | 50 + packages/web/.eslintrc.json | 67 - packages/web/components.json | 11 +- packages/web/eslint.config.mjs | 77 + packages/web/next.config.mjs | 10 +- packages/web/package.json | 94 +- packages/web/postcss.config.mjs | 4 +- .../src/app/api/message-stats/route.test.ts | 237 + .../web/src/app/api/message-stats/route.ts | 137 + .../app/dapp/[address]/DappAddressHeader.tsx | 51 + packages/web/src/app/dapp/[address]/page.tsx | 54 +- packages/web/src/app/error.test.tsx | 32 + packages/web/src/app/error.tsx | 27 +- packages/web/src/app/globals.css | 368 +- packages/web/src/app/layout.tsx | 34 +- packages/web/src/app/loading.test.tsx | 16 + packages/web/src/app/loading.tsx | 54 +- .../[id]/components/AddressInfo.test.tsx | 49 + .../message/[id]/components/AddressInfo.tsx | 26 +- .../[id]/components/ClientPage.test.tsx | 161 + .../message/[id]/components/ClientPage.tsx | 70 +- .../app/message/[id]/components/NotFound.tsx | 38 +- .../app/message/[id]/components/OrmpInfo.tsx | 55 - .../message/[id]/components/Pending.test.tsx | 95 + .../app/message/[id]/components/Pending.tsx | 153 +- .../message/[id]/components/ProtocolInfo.tsx | 4 +- .../[id]/components/TransactionHashInfo.tsx | 33 +- .../app/message/[id]/components/TxDetail.tsx | 196 +- .../[id]/components/sections/DetailHeader.tsx | 42 + .../sections/EventEvidencePanel.tsx | 77 + .../sections/MessageInfoSection.tsx | 87 + .../components/sections/OverviewPanel.tsx | 83 + .../sections/ProtocolDetailsSection.tsx | 81 + .../components/sections/QuickInfoBar.test.tsx | 116 + .../[id]/components/sections/QuickInfoBar.tsx | 145 + .../components/sections/RawDataSection.tsx | 64 + .../sections/StepProgressBar.test.ts | 93 + .../components/sections/StepProgressBar.tsx | 80 + .../sections/TransactionSummary.tsx | 166 + .../sections/observed-events-model.ts | 224 + .../[id]/components/sections/shared.tsx | 42 + .../web/src/app/message/[id]/error.test.tsx | 32 + packages/web/src/app/message/[id]/error.tsx | 32 + .../web/src/app/message/[id]/loading.test.tsx | 16 + packages/web/src/app/message/[id]/loading.tsx | 95 + .../app/message/[id]/network-policy.test.ts | 48 + .../src/app/message/[id]/network-policy.ts | 23 + packages/web/src/app/message/[id]/page.tsx | 44 +- packages/web/src/app/not-found.tsx | 10 +- packages/web/src/app/page.tsx | 28 +- .../web/src/app/sender/[address]/page.tsx | 61 +- packages/web/src/app/sw.ts | 26 - .../address-display-filter-dapp-remark.tsx | 11 +- .../web/src/components/chain-tx-display.tsx | 69 +- .../charts/chain-distribution-chart.tsx | 189 + .../src/components/charts/chart-container.tsx | 30 + .../components/charts/message-trend-chart.tsx | 137 + .../components/clipboard-icon-button.test.tsx | 91 + .../src/components/clipboard-icon-button.tsx | 85 +- .../DesktopFilterToolbar/TableChainFilter.tsx | 159 +- .../DesktopFilterToolbar/TableDateFilter.tsx | 62 +- .../TableMultiSelectFilter.tsx | 202 +- .../filterTriggerStyles.ts | 5 + .../data-table/DesktopFilterToolbar/index.tsx | 50 +- .../MobileFilterToolbar/DropdownButton.tsx | 6 +- .../MobileFilterToolbar/FilterBack.tsx | 10 +- .../MobileFilterToolbar/TableChainFilter.tsx | 133 +- .../MobileFilterToolbar/TableDateFilter.tsx | 12 +- .../TableMultiSelectFilter.tsx | 137 +- .../data-table/MobileFilterToolbar/index.tsx | 255 +- .../web/src/components/data-table/columns.tsx | 280 +- .../data-table/filter-option-policy.test.ts | 17 + .../data-table/filter-option-policy.ts | 5 + .../data-table/hooks/useChainFilterLogic.ts | 63 +- .../components/data-table/hooks/useFilter.ts | 72 +- .../src/components/data-table/index.test.tsx | 143 + .../web/src/components/data-table/index.tsx | 345 +- packages/web/src/components/error-display.tsx | 6 +- .../components/explorer-link-button.test.tsx | 28 + .../src/components/explorer-link-button.tsx | 14 +- packages/web/src/components/footer.tsx | 35 +- packages/web/src/components/header-logo.tsx | 4 +- packages/web/src/components/header.tsx | 80 +- packages/web/src/components/icon/ormp.tsx | 4 +- .../web/src/components/message-port-table.tsx | 421 +- .../message-progress-stats.test.tsx | 71 + .../src/components/message-progress-stats.tsx | 34 +- .../web/src/components/message-status.tsx | 68 +- .../components/message/message-card.test.tsx | 97 + .../src/components/message/message-card.tsx | 171 + .../src/components/message/step-indicator.tsx | 76 + packages/web/src/components/mode-toggle.tsx | 23 +- .../src/components/network-switcher.test.ts | 28 + .../web/src/components/network-switcher.tsx | 48 +- .../components/search-bar.component.test.tsx | 142 + .../web/src/components/search-bar.test.ts | 173 + packages/web/src/components/search-bar.tsx | 287 +- .../web/src/components/selected-labels.tsx | 2 +- .../src/components/shared/live-indicator.tsx | 17 + .../src/components/shared/toast-provider.tsx | 22 + packages/web/src/components/stat-card.tsx | 75 +- .../src/components/stats-container.test.tsx | 39 + .../web/src/components/stats-container.tsx | 194 +- packages/web/src/components/ui/avatar.tsx | 144 +- .../web/src/components/ui/back-to-top.tsx | 12 +- packages/web/src/components/ui/badge.tsx | 46 +- .../web/src/components/ui/button-variants.ts | 40 + packages/web/src/components/ui/button.tsx | 67 +- packages/web/src/components/ui/calendar.tsx | 254 +- packages/web/src/components/ui/card.tsx | 153 +- packages/web/src/components/ui/checkbox.tsx | 43 +- .../web/src/components/ui/collapsible.tsx | 77 + packages/web/src/components/ui/command.tsx | 256 +- packages/web/src/components/ui/dialog.tsx | 215 +- .../web/src/components/ui/dropdown-menu.tsx | 396 +- .../web/src/components/ui/flip-words.test.tsx | 60 + packages/web/src/components/ui/flip-words.tsx | 35 +- .../web/src/components/ui/input-group.tsx | 149 + packages/web/src/components/ui/input.tsx | 39 +- packages/web/src/components/ui/pagination.tsx | 33 +- packages/web/src/components/ui/popover.tsx | 103 +- packages/web/src/components/ui/select.tsx | 305 +- packages/web/src/components/ui/separator.tsx | 28 +- packages/web/src/components/ui/sheet.tsx | 208 +- packages/web/src/components/ui/skeleton.tsx | 14 +- packages/web/src/components/ui/table.tsx | 160 +- packages/web/src/components/ui/textarea.tsx | 18 + packages/web/src/components/ui/tooltip.tsx | 76 +- packages/web/src/config/api.ts | 2 +- packages/web/src/config/dapps.ts | 13 + packages/web/src/config/font.ts | 4 +- packages/web/src/config/site.ts | 1 + packages/web/src/dappRemark/config.json | 10 - packages/web/src/graphql/fragments.ts | 68 +- packages/web/src/graphql/queries.ts | 170 +- .../src/graphql/services.status-scan.test.ts | 176 + packages/web/src/graphql/services.ts | 1063 ++- packages/web/src/graphql/type.ts | 301 +- packages/web/src/hooks/breakpoint.ts | 25 +- packages/web/src/hooks/services.test.ts | 20 + packages/web/src/hooks/services.ts | 160 +- .../src/hooks/use-chain-distribution.test.ts | 55 + .../web/src/hooks/use-chain-distribution.ts | 111 + .../web/src/hooks/use-message-trend.test.ts | 39 + packages/web/src/hooks/use-message-trend.ts | 103 + packages/web/src/hooks/useQueryParamState.ts | 67 +- packages/web/src/provider/AppProvider.tsx | 29 +- packages/web/src/store/filter.ts | 42 - packages/web/src/types/filter.ts | 2 +- packages/web/src/types/messages.ts | 55 + packages/web/src/utils/dapp.ts | 51 +- packages/web/src/utils/date.test.ts | 58 + packages/web/src/utils/date.ts | 91 +- packages/web/src/utils/network.ts | 15 +- packages/web/src/utils/number.ts | 4 +- packages/web/src/utils/string.ts | 10 +- ...tailwind.config.ts => tailwind.config.mjs} | 7 +- packages/web/tsconfig.json | 19 +- packages/web/vitest.config.ts | 21 + pnpm-lock.yaml | 5890 +++++++++++------ 162 files changed, 14865 insertions(+), 5396 deletions(-) delete mode 100644 packages/web/.eslintignore create mode 100644 packages/web/.eslintrc.cjs delete mode 100644 packages/web/.eslintrc.json create mode 100644 packages/web/eslint.config.mjs create mode 100644 packages/web/src/app/api/message-stats/route.test.ts create mode 100644 packages/web/src/app/api/message-stats/route.ts create mode 100644 packages/web/src/app/dapp/[address]/DappAddressHeader.tsx create mode 100644 packages/web/src/app/error.test.tsx create mode 100644 packages/web/src/app/loading.test.tsx create mode 100644 packages/web/src/app/message/[id]/components/AddressInfo.test.tsx create mode 100644 packages/web/src/app/message/[id]/components/ClientPage.test.tsx delete mode 100644 packages/web/src/app/message/[id]/components/OrmpInfo.tsx create mode 100644 packages/web/src/app/message/[id]/components/Pending.test.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/DetailHeader.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/EventEvidencePanel.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/MessageInfoSection.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/OverviewPanel.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/ProtocolDetailsSection.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/QuickInfoBar.test.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/QuickInfoBar.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/RawDataSection.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/StepProgressBar.test.ts create mode 100644 packages/web/src/app/message/[id]/components/sections/StepProgressBar.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/TransactionSummary.tsx create mode 100644 packages/web/src/app/message/[id]/components/sections/observed-events-model.ts create mode 100644 packages/web/src/app/message/[id]/components/sections/shared.tsx create mode 100644 packages/web/src/app/message/[id]/error.test.tsx create mode 100644 packages/web/src/app/message/[id]/error.tsx create mode 100644 packages/web/src/app/message/[id]/loading.test.tsx create mode 100644 packages/web/src/app/message/[id]/loading.tsx create mode 100644 packages/web/src/app/message/[id]/network-policy.test.ts create mode 100644 packages/web/src/app/message/[id]/network-policy.ts delete mode 100644 packages/web/src/app/sw.ts create mode 100644 packages/web/src/components/charts/chain-distribution-chart.tsx create mode 100644 packages/web/src/components/charts/chart-container.tsx create mode 100644 packages/web/src/components/charts/message-trend-chart.tsx create mode 100644 packages/web/src/components/clipboard-icon-button.test.tsx create mode 100644 packages/web/src/components/data-table/DesktopFilterToolbar/filterTriggerStyles.ts create mode 100644 packages/web/src/components/data-table/filter-option-policy.test.ts create mode 100644 packages/web/src/components/data-table/filter-option-policy.ts create mode 100644 packages/web/src/components/data-table/index.test.tsx create mode 100644 packages/web/src/components/explorer-link-button.test.tsx create mode 100644 packages/web/src/components/message-progress-stats.test.tsx create mode 100644 packages/web/src/components/message/message-card.test.tsx create mode 100644 packages/web/src/components/message/message-card.tsx create mode 100644 packages/web/src/components/message/step-indicator.tsx create mode 100644 packages/web/src/components/network-switcher.test.ts create mode 100644 packages/web/src/components/search-bar.component.test.tsx create mode 100644 packages/web/src/components/search-bar.test.ts create mode 100644 packages/web/src/components/shared/live-indicator.tsx create mode 100644 packages/web/src/components/shared/toast-provider.tsx create mode 100644 packages/web/src/components/stats-container.test.tsx create mode 100644 packages/web/src/components/ui/button-variants.ts create mode 100644 packages/web/src/components/ui/collapsible.tsx create mode 100644 packages/web/src/components/ui/flip-words.test.tsx create mode 100644 packages/web/src/components/ui/input-group.tsx create mode 100644 packages/web/src/components/ui/textarea.tsx create mode 100644 packages/web/src/config/dapps.ts delete mode 100644 packages/web/src/dappRemark/config.json create mode 100644 packages/web/src/graphql/services.status-scan.test.ts create mode 100644 packages/web/src/hooks/services.test.ts create mode 100644 packages/web/src/hooks/use-chain-distribution.test.ts create mode 100644 packages/web/src/hooks/use-chain-distribution.ts create mode 100644 packages/web/src/hooks/use-message-trend.test.ts create mode 100644 packages/web/src/hooks/use-message-trend.ts delete mode 100644 packages/web/src/store/filter.ts create mode 100644 packages/web/src/types/messages.ts create mode 100644 packages/web/src/utils/date.test.ts rename packages/web/{tailwind.config.ts => tailwind.config.mjs} (94%) create mode 100644 packages/web/vitest.config.ts 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/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..48cbdc9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -5,62 +5,52 @@ "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 ( +
+ +
+ {dappName ? ( + <> +

+ +

+
+ + {toShortText({ text: address, frontLength: 6, backLength: 4 })} + + +
+ + ) : ( +
+

+ {address} +

+ +
+ )} +
+
+ ); +} 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. + + +
+ ) : 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 +
); }; 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}

- + + {phase !== 'initial' ? ( +

You can stay on this page while indexing catches up.

+ ) : null} + +
+ {isActionable ? ( + <> + {canRetry ? ( + + ) : 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 +
+ +
+ + {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