diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d63dfbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +target +dist +.DS_Store +.env +stellar_fees.db + diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json index 3cd8966..87b57ec 100644 --- a/packages/ui/package-lock.json +++ b/packages/ui/package-lock.json @@ -8,9 +8,13 @@ "name": "ui", "version": "0.1.0", "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", "next": "15.5.3", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "recharts": "^3.7.0", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -959,6 +963,42 @@ "node": ">=12.4.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -973,6 +1013,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1269,6 +1321,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1304,7 +1419,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1320,6 +1435,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -2307,6 +2428,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2353,9 +2483,130 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2435,6 +2686,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2714,6 +2971,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3153,6 +3420,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3579,6 +3852,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3621,6 +3904,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4453,6 +4745,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -5038,9 +5339,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5085,6 +5453,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5641,6 +6015,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", @@ -5679,6 +6063,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5948,6 +6338,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/packages/ui/package.json b/packages/ui/package.json index 47ffd94..a9fd3d4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,19 +9,23 @@ "lint": "eslint" }, "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.3" + "recharts": "^3.7.0", + "tailwind-merge": "^3.5.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.3", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/packages/ui/src/app/components/landing/DashboardPreview.tsx b/packages/ui/src/app/components/landing/DashboardPreview.tsx new file mode 100644 index 0000000..84f0d82 --- /dev/null +++ b/packages/ui/src/app/components/landing/DashboardPreview.tsx @@ -0,0 +1,61 @@ +'use client' +import React from "react"; +import MockDashboard from "./MockDashboard" + +const ANNOTATIONS = [ + { num: '01', label: 'Stat Cards', desc: 'Base fee, avg, status and spikes at a glance' }, + { num: '02', label: 'Fee History Chart', desc: 'Bar chart across 1H, 6H or 24H windows with spike detection' }, + { num: '03', label: 'Trend + Averages', desc: 'Rolling short/medium/long term analysis with % change' }, +] + +export function DashboardPreview() { + return ( +
+
+ {/* Header */} +
+
DASHBOARD PREVIEW
+

+ Everything in one view. +

+

+ From base fee to p99 distribution โ€” the full picture of what's + happening on the Stellar network right now. +

+
+ + {/* Mock with glow */} +
+
+ +
+ + {/* Annotations */} +
+ {ANNOTATIONS.map(({ num, label, desc }) => ( +
+
+ {num} +
+
+
+ {label} +
+
{desc}
+
+
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/FeatureOrbit.tsx b/packages/ui/src/app/components/landing/FeatureOrbit.tsx new file mode 100644 index 0000000..278193f --- /dev/null +++ b/packages/ui/src/app/components/landing/FeatureOrbit.tsx @@ -0,0 +1,95 @@ +'use client' +import React from "react"; + +const FEATURES = [ + { icon: 'โšก', label: 'Live Polling', desc: 'Fetches fee stats from Horizon every 10 seconds', angle: 0 }, + { icon: '๐Ÿ“Š', label: 'Percentile Breakdown', desc: 'P10 through P99 distribution on every refresh', angle: 72 }, + { icon: '๐Ÿ“ˆ', label: 'Trend Detection', desc: 'Rising, Normal, Declining, Congested status', angle: 144 }, + { icon: '๐Ÿ—„๏ธ', label: 'SQLite Persistence', desc: 'Fee history stored locally, queryable by window', angle: 216 }, + { icon: '๐Ÿ””', label: 'Spike Alerts', desc: 'Webhook delivery when fees spike above threshold', angle: 288 }, +] + +export function FeatureOrbit() { + return ( +
+
+ {/* Header */} +
+
CAPABILITIES
+

+ Built for developers. +

+
+ + {/* Orbit */} +
+ {/* Center orb */} +
+
+
+ + STELLAR
FEES +
+
+
+ + {/* Orbit rings */} +
+
+ + {/* Feature nodes */} + {FEATURES.map(({ icon, label, desc, angle }) => { + const rad = (angle - 90) * (Math.PI / 180) + const r = 144 + const x = 50 + (r / 4.6) * Math.cos(rad) + const y = 50 + (r / 4.6) * Math.sin(rad) + + return ( +
+ {/* Connector line */} +
+ {/* Node card */} +
+
{icon}
+
+ {label} +
+
+ {desc} +
+
+
+ ) + })} +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/Footer.tsx b/packages/ui/src/app/components/landing/Footer.tsx new file mode 100644 index 0000000..75e2980 --- /dev/null +++ b/packages/ui/src/app/components/landing/Footer.tsx @@ -0,0 +1,29 @@ +'use client' +import React from "react"; + +export function Footer() { + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/GridBeams.tsx b/packages/ui/src/app/components/landing/GridBeams.tsx new file mode 100644 index 0000000..1cfd846 --- /dev/null +++ b/packages/ui/src/app/components/landing/GridBeams.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useEffect, useRef } from 'react' + +interface Beam { + id: number + axis: 'x' | 'y' // travelling horizontally or vertically + line: number // which grid line (index) + pos: number // current position along the line (0โ€“1) + speed: number // units per frame + opacity: number // current opacity + length: number // tail length as fraction of canvas size + color: string + state: 'in' | 'travel' | 'out' + fadeAlpha: number +} + +const COLORS = [ + 'rgba(0, 255, 157,', // green + 'rgba(0, 212, 255,', // cyan + 'rgba(255, 210, 63,', // yellow (rare) +] + +const GRID_SIZE = 48 // must match globals.css backgroundSize + +let beamId = 0 + +function randomBeam(canvasW: number, canvasH: number): Beam { + const axis = Math.random() > 0.5 ? 'x' : 'y' + const cols = Math.floor(canvasW / GRID_SIZE) + const rows = Math.floor(canvasH / GRID_SIZE) + const line = Math.floor(Math.random() * (axis === 'x' ? rows : cols)) + const speed = 0.0008 + Math.random() * 0.0018 + const length = 0.08 + Math.random() * 0.12 + const roll = Math.random() + const color = roll > 0.85 + ? COLORS[2] + : roll > 0.4 + ? COLORS[1] + : COLORS[0] + + return { + id: beamId++, + axis, + line, + pos: -length, + speed, + opacity: 0, + length, + color, + state: 'in', + fadeAlpha: 0, + } +} + +export function GridBeams() { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + let animId: number + let beams: Beam[] = [] + let lastSpawn = 0 + + function resize() { + if (!canvas) return + canvas.width = window.innerWidth + canvas.height = window.innerHeight + } + + resize() + window.addEventListener('resize', resize) + + function spawnBeam() { + if (!canvas) return + beams.push(randomBeam(canvas.width, canvas.height)) + } + + // Seed a few beams immediately + for (let i = 0; i < 4; i++) spawnBeam() + + function draw(ts: number) { + if (!canvas || !ctx) return + + // Spawn new beam every 400โ€“900ms randomly + if (ts - lastSpawn > 400 + Math.random() * 500) { + spawnBeam() + lastSpawn = ts + } + + ctx.clearRect(0, 0, canvas.width, canvas.height) + + beams = beams.filter(b => b.state !== 'out' || b.fadeAlpha > 0.005) + + for (const beam of beams) { + // Advance position + beam.pos += beam.speed + + // Fade in + if (beam.state === 'in') { + beam.fadeAlpha = Math.min(beam.fadeAlpha + 0.04, 1) + if (beam.fadeAlpha >= 1) beam.state = 'travel' + } + + // Fade out when past end + if (beam.pos - beam.length > 1) { + beam.state = 'out' + beam.fadeAlpha = Math.max(beam.fadeAlpha - 0.03, 0) + } + + const alpha = beam.fadeAlpha + const W = canvas.width + const H = canvas.height + + if (beam.axis === 'x') { + // Beam travels left โ†’ right along a horizontal grid line + const y = (beam.line * GRID_SIZE) + const xEnd = beam.pos * W + const xStart = (beam.pos - beam.length) * W + + const grad = ctx.createLinearGradient(xStart, y, xEnd, y) + grad.addColorStop(0, `${beam.color} 0)`) + grad.addColorStop(0.4, `${beam.color} ${alpha * 0.15})`) + grad.addColorStop(1, `${beam.color} ${alpha * 0.9})`) + + ctx.beginPath() + ctx.strokeStyle = grad + ctx.lineWidth = 1.5 + ctx.shadowColor = `${beam.color} ${alpha * 0.6})` + ctx.shadowBlur = 6 + ctx.moveTo(xStart, y) + ctx.lineTo(xEnd, y) + ctx.stroke() + + // Leading dot + ctx.beginPath() + ctx.fillStyle = `${beam.color} ${alpha})` + ctx.shadowBlur = 10 + ctx.arc(xEnd, y, 2, 0, Math.PI * 2) + ctx.fill() + + } else { + // Beam travels top โ†’ bottom along a vertical grid line + const x = (beam.line * GRID_SIZE) + const yEnd = beam.pos * H + const yStart = (beam.pos - beam.length) * H + + const grad = ctx.createLinearGradient(x, yStart, x, yEnd) + grad.addColorStop(0, `${beam.color} 0)`) + grad.addColorStop(0.4, `${beam.color} ${alpha * 0.15})`) + grad.addColorStop(1, `${beam.color} ${alpha * 0.9})`) + + ctx.beginPath() + ctx.strokeStyle = grad + ctx.lineWidth = 1.5 + ctx.shadowColor = `${beam.color} ${alpha * 0.6})` + ctx.shadowBlur = 6 + ctx.moveTo(x, yStart) + ctx.lineTo(x, yEnd) + ctx.stroke() + + // Leading dot + ctx.beginPath() + ctx.fillStyle = `${beam.color} ${alpha})` + ctx.shadowBlur = 10 + ctx.arc(x, yEnd, 2, 0, Math.PI * 2) + ctx.fill() + } + + ctx.shadowBlur = 0 + } + + animId = requestAnimationFrame(draw) + } + + animId = requestAnimationFrame(draw) + + return () => { + cancelAnimationFrame(animId) + window.removeEventListener('resize', resize) + } + }, []) + + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/Hero.tsx b/packages/ui/src/app/components/landing/Hero.tsx new file mode 100644 index 0000000..06e3898 --- /dev/null +++ b/packages/ui/src/app/components/landing/Hero.tsx @@ -0,0 +1,167 @@ +'use client' +import Link from "next/link"; +import React, { useEffect, useState } from "react"; + +// โ”€โ”€โ”€ Live fee ticker simulation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const FEES = [ + 100, 3849, 8945, 11879, 42440, 61684, 100, 219192, 100, 3850, 137793, +]; +function useTickingFee() { + const [fee, setFee] = useState(100); + const [prev, setPrev] = useState(100); + + useEffect(() => { + const id = setInterval(() => { + const next = FEES[Math.floor(Math.random() * FEES.length)]; + setPrev(fee); + setFee(next); + }, 1800); + return () => clearInterval(id); + }, [fee]); + return { fee, prev, up: fee > prev }; +} + +// โ”€โ”€โ”€ Sparkline SVG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function Sparkline({ + data, + color = "#00ff9d", +}: { + data: number[]; + color?: string; +}) { + const max = Math.max(...data); + const min = Math.min(...data); + const w = 120, + h = 40; + const pts = data + .map((v, i) => { + const x = (i / (data.length - 1)) * w; + const y = h - ((v - min) / (max - min || 1)) * h; + return `${x},${y}`; + }) + .join(" "); + return ( + + + + + ); +} + +function Hero() { + const [mounted, setMounted] = useState(false); + const { fee, up } = useTickingFee(); + + useEffect(() => { + setMounted(true); + }, []); + + const sparkData = [ + 100, 3849, 100, 8945, 100, 42440, 100, 3849, 219192, 100, 61684, 100, 3849, + 100, 11879, + ]; + + return ( +
+
+ {/* eyebrow */} +
+ + + STELLAR TESTNET ยท LIVE + + ยท + + {fee.toLocaleString()} STROOPS + +
+ + {/* headline */} +

+ Know Before +
+ + You Send. + +

+ + {/* subline */} +

+ Real-time Stellar network fee intelligence. Track costs, detect + congestion, and time your transactions with confidence. +

+ + {/* CTAs */} +
+ + OPEN DASHBOARD + + โ†’ + + + + + + + VIEW SOURCE + +
+
+ + {/* Hero sparkline โ€” decorative right side */} +
+
+ LAST 15 LEDGERS +
+ +
+ 100 str + 219K str +
+
+
+ ); +} + +export default Hero; diff --git a/packages/ui/src/app/components/landing/HowItWorks.tsx b/packages/ui/src/app/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..e01e00a --- /dev/null +++ b/packages/ui/src/app/components/landing/HowItWorks.tsx @@ -0,0 +1,101 @@ +'use client' +import React from "react"; + +const STEPS = [ + { + step: '01', + title: 'Horizon API', + cmd: 'GET https://horizon-testnet.stellar.org/fee_stats', + desc: "Polls Stellar's Horizon API every 10 seconds, fetching live fee statistics including min, max, mode and full percentile distribution.", + color: '#00ff9d', + output: '{ "fee_charged": { "min": "100", "mode": "3849", "p95": "61684" ... } }', + }, + { + step: '02', + title: 'Rust Backend', + cmd: 'cargo run -- --network testnet', + desc: 'Axum-powered REST API processes, stores and analyses fee data. Insights engine computes rolling averages, detects spikes and trends.', + color: '#00d4ff', + output: 'INFO Insights updated โ€” 10000 points ยท short-term avg: 3849.0 stroops', + }, + { + step: '03', + title: 'Dashboard', + cmd: 'npm run dev', + desc: 'Next.js frontend polls the backend every 10 seconds and renders live data across all panels โ€” no page refresh needed.', + color: '#ffd23f', + output: 'โœ“ LIVE ยท UPDATED 01:38:45 ยท 10,000 transactions tracked', + }, +] + +export function HowItWorks() { + return ( +
+
+ {/* Header */} +
+
ARCHITECTURE
+

+ How it works. +

+
+ + {/* Steps */} +
+ {/* Vertical line */} +
+ + {STEPS.map(({ step, title, cmd, desc, color, output }) => ( +
+ {/* Dot */} +
+
+
+ +
+ STEP {step} +
+

+ {title} +

+

{desc}

+ + {/* Terminal block */} +
+
+ + + +
+
+
+ $ + {cmd} +
+
+ {output} +
+
+
+
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/LandingPage.tsx b/packages/ui/src/app/components/landing/LandingPage.tsx new file mode 100644 index 0000000..e8499a6 --- /dev/null +++ b/packages/ui/src/app/components/landing/LandingPage.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { DashboardPreview } from "./DashboardPreview"; +import { FeatureOrbit } from "./FeatureOrbit"; +import { HowItWorks } from "./HowItWorks"; +import { OpenSourceCTA } from "./OpenSourceCTA"; +import { Footer } from "./Footer"; +import Hero from "./Hero"; +import StatStrip from "./StatStrip"; +import Nav from "./Nav"; + +export function LandingPage() { + return ( +
+ {/* Scanline texture */} +
+ + {/* Grid background */} +
+ +
+ ); +} diff --git a/packages/ui/src/app/components/landing/MockDashboard.tsx b/packages/ui/src/app/components/landing/MockDashboard.tsx new file mode 100644 index 0000000..23884f8 --- /dev/null +++ b/packages/ui/src/app/components/landing/MockDashboard.tsx @@ -0,0 +1,103 @@ +'use client' + +const BARS = [0.3, 0.5, 0.4, 0.8, 0.6, 0.9, 0.5, 0.7, 0.4, 1.0, 0.6, 0.8, 0.5, 0.3, 0.6, 0.7, 0.4, 0.5, 0.8, 0.6] + +const STAT_CARDS = [ + { label: 'BASE FEE', val: '100 str', color: '#00ff9d' }, + { label: 'AVG FEE', val: '3,849 str', color: '#e2eaf4' }, + { label: 'STATUS', val: 'Normal', color: '#00ff9d' }, + { label: 'SPIKES', val: '0', color: '#e2eaf4' }, +] + +const ROLLING = [ + { l: 'Short-term', v: '3,849 str', c: '#00ff9d' }, + { l: 'Medium-term', v: '5,234 str', c: '#00d4ff' }, + { l: 'Long-term', v: '6,102 str', c: '#ffd23f' }, +] + +export default function MockDashboard() { + return ( +
+ {/* Titlebar */} +
+
+
+
+ STELLARFEES โ€” DASHBOARD +
+ + LIVE +
+
+ + {/* Stat cards */} +
+ {STAT_CARDS.map((s, i) => ( +
+
{s.label}
+
+ {s.val} +
+
+ ))} +
+ + {/* Chart */} +
+
+ FEE HISTORY ยท 1H +
+ {['1H', '6H', '24H'].map((w, i) => ( + {w} + ))} +
+
+
+ {BARS.map((h, i) => ( +
0.85 ? 'linear-gradient(to top, #ff4d6d60, #ff4d6d30)' + : h > 0.6 ? 'linear-gradient(to top, #ffd23f60, #ffd23f20)' + : 'linear-gradient(to top, #00ff9d60, #00ff9d20)', + border: `1px solid ${h > 0.85 ? '#ff4d6d30' : h > 0.6 ? '#ffd23f20' : '#00ff9d20'}`, + }} /> + ))} +
+
+ 01:18 + 01:38 +
+
+ + {/* Bottom panels */} +
+
+
TREND ANALYSIS
+
โ€” Normal
+ {['1H', '6H', '24H'].map(l => ( +
+ {l} change + โ†’ 0.0% +
+ ))} +
+
+
ROLLING AVERAGES
+ {ROLLING.map(({ l, v, c }) => ( +
+ {l} + {v} +
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/Nav.tsx b/packages/ui/src/app/components/landing/Nav.tsx new file mode 100644 index 0000000..94ebc4a --- /dev/null +++ b/packages/ui/src/app/components/landing/Nav.tsx @@ -0,0 +1,43 @@ +'use client' +import Link from "next/link"; +import React from "react"; + +function Nav() { + return ( + + ); +} + +export default Nav; diff --git a/packages/ui/src/app/components/landing/OpenSourceCTA.tsx b/packages/ui/src/app/components/landing/OpenSourceCTA.tsx new file mode 100644 index 0000000..e60c3c0 --- /dev/null +++ b/packages/ui/src/app/components/landing/OpenSourceCTA.tsx @@ -0,0 +1,61 @@ +'use client' +import Link from 'next/link' + +const GITHUB = 'https://github.com/StellarCommons/stellar-fee-tracker' + +function GitHubIcon({ size = 14 }: { size?: number }) { + return ( + + + + ) +} + +export function OpenSourceCTA() { + return ( +
+
+ {/* Badge */} +
+ + Open Source ยท MIT License +
+ + {/* Headline */} +

+ Free to use.
+ Free to fork. +

+ +

+ Built with Rust, Next.js and Stellar's Horizon API. + Star it, fork it, contribute to it. +

+ + {/* CTAs */} +
+ + + STAR ON GITHUB + + + OPEN DASHBOARD โ†’ + +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/app/components/landing/StatStrip.tsx b/packages/ui/src/app/components/landing/StatStrip.tsx new file mode 100644 index 0000000..feb29dc --- /dev/null +++ b/packages/ui/src/app/components/landing/StatStrip.tsx @@ -0,0 +1,97 @@ +'use client' + +import React, { RefObject, useEffect, useRef, useState } from "react"; + +// โ”€โ”€โ”€ Animated counter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function useCounter(target: number, duration = 1200) { + const [val, setVal] = useState(0); + const [started, setStarted] = useState(false); + const ref = useRef(null); + useEffect(() => { + const obs = new IntersectionObserver( + ([e]) => { + if (e.isIntersecting) setStarted(true); + }, + { threshold: 0.3 }, + ); + if (ref.current) obs.observe(ref.current); + return () => obs.disconnect(); + }, []); + useEffect(() => { + if (!started) return; + let start: number; + const step = (ts: number) => { + if (!start) start = ts; + const p = Math.min((ts - start) / duration, 1); + setVal(Math.floor(p * target)); + if (p < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }, [started, target, duration]); + return { val, ref }; +} + +function StatStrip() { + const { val: txCount, ref: txRef } = useCounter(10000); + const { val: ledgerCount, ref: ledgerRef } = useCounter(1351393); + const { val: pollRate, ref: pollRef } = useCounter(10); + return ( +
+
+ {[ + { + label: "TRANSACTIONS TRACKED", + ref: txRef, + val: txCount, + suffix: "+", + color: "#00ff9d", + }, + { + label: "LEDGERS PROCESSED", + ref: ledgerRef, + val: ledgerCount, + suffix: "", + color: "#00d4ff", + }, + { + label: "POLL INTERVAL", + ref: pollRef, + val: pollRate, + suffix: "s", + color: "#ffd23f", + }, + ].map(({ label, ref: r, val, suffix, color }) => ( +
} + className="flex-1 min-w-[160px]" + > +
+ {label} +
+
+ {val.toLocaleString()} + {suffix} +
+
+ ))} +
+
+ OPEN SOURCE +
+
+ MIT +
+
+
+
+ ); +} + +export default StatStrip; diff --git a/packages/ui/src/app/components/landing/hooks/useCounter.ts b/packages/ui/src/app/components/landing/hooks/useCounter.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/ui/src/app/components/landing/hooks/useTickingFee.ts b/packages/ui/src/app/components/landing/hooks/useTickingFee.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/ui/src/app/components/landing/ui/Sparkline.tsx b/packages/ui/src/app/components/landing/ui/Sparkline.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/ui/src/app/dashboard/page.tsx b/packages/ui/src/app/dashboard/page.tsx new file mode 100644 index 0000000..c67b50f --- /dev/null +++ b/packages/ui/src/app/dashboard/page.tsx @@ -0,0 +1,31 @@ +import { api } from '@/lib/api' +import { DashboardShell } from '@/components/dashboard/DashboardShell' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +async function fetchDashboardData() { + try { + const [current, history, trend, insights] = await Promise.allSettled([ + api.currentFees(), + api.feeHistory('1h'), + api.feeTrend(), + api.insights(), + ]) + + return { + current: current.status === 'fulfilled' ? current.value : null, + history: history.status === 'fulfilled' ? history.value : null, + trend: trend.status === 'fulfilled' ? trend.value : null, + insights: insights.status === 'fulfilled' ? insights.value : null, + error: null, + } + } catch (e) { + return { current: null, history: null, trend: null, insights: null, error: String(e) } + } +} + +export default async function DashboardPage() { + const data = await fetchDashboardData() + return +} \ No newline at end of file diff --git a/packages/ui/src/app/globals.css b/packages/ui/src/app/globals.css index a2dc41e..2886d8a 100644 --- a/packages/ui/src/app/globals.css +++ b/packages/ui/src/app/globals.css @@ -1,26 +1,95 @@ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap'); @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@theme { + --color-bg-base: #080b0f; + --color-bg-surface: #0d1117; + --color-bg-card: #111820; + --color-bg-border: #1e2a36; + + --color-accent-green: #00ff9d; + --color-accent-cyan: #00d4ff; + --color-accent-yellow: #ffd23f; + --color-accent-red: #ff4d6d; + --color-accent-dim: #1a3a2a; + + --color-text-primary: #e2eaf4; + --color-text-secondary: #7a9ab5; + --color-text-muted: #3d5a73; -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-mono: 'JetBrains Mono', monospace; + --font-display: 'Space Mono', monospace; + + --animate-slide-up: slideUp 0.4s ease-out forwards; + --animate-fade-in: fadeIn 0.6s ease-out forwards; + --animate-blink: blink 1.2s step-end infinite; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } } +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background-color: #080b0f; + color: #e2eaf4; + font-family: 'JetBrains Mono', monospace; + -webkit-font-smoothing: antialiased; +} + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #0d1117; } +::-webkit-scrollbar-thumb { background: #1e2a36; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #2d4056; } + +::selection { background: #00ff9d22; color: #00ff9d; } + +.scanlines::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, transparent, transparent 2px, + rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px + ); + z-index: 9999; +} + +.grid-bg { + background-image: + linear-gradient(rgba(0,255,157,0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,255,157,0.03) 1px, transparent 1px); + background-size: 40px 40px; +} + +.glow-green { text-shadow: 0 0 20px rgba(0,255,157,0.5); } +.glow-cyan { text-shadow: 0 0 20px rgba(0,212,255,0.5); } + +.card { + background: #111820; + border: 1px solid #1e2a36; + border-radius: 4px; +} + +.ticker { + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; +} + +.recharts-cartesian-grid-horizontal line, +.recharts-cartesian-grid-vertical line { + stroke: #1e2a36 !important; } +.recharts-tooltip-wrapper { outline: none !important; } \ No newline at end of file diff --git a/packages/ui/src/app/page.tsx b/packages/ui/src/app/page.tsx index a932894..54da78d 100644 --- a/packages/ui/src/app/page.tsx +++ b/packages/ui/src/app/page.tsx @@ -1,103 +1,78 @@ -import Image from "next/image"; +"use client"; -export default function Home() { +import Nav from "./components/landing/Nav"; +import Hero from "./components/landing/Hero"; +import StatStrip from "./components/landing/StatStrip"; +import MockDashboard from "./components/landing/MockDashboard"; +import { FeatureOrbit } from "./components/landing/FeatureOrbit"; +import { HowItWorks } from "./components/landing/HowItWorks"; +import { OpenSourceCTA } from "./components/landing/OpenSourceCTA"; +import { Footer } from "./components/landing/Footer"; +import { GridBeams } from "./components/landing/GridBeams"; + +export default function LandingPage() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+
+ {/* Scanline texture */} +
+ + {/* Grid bg */} +
+ -
- - Vercel logomark - Deploy now - - - Read our docs - +
- + + + + + +
); } diff --git a/packages/ui/src/components/dashboard/DashboardShell.tsx b/packages/ui/src/components/dashboard/DashboardShell.tsx new file mode 100644 index 0000000..42dc705 --- /dev/null +++ b/packages/ui/src/components/dashboard/DashboardShell.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { api } from '@/lib/api' +import type { + CurrentFeeResponse, + FeeHistoryResponse, + FeeTrendResponse, + InsightsResponse, +} from '@/lib/types' +import { TopBar } from './TopBar' +import { StatCards } from './StatCards' +import { PercentileRow } from './PercentileRow' +import { FeeChart } from './FeeChart' +import { TrendPanel } from './TrendPanel' +import { RollingAverages } from './RollingAverages' + +interface DashboardData { + current: CurrentFeeResponse | null + history: FeeHistoryResponse | null + trend: FeeTrendResponse | null + insights: InsightsResponse | null + error: string | null +} + +interface Props { + initialData: DashboardData +} + +const POLL_MS = 10_000 + +export function DashboardShell({ initialData }: Props) { + const [data, setData] = useState(initialData) + const [window, setWindow] = useState<'1h' | '6h' | '24h'>('1h') + const [lastUpdated, setLastUpdated] = useState(new Date()) + const [isRefreshing, setIsRefreshing] = useState(false) + const [tick, setTick] = useState(0) + + const refresh = useCallback(async (win = window) => { + setIsRefreshing(true) + try { + const [current, history, trend, insights] = await Promise.allSettled([ + api.currentFees(), + api.feeHistory(win), + api.feeTrend(), + api.insights(), + ]) + setData({ + current: current.status === 'fulfilled' ? current.value : data.current, + history: history.status === 'fulfilled' ? history.value : data.history, + trend: trend.status === 'fulfilled' ? trend.value : data.trend, + insights: insights.status === 'fulfilled' ? insights.value : data.insights, + error: null, + }) + setLastUpdated(new Date()) + setTick(t => t + 1) + } catch { + // keep stale data + } finally { + setIsRefreshing(false) + } + }, [window, data]) + + // Auto-poll every 10s + useEffect(() => { + const id = setInterval(() => refresh(), POLL_MS) + return () => clearInterval(id) + }, [refresh]) + + // Re-fetch history when window changes + const handleWindowChange = async (w: '1h' | '6h' | '24h') => { + setWindow(w) + refresh(w) + } + + const { current, history, trend, insights } = data + + return ( +
+ refresh()} + /> + +
+ + {/* Row 1 โ€” Stat Cards */} + + + {/* Row 2 โ€” Percentile strip */} + {current && ( + + )} + + {/* Row 3 โ€” Chart */} + + + {/* Row 4 โ€” Trend + Averages */} +
+ + +
+ + {/* Footer */} +
+ โ— + {' '}STELLAR TESTNET ยท POLLING EVERY 10s ยท BUILT WITH RUST + NEXT.JS +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/FeeChart.tsx b/packages/ui/src/components/dashboard/FeeChart.tsx new file mode 100644 index 0000000..e5f081c --- /dev/null +++ b/packages/ui/src/components/dashboard/FeeChart.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, +} from "recharts"; +import type { FeeHistoryResponse, FeeDataPoint } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface Props { + history: FeeHistoryResponse | null; + window: "1h" | "6h" | "24h"; + onWindowChange: (w: "1h" | "6h" | "24h") => void; +} + +const WINDOWS: ("1h" | "6h" | "24h")[] = ["1h", "6h", "24h"]; + +function formatTime(iso: string, win: string): string { + const d = new Date(iso); + if (win === "24h") { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ value: number }>; + label?: string; +} + +function CustomTooltip({ active, payload, label }: CustomTooltipProps) { + if (!active || !payload?.length) return null; + const fee = payload[0]?.value as number; + return ( +
+
{label}
+
+ {fee?.toLocaleString()} stroops +
+
+ ); +} + +export function FeeChart({ history, window, onWindowChange }: Props) { + const points = history?.fees ?? []; + + // Downsample for readability if too many points + const maxPoints = 200; + const step = + points.length > maxPoints ? Math.ceil(points.length / maxPoints) : 1; + const chartData = points + .filter((_, i) => i % step === 0) + .map((p: FeeDataPoint) => ({ + time: formatTime(p.timestamp, window), + fee: p.fee_amount, + ledger: p.ledger_sequence, + })); + + const avg = history?.summary.avg ?? 0; + const p95 = history?.summary.p95 ?? 0; + const count = history?.data_points ?? 0; + + return ( +
+ {/* Header */} +
+
+
+ Fee History +
+ {count > 0 && ( +
+ {count.toLocaleString()} transactions + ยท avg + {avg.toFixed(0)} str + ยท p95 + + {p95.toLocaleString()} str + +
+ )} +
+ + {/* Window toggle */} +
+ {WINDOWS.map((w) => ( + + ))} +
+
+ + {/* Chart */} + {chartData.length === 0 ? ( +
+ No data yet โ€” waiting for first poll cycle... +
+ ) : ( + + + + + `${v.toLocaleString()}`} + width={70} + /> + } /> + {avg > 0 && ( + + )} + {p95 > 0 && ( + + )} + + + + )} +
+ ); +} diff --git a/packages/ui/src/components/dashboard/PercentileRow.tsx b/packages/ui/src/components/dashboard/PercentileRow.tsx new file mode 100644 index 0000000..c3f2883 --- /dev/null +++ b/packages/ui/src/components/dashboard/PercentileRow.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { PercentileFees } from '@/lib/types' +import { formatStroops } from '@/lib/utils' + +interface Props { + percentiles: PercentileFees + tick: number +} + +const LABELS: { key: keyof PercentileFees; label: string; color: string }[] = [ + { key: 'p10', label: 'P10', color: 'text-accent-green border-accent-green/40' }, + { key: 'p20', label: 'P20', color: 'text-accent-green border-accent-green/30' }, + { key: 'p30', label: 'P30', color: 'text-accent-green border-accent-green/20' }, + { key: 'p40', label: 'P40', color: 'text-accent-cyan border-accent-cyan/30' }, + { key: 'p50', label: 'P50', color: 'text-accent-cyan border-accent-cyan/40' }, + { key: 'p60', label: 'P60', color: 'text-accent-cyan border-accent-cyan/30' }, + { key: 'p70', label: 'P70', color: 'text-accent-yellow border-accent-yellow/30' }, + { key: 'p80', label: 'P80', color: 'text-accent-yellow border-accent-yellow/40' }, + { key: 'p90', label: 'P90', color: 'text-accent-red border-accent-red/30' }, + { key: 'p95', label: 'P95', color: 'text-accent-red border-accent-red/50' }, + { key: 'p99', label: 'P99', color: 'text-accent-red border-accent-red/70' }, +] + +export function PercentileRow({ percentiles, tick }: Props) { + return ( +
+
+ Fee Percentiles ยท Last 5 Ledgers +
+
+ {LABELS.map(({ key, label, color }) => ( +
+ {label} + + {formatStroops(percentiles[key])} + +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/RollingAverages.tsx b/packages/ui/src/components/dashboard/RollingAverages.tsx new file mode 100644 index 0000000..e44e6bc --- /dev/null +++ b/packages/ui/src/components/dashboard/RollingAverages.tsx @@ -0,0 +1,99 @@ +'use client' + +import type { InsightsResponse } from '@/lib/types' +import { formatStroops, timeAgo, cn } from '@/lib/utils' + +interface Props { + insights: InsightsResponse | null + tick: number +} + +export function RollingAverages({ insights, tick }: Props) { + if (!insights) { + return ( +
+ Loading insights... +
+ ) + } + + const { rolling_averages, extremes } = insights + + const rows = [ + { + label: 'Short-term avg', + sub: '~5 min window', + result: rolling_averages.short_term, + accent: 'text-accent-green', + border: 'border-accent-green/20', + }, + { + label: 'Medium-term avg', + sub: '~1 hour window', + result: rolling_averages.medium_term, + accent: 'text-accent-cyan', + border: 'border-accent-cyan/20', + }, + { + label: 'Long-term avg', + sub: '~24 hour window', + result: rolling_averages.long_term, + accent: 'text-accent-yellow', + border: 'border-accent-yellow/20', + }, + ] + + return ( +
+
+ Rolling Averages +
+ +
+ {rows.map(({ label, sub, result, accent, border }) => ( +
+
+
{label}
+
+ {sub} + {result.is_partial && (partial)} +
+
+
+
+ {formatStroops(result.value)} +
+
+ {result.sample_count} samples +
+
+
+ ))} +
+ + {/* Extremes */} +
+
+ Period Extremes +
+
+
+ Min + + {formatStroops(extremes.current_min.value)} + +
+
+ Max + + {formatStroops(extremes.current_max.value)} + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/StatCards.tsx b/packages/ui/src/components/dashboard/StatCards.tsx new file mode 100644 index 0000000..2026ec1 --- /dev/null +++ b/packages/ui/src/components/dashboard/StatCards.tsx @@ -0,0 +1,97 @@ +'use client' + +import { TrendingUp, TrendingDown, Minus, AlertTriangle, Layers, BarChart2 } from 'lucide-react' +import type { CurrentFeeResponse, FeeTrendResponse } from '@/lib/types' +import { formatStroops, congestionColor, congestionBg, cn } from '@/lib/utils' + +interface Props { + current: CurrentFeeResponse | null + trend: FeeTrendResponse | null + tick: number +} + +function StatCard({ + label, + value, + sub, + icon: Icon, + accent = false, + className = '', +}: { + label: string + value: string + sub?: React.ReactNode + icon: React.ElementType + accent?: boolean + className?: string +}) { + return ( +
+
+ {label} + +
+
+ {value} +
+ {sub && ( +
{sub}
+ )} +
+ ) +} + +export function StatCards({ current, trend, tick }: Props) { + const baseFee = current ? formatStroops(current.base_fee) : 'โ€”' + const avgFee = current ? formatStroops(current.avg_fee) : 'โ€”' + const status = trend?.status ?? 'Normal' + const spikes = trend?.recent_spike_count ?? 0 + const strength = trend?.trend_strength ?? 'โ€”' + + const TrendIcon = status === 'Rising' || status === 'Congested' + ? TrendingUp + : status === 'Declining' + ? TrendingDown + : Minus + + return ( +
+ last ledger} + icon={Layers} + accent + /> + mode over 5 ledgers} + icon={BarChart2} + /> + {strength} trend} + icon={TrendIcon} + className={congestionBg(status)} + /> + 0 + ? fee anomalies detected + : all clear + } + icon={AlertTriangle} + /> +
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/TopBar.tsx b/packages/ui/src/components/dashboard/TopBar.tsx new file mode 100644 index 0000000..c924c80 --- /dev/null +++ b/packages/ui/src/components/dashboard/TopBar.tsx @@ -0,0 +1,63 @@ +'use client' + +import { RefreshCw, Activity, Zap } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface Props { + lastUpdated: Date + isRefreshing: boolean + onRefresh: () => void +} + +export function TopBar({ lastUpdated, isRefreshing, onRefresh }: Props) { + const timeStr = lastUpdated.toLocaleTimeString([], { + hour: '2-digit', minute: '2-digit', second: '2-digit' + }) + + return ( +
+
+ + {/* Left โ€” branding */} +
+
+ + + StellarFees + +
+ | + + TESTNET + +
+ + {/* Center โ€” live indicator */} +
+ + LIVE + ยท + UPDATED {timeStr} +
+ + {/* Right โ€” refresh */} + +
+
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/TrendPanel.tsx b/packages/ui/src/components/dashboard/TrendPanel.tsx new file mode 100644 index 0000000..1e3ec2f --- /dev/null +++ b/packages/ui/src/components/dashboard/TrendPanel.tsx @@ -0,0 +1,89 @@ +'use client' + +import { TrendingUp, TrendingDown, Minus } from 'lucide-react' +import type { FeeTrendResponse } from '@/lib/types' +import { pctColor, pctArrow, congestionColor, cn } from '@/lib/utils' + +interface Props { + trend: FeeTrendResponse | null + tick: number +} + +export function TrendPanel({ trend, tick }: Props) { + if (!trend) { + return ( +
+ Loading trend data... +
+ ) + } + + const { status, trend_strength, changes, recent_spike_count, predicted_congestion_minutes } = trend + + const StatusIcon = + status === 'Rising' || status === 'Congested' ? TrendingUp : + status === 'Declining' ? TrendingDown : Minus + + const strengthDots = trend_strength === 'Strong' ? 3 : trend_strength === 'Moderate' ? 2 : 1 + + return ( +
+
+ Trend Analysis +
+ + {/* Status + strength */} +
+
+ + + {status} + +
+
+ {[1, 2, 3].map(i => ( +
+ ))} + {trend_strength} +
+
+ + {/* Pct changes */} +
+ {([ + ['1H', changes['1h_pct']], + ['6H', changes['6h_pct']], + ['24H', changes['24h_pct']], + ] as [string, number | null][]).map(([label, pct]) => ( +
+ {label} change + + {pctArrow(pct)} + +
+ ))} +
+ + {/* Extras */} +
+ Recent spikes + 0 ? 'text-accent-yellow' : 'text-accent-green'}> + {recent_spike_count} + +
+ + {predicted_congestion_minutes !== null && ( +
+ Predicted congestion + {predicted_congestion_minutes}m +
+ )} +
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts new file mode 100644 index 0000000..b89bd50 --- /dev/null +++ b/packages/ui/src/lib/api.ts @@ -0,0 +1,27 @@ +import type { + CurrentFeeResponse, + FeeHistoryResponse, + FeeTrendResponse, + InsightsResponse, + HealthResponse, +} from './types' + +const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080' + +async function get(path: string): Promise { + const res = await fetch(`${BASE}${path}`, { + next: { revalidate: 0 }, // always fresh + }) + if (!res.ok) { + throw new Error(`API error ${res.status} on ${path}`) + } + return res.json() as Promise +} + +export const api = { + currentFees: () => get('/fees/current'), + feeHistory: (window = '1h') => get(`/fees/history?window=${window}`), + feeTrend: () => get('/fees/trend'), + insights: () => get('/insights'), + health: () => get('/health'), +} \ No newline at end of file diff --git a/packages/ui/src/lib/types.ts b/packages/ui/src/lib/types.ts new file mode 100644 index 0000000..e548da2 --- /dev/null +++ b/packages/ui/src/lib/types.ts @@ -0,0 +1,103 @@ +// ---- /fees/current ---- +export interface PercentileFees { + p10: string + p20: string + p30: string + p40: string + p50: string + p60: string + p70: string + p80: string + p90: string + p95: string + p99: string +} + +export interface CurrentFeeResponse { + base_fee: string + min_fee: string + max_fee: string + avg_fee: string + percentiles: PercentileFees +} + +// ---- /fees/history ---- +export interface FeeDataPoint { + fee_amount: number + timestamp: string + transaction_hash: string + ledger_sequence: number +} + +export interface FeeSummary { + min: number + max: number + avg: number + p50: number + p95: number +} + +export interface FeeHistoryResponse { + window: string + from: string + to: string + data_points: number + fees: FeeDataPoint[] + summary: FeeSummary +} + +// ---- /fees/trend ---- +export interface TrendChanges { + '1h_pct': number | null + '6h_pct': number | null + '24h_pct': number | null +} + +export interface FeeTrendResponse { + status: 'Normal' | 'Rising' | 'Congested' | 'Declining' + trend_strength: 'Weak' | 'Moderate' | 'Strong' + changes: TrendChanges + recent_spike_count: number + predicted_congestion_minutes: number | null + last_updated: string +} + +// ---- /insights ---- +export interface AverageResult { + value: number + sample_count: number + is_partial: boolean + calculated_at: string +} + +export interface RollingAverages { + short_term: AverageResult + medium_term: AverageResult + long_term: AverageResult +} + +export interface ExtremeValue { + value: number + timestamp: string + transaction_hash: string +} + +export interface FeeExtremes { + current_min: ExtremeValue + current_max: ExtremeValue + period_start: string + period_end: string +} + +export interface InsightsResponse { + rolling_averages: RollingAverages + extremes: FeeExtremes + last_updated: string + // these exist in the response but we can ignore them for now + congestion_trends?: unknown + data_quality?: unknown +} +// ---- /health ---- +export interface HealthResponse { + status: string +} \ No newline at end of file diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts new file mode 100644 index 0000000..7f01731 --- /dev/null +++ b/packages/ui/src/lib/utils.ts @@ -0,0 +1,63 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function formatStroops(stroops: number | string): string { + const n = typeof stroops === 'string' ? parseFloat(stroops) : stroops + if (n >= 10_000_000) return `${(n / 10_000_000).toFixed(2)} XLM` + if (n >= 1_000) return `${n.toLocaleString()} str` + return `${n} str` +} + +export function formatNumber(n: number): string { + return n.toLocaleString() +} + +export function pctColor(pct: number | null): string { + if (pct === null) return 'text-text-secondary' + if (pct > 10) return 'text-accent-red' + if (pct > 0) return 'text-accent-yellow' + if (pct < -10) return 'text-accent-green' + if (pct < 0) return 'text-accent-cyan' + return 'text-text-secondary' +} + +export function pctArrow(pct: number | null): string { + if (pct === null) return 'โ€”' + if (pct > 0) return `โ–ฒ +${pct.toFixed(1)}%` + if (pct < 0) return `โ–ผ ${pct.toFixed(1)}%` + return `โ†’ 0.0%` +} + +export function congestionColor(status: string): string { + switch (status) { + case 'Congested': return 'text-accent-red' + case 'Rising': return 'text-accent-yellow' + case 'Declining': return 'text-accent-cyan' + default: return 'text-accent-green' + } +} + +export function congestionBg(status: string): string { + switch (status) { + case 'Congested': return 'bg-red-950/30 border-accent-red/30' + case 'Rising': return 'bg-yellow-950/30 border-accent-yellow/30' + case 'Declining': return 'bg-cyan-950/30 border-accent-cyan/30' + default: return 'bg-accent-dim border-accent-green/30' + } +} + +export function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const s = Math.floor(diff / 1000) + if (s < 60) return `${s}s ago` + if (s < 3600) return `${Math.floor(s / 60)}m ago` + return `${Math.floor(s / 3600)}h ago` +} + +export function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} \ No newline at end of file