├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .nvmrc
├── .prettierrc
├── LICENSE
├── README.md
├── components.json
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
├── algorand-coin.webp
├── apple-touch-icon.png
├── favicon-96x96.png
├── favicon.ico
├── favicon.svg
├── logo.png
├── logo.svg
├── preview.png
├── site.webmanifest
├── web-app-manifest-192x192.png
└── web-app-manifest-512x512.png
├── screenshot.png
├── src
├── App.css
├── assets
│ └── react.svg
├── components
│ ├── address-input.tsx
│ ├── address
│ │ ├── add-address.tsx
│ │ ├── address-breadcrumb.tsx
│ │ ├── address-filters.tsx
│ │ ├── address-view.tsx
│ │ ├── charts
│ │ │ ├── block-reward-intervals.tsx
│ │ │ ├── cumulative-blocks-chart.tsx
│ │ │ ├── cumulative-rewards-chart.tsx
│ │ │ └── reward-by-day-hour-chart.tsx
│ │ ├── csv-export-dialog.tsx
│ │ ├── search-bar.tsx
│ │ ├── settings.tsx
│ │ └── stats
│ │ │ ├── panel.tsx
│ │ │ ├── panels
│ │ │ ├── apy-panel.tsx
│ │ │ ├── blocks-per-day-panel.tsx
│ │ │ ├── rewards-per-day-panel.tsx
│ │ │ └── total-panel.tsx
│ │ │ ├── percentage-change.tsx
│ │ │ ├── stat-box.tsx
│ │ │ ├── stats-panels.tsx
│ │ │ └── status
│ │ │ ├── anticipated-time-between-blocks-badge.tsx
│ │ │ ├── anxiety-box.tsx
│ │ │ ├── anxiety-card.tsx
│ │ │ ├── anxiety-gauge.tsx
│ │ │ ├── balance-badge.tsx
│ │ │ ├── balance-card.tsx
│ │ │ ├── incentive-eligibility-badge.tsx
│ │ │ ├── last-block-proposed-badge.tsx
│ │ │ ├── last-heartbeat-badge.tsx
│ │ │ ├── participation-key-badge.tsx
│ │ │ ├── status-badge.tsx
│ │ │ └── status.tsx
│ ├── algo-amount-display.tsx
│ ├── algorand-logo.tsx
│ ├── coins-spread.tsx
│ ├── copy-to-clipboard.tsx
│ ├── dot-badge.tsx
│ ├── error.tsx
│ ├── explorer-link.tsx
│ ├── footer.tsx
│ ├── github-corner.tsx
│ ├── heatmap
│ │ ├── day-view.tsx
│ │ ├── heatmap.tsx
│ │ ├── month-view.tsx
│ │ └── types.ts
│ ├── number-display.tsx
│ ├── search-bar.tsx
│ ├── spinner.tsx
│ ├── staking-cta.tsx
│ ├── theme-provider.tsx
│ └── ui
│ │ ├── alert.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── chart.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── mobile-tooltip.tsx
│ │ ├── popover.tsx
│ │ ├── select.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── start-date-picker.tsx
│ │ ├── toggle-group.tsx
│ │ ├── toggle.tsx
│ │ └── tooltip.tsx
├── constants.ts
├── hooks
│ ├── useAccounts.ts
│ ├── useAlgoPrice.ts
│ ├── useAlgorandAddress.ts
│ ├── useAverageBlockTime.ts
│ ├── useBlock.ts
│ ├── useBlocksStats.test.ts
│ ├── useBlocksStats.ts
│ ├── useCurrentRound.ts
│ ├── useIsSmallScreen.ts
│ ├── useRewardTransactions.ts
│ └── useStakeInfo.ts
├── lib
│ ├── csv-columns.ts
│ ├── csv-export.ts
│ ├── date-utils.ts
│ ├── indexer-client.ts
│ ├── mockTimezone.ts
│ ├── utils.test.ts
│ └── utils.ts
├── main.tsx
├── queries
│ ├── getAccountsBlockHeaders.ts
│ └── getResolvedNFD.ts
├── routeTree.gen.ts
├── routes
│ ├── $addresses.tsx
│ ├── __root.tsx
│ └── index.tsx
└── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── vitest.config.ts
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: 🔄 Checkout repository
15 | uses: actions/checkout@v4
16 |
17 | - name: 🟢 Setup Node.js
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: "22"
21 | cache: "npm"
22 |
23 | - name: 📦 Install dependencies
24 | run: npm ci
25 |
26 | - name: 🔍 Lint check
27 | run: npm run lint
28 |
29 | - name: 💅 Prettier check
30 | run: npm run prettier:check
31 |
32 | - name: 📝 Type check
33 | run: npm run type:check
34 |
35 | - name: 🏗️ Build
36 | run: npm run build
37 |
38 | - name: 🧪 Run tests
39 | run: npm run test
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"]
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 @Cryptomalgo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AlgoNodeRewards
2 |
3 | [](https://github.com/cryptomalgo/algonoderewards/actions/workflows/ci.yml)
4 | [](https://opensource.org/licenses/MIT)
5 |
6 | [](https://react.dev/)
7 | [](https://www.typescriptlang.org/)
8 | [](https://vitejs.dev/)
9 |
10 | [](https://x.com/cryptomalgo)
11 |
12 | A React application to track and visualize the rewards from running an Algorand node, using [Nodely](https://nodely.io/) API.
13 |
14 | ## Website
15 |
16 | You can access the website at [algonoderewards.com](https://algonoderewards.com)
17 |
18 | 
19 |
20 | ## Features
21 |
22 | - Node status
23 | - Rewards statistics
24 |
25 | - Estimated APY (Annual Percentage Yield)
26 | - Total rewards
27 | - Total blocks
28 | - No block probability gauge
29 | - Max blocks/rewards in a day
30 | - Min/max reward
31 | - Average rewards per day/month total/last 30D/last 7D
32 | - Average blocks per day/month total/last 30D/last 7D
33 | - Monthly heatmap statistics
34 | - Rewards History chart
35 | - Blocks History chart
36 | - Block Distribution chart by Day and Hour
37 | - Block reward intervals chart
38 |
39 | - Responsive design for both desktop and mobile
40 | - Dark/light/system theme modes
41 | - Real-time exchange rate in USD (from Binance)
42 | - CSV export
43 |
44 | ## Development Setup
45 |
46 | ```bash
47 | # Install dependencies
48 | npm install
49 |
50 | # Start development server
51 | npm run dev
52 |
53 | # Lint & test
54 | npm run ci
55 |
56 | # Build for production
57 | npm run build
58 | ```
59 |
60 | ## Deploy
61 |
62 | The project is automatically deployed to [](https://pages.cloudflare.com/)
63 | on each push to the main branch. The production website is available at [algonoderewards.com](https://algonoderewards.com).
64 |
65 | ## License
66 |
67 | [](LICENSE)
68 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/App.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 | import reactHooks from "eslint-plugin-react-hooks";
4 | import reactRefresh from "eslint-plugin-react-refresh";
5 | import tseslint from "typescript-eslint";
6 | import eslintConfigPrettier from "eslint-config-prettier";
7 |
8 | export default tseslint.config(
9 | { ignores: ["dist"] },
10 | {
11 | extends: [
12 | js.configs.recommended,
13 | ...tseslint.configs.recommended,
14 | eslintConfigPrettier,
15 | ],
16 | files: ["**/*.{ts,tsx}"],
17 | languageOptions: {
18 | ecmaVersion: 2020,
19 | globals: globals.browser,
20 | },
21 | plugins: {
22 | "react-hooks": reactHooks,
23 | "react-refresh": reactRefresh,
24 | },
25 | rules: {
26 | ...reactHooks.configs.recommended.rules,
27 | "react-refresh/only-export-components": [
28 | "warn",
29 | { allowConstantExport: true },
30 | ],
31 | },
32 | },
33 | );
34 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 | AlgoNodeRewards • Cool stats for your Algorand node
17 |
21 |
25 |
26 |
30 |
31 |
32 |
33 |
52 |
53 |
54 |
55 |
58 | Cool stats for your Algorand staking rewards
59 |
60 |
61 | Get your total node rewards and identify peak performance periods
62 | with our detailed rewards heatmap
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "algonoderewards",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "license": "MIT",
7 | "author": {
8 | "name": "Cryptomalgo",
9 | "url": "https://github.com/cryptomalgo",
10 | "social": {
11 | "github": "https://github.com/cryptomalgo",
12 | "x": "https://x.com/cryptomalgo"
13 | }
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "https://github.com/cryptomalgo/algonoderewards"
18 | },
19 | "homepage": "https://algonoderewards.com",
20 | "bugs": {
21 | "url": "https://github.com/cryptomalgo/algonoderewards/issues"
22 | },
23 | "scripts": {
24 | "dev": "vite",
25 | "build": "vite build",
26 | "lint": "eslint .",
27 | "preview": "vite preview",
28 | "prettier:fix": "prettier --write .",
29 | "type:check": "tsc -b",
30 | "prettier:check": "prettier --check .",
31 | "test:watch": "vitest",
32 | "test": "vitest --run",
33 | "ci": "npm run lint && npm run prettier:fix && npm run type:check && npm run build && vitest --run"
34 | },
35 | "dependencies": {
36 | "@algorandfoundation/algokit-utils": "^9.0.0",
37 | "@radix-ui/react-checkbox": "^1.1.4",
38 | "@radix-ui/react-dialog": "^1.1.6",
39 | "@radix-ui/react-dropdown-menu": "^2.1.6",
40 | "@radix-ui/react-label": "^2.1.2",
41 | "@radix-ui/react-popover": "^1.1.6",
42 | "@radix-ui/react-select": "^2.1.6",
43 | "@radix-ui/react-slider": "^1.3.5",
44 | "@radix-ui/react-slot": "^1.2.2",
45 | "@radix-ui/react-toggle": "^1.1.2",
46 | "@radix-ui/react-toggle-group": "^1.1.2",
47 | "@radix-ui/react-tooltip": "^1.1.8",
48 | "@tailwindcss/vite": "^4.0.17",
49 | "@tanstack/react-query": "^5.74.4",
50 | "@tanstack/react-query-devtools": "^5.74.6",
51 | "@tanstack/react-router": "^1.114.29",
52 | "@tanstack/react-router-devtools": "^1.114.29",
53 | "@uiw/react-heat-map": "^2.3.2",
54 | "algosdk": "^3.2.0",
55 | "class-variance-authority": "^0.7.1",
56 | "clsx": "^2.1.1",
57 | "date-fns": "^4.1.0",
58 | "lucide-react": "^0.484.0",
59 | "motion": "^12.6.2",
60 | "next-themes": "^0.4.6",
61 | "react": "^19.0.0",
62 | "react-day-picker": "^9.7",
63 | "react-dom": "^19.0.0",
64 | "react-error-boundary": "^5.0.0",
65 | "react-gauge-component": "^1.2.64",
66 | "react-scan": "^0.3",
67 | "recharts": "^2.15.3",
68 | "sonner": "^2.0.2",
69 | "tailwind-merge": "^3.0.2",
70 | "tailwindcss": "^4.0.17",
71 | "tailwindcss-animate": "^1.0.7",
72 | "vite-tsconfig-paths": "^5.1.4"
73 | },
74 | "devDependencies": {
75 | "@eslint/js": "^9.23.0",
76 | "@tanstack/router-plugin": "^1.114.29",
77 | "@testing-library/dom": "^10.4.0",
78 | "@testing-library/react": "^16.2.0",
79 | "@types/node": "^22.13.14",
80 | "@types/react": "^19.0.12",
81 | "@types/react-dom": "^19.0.4",
82 | "@vitejs/plugin-react": "^4.3.4",
83 | "autoprefixer": "^10.4.21",
84 | "eslint": "^9.23.0",
85 | "eslint-config-prettier": "^10.1.1",
86 | "eslint-plugin-react-hooks": "^5.2.0",
87 | "eslint-plugin-react-refresh": "^0.4.19",
88 | "globals": "^16.0.0",
89 | "jsdom": "^26.0.0",
90 | "mockdate": "^3.0.5",
91 | "postcss": "^8.5.3",
92 | "prettier": "^3.5.3",
93 | "prettier-plugin-tailwindcss": "^0.6.11",
94 | "timezone-mock": "^1.3.6",
95 | "typescript": "~5.8.2",
96 | "typescript-eslint": "^8.28.0",
97 | "vite": "^6.2.3",
98 | "vitest": "^3.0.9"
99 | },
100 | "engines": {
101 | "node": ">=22.0.0",
102 | "npm": ">=10.0.0"
103 | },
104 | "keywords": [
105 | "algorand",
106 | "algorand node monitoring",
107 | "algorand node rewards",
108 | "algorand node dashboard",
109 | "blockchain"
110 | ]
111 | }
112 |
--------------------------------------------------------------------------------
/public/algorand-coin.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/algorand-coin.webp
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/favicon-96x96.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/logo.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/preview.png
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Algonoderewards",
3 | "short_name": "AlgoNR",
4 | "icons": [
5 | {
6 | "src": "/web-app-manifest-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png",
9 | "purpose": "maskable"
10 | },
11 | {
12 | "src": "/web-app-manifest-512x512.png",
13 | "sizes": "512x512",
14 | "type": "image/png",
15 | "purpose": "maskable"
16 | }
17 | ],
18 | "theme_color": "#ffffff",
19 | "background_color": "#ffffff",
20 | "display": "standalone"
21 | }
22 |
--------------------------------------------------------------------------------
/public/web-app-manifest-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/web-app-manifest-192x192.png
--------------------------------------------------------------------------------
/public/web-app-manifest-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/web-app-manifest-512x512.png
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/screenshot.png
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @plugin "tailwindcss-animate";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 | @custom-variant dark (&:where(.dark, .dark *));
6 |
7 | :root {
8 | --background: hsl(0 0% 100%);
9 | --foreground: hsl(240 10% 3.9%);
10 | --card: hsl(0 0% 100%);
11 | --card-foreground: hsl(240 10% 3.9%);
12 | --popover: hsl(0 0% 100%);
13 | --popover-foreground: hsl(240 10% 3.9%);
14 | --tooltip: var(--color-gray-950);
15 | --tooltip-foreground: var(--color-indigo-100);
16 | --primary: hsl(240 5.9% 10%);
17 | --primary-foreground: hsl(0 0% 98%);
18 | --secondary: hsl(240 4.8% 95.9%);
19 | --secondary-foreground: hsl(240 5.9% 10%);
20 | --muted: hsl(240 4.8% 95.9%);
21 | --muted-foreground: hsl(240 3.8% 46.1%);
22 | --accent: hsl(240 4.8% 95.9%);
23 | --accent-foreground: hsl(240 5.9% 10%);
24 | --destructive: hsl(0 84.2% 60.2%);
25 | --destructive-foreground: hsl(0 0% 98%);
26 | --border: hsl(240 5.9% 90%);
27 | --input: hsl(240 5.9% 90%);
28 | --ring: hsl(240 10% 3.9%);
29 | --chart-1: hsl(12 76% 61%);
30 | --chart-2: hsl(173 58% 39%);
31 | --chart-3: hsl(197 37% 24%);
32 | --chart-4: hsl(43 74% 66%);
33 | --chart-5: hsl(27 87% 67%);
34 | --radius: 0.6rem;
35 | }
36 |
37 | .dark {
38 | --background: var(--color-gray-950);
39 | --foreground: hsl(0 0% 98%);
40 | --card: hsl(240 10% 3.9%);
41 | --card-foreground: hsl(0 0% 98%);
42 | --popover: hsl(240 10% 3.9%);
43 | --popover-foreground: hsl(0 0% 98%);
44 | --tooltip: var(--color-slate-600);
45 | --tooltip-foreground: var(--color-indigo-200);
46 | --primary: hsl(0 0% 98%);
47 | --primary-foreground: hsl(240 5.9% 10%);
48 | --secondary: hsl(240 3.7% 15.9%);
49 | --secondary-foreground: hsl(0 0% 98%);
50 | --muted: hsl(240 3.7% 15.9%);
51 | --muted-foreground: hsl(240 5% 64.9%);
52 | --accent: hsl(240 3.7% 15.9%);
53 | --accent-foreground: hsl(0 0% 98%);
54 | --destructive: hsl(0 62.8% 30.6%);
55 | --destructive-foreground: hsl(0 0% 98%);
56 | --border: hsl(240 3.7% 15.9%);
57 | --input: hsl(240 3.7% 15.9%);
58 | --ring: hsl(240 4.9% 83.9%);
59 | --chart-1: hsl(220 70% 50%);
60 | --chart-2: hsl(160 60% 45%);
61 | --chart-3: hsl(30 80% 55%);
62 | --chart-4: hsl(280 65% 60%);
63 | --chart-5: hsl(340 75% 55%);
64 | }
65 |
66 | @theme inline {
67 | --color-background: var(--background);
68 | --color-foreground: var(--foreground);
69 | --color-card: var(--card);
70 | --color-card-foreground: var(--card-foreground);
71 | --color-popover: var(--popover);
72 | --color-popover-foreground: var(--popover-foreground);
73 | --color-tooltip: var(--tooltip);
74 | --color-tooltip-foreground: var(--tooltip-foreground);
75 | --color-primary: var(--primary);
76 | --color-primary-foreground: var(--primary-foreground);
77 | --color-secondary: var(--secondary);
78 | --color-secondary-foreground: var(--secondary-foreground);
79 | --color-muted: var(--muted);
80 | --color-muted-foreground: var(--muted-foreground);
81 | --color-accent: var(--accent);
82 | --color-accent-foreground: var(--accent-foreground);
83 | --color-destructive: var(--destructive);
84 | --color-destructive-foreground: var(--destructive-foreground);
85 | --color-border: var(--border);
86 | --color-input: var(--input);
87 | --color-ring: var(--ring);
88 | --color-chart-1: var(--chart-1);
89 | --color-chart-2: var(--chart-2);
90 | --color-chart-3: var(--chart-3);
91 | --color-chart-4: var(--chart-4);
92 | --color-chart-5: var(--chart-5);
93 | --radius-sm: calc(var(--radius) - 4px);
94 | --radius-md: calc(var(--radius) - 2px);
95 | --radius-lg: var(--radius);
96 | --radius-xl: calc(var(--radius) + 4px);
97 | }
98 |
99 | @layer base {
100 | * {
101 | @apply border-border outline-ring/50;
102 | }
103 | body {
104 | @apply bg-background text-foreground;
105 | }
106 | }
107 |
108 | @theme {
109 | --font-sans: InterVariable, sans-serif;
110 | }
111 |
112 | @theme {
113 | --animate-coin-spread: coin-spread 1.5s ease-out forwards;
114 |
115 | @keyframes coin-spread {
116 | 0% {
117 | opacity: 1;
118 | transform: translate(-50%, -50%) scale(0);
119 | }
120 | 50% {
121 | opacity: 0.5;
122 | }
123 | 100% {
124 | opacity: 0;
125 | transform: translate(-50%, calc(-50% - 100px)) scale(1);
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/address-input.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/src/components/address-input.tsx
--------------------------------------------------------------------------------
/src/components/address/add-address.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion } from "framer-motion";
2 | import SearchBar from "./search-bar";
3 | import { ResolvedAddress } from "@/components/heatmap/types.ts";
4 |
5 | export default function AddAddress({
6 | showAddAddress,
7 | resolvedAddresses,
8 | setAddresses,
9 | }: {
10 | showAddAddress: boolean;
11 | resolvedAddresses: ResolvedAddress[];
12 | setAddresses: (addresses: string[]) => void;
13 | }) {
14 | return (
15 |
16 | {showAddAddress && (
17 |
24 |
27 | resolvedAddress.nfd ?? resolvedAddress.address,
28 | )}
29 | setAddresses={setAddresses}
30 | />
31 |
32 | )}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/address/address-breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ChevronRightIcon,
3 | FilterIcon,
4 | HomeIcon,
5 | SlidersHorizontalIcon,
6 | } from "lucide-react";
7 | import { ResolvedAddress } from "@/components/heatmap/types";
8 | import { cn, displayAlgoAddress } from "@/lib/utils";
9 | import Spinner from "@/components/spinner.tsx";
10 | import {
11 | Tooltip,
12 | TooltipContent,
13 | TooltipProvider,
14 | TooltipTrigger,
15 | } from "@/components/ui/tooltip.tsx";
16 | import Settings from "./settings.tsx";
17 | import { useTheme } from "@/components/theme-provider";
18 | import { Block } from "algosdk/client/indexer";
19 |
20 | export default function AddressBreadcrumb({
21 | resolvedAddresses,
22 | showFilters,
23 | setShowFilters,
24 | showAddAddress,
25 | setShowAddAddress,
26 | blocks,
27 | }: {
28 | resolvedAddresses: ResolvedAddress[];
29 | showFilters: boolean;
30 | setShowFilters: (show: boolean) => void;
31 | showAddAddress: boolean;
32 | setShowAddAddress: (show: boolean) => void;
33 | blocks: Block[];
34 | }) {
35 | const { theme } = useTheme();
36 | return (
37 |
38 |
39 |
40 |
49 |
50 |
51 |
134 |
135 |
136 |
137 |
138 | );
139 | }
140 |
--------------------------------------------------------------------------------
/src/components/address/address-filters.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion } from "framer-motion";
2 | import { CheckIcon } from "lucide-react";
3 | import { displayAlgoAddress } from "@/lib/utils";
4 | import CopyButton from "@/components/copy-to-clipboard";
5 | import { ResolvedAddress } from "@/components/heatmap/types";
6 | import { useAccount } from "@/hooks/useAccounts";
7 | import AccountStatus from "./stats/status/status";
8 |
9 | export default function AddressFilters({
10 | showFilters,
11 | resolvedAddresses,
12 | selectedAddresses,
13 | setSelectedAddresses,
14 | }: {
15 | showFilters: boolean;
16 | resolvedAddresses: ResolvedAddress[];
17 | selectedAddresses: string[];
18 | setSelectedAddresses: (addresses: string[]) => void;
19 | }) {
20 | const selectAllAddresses = () => {
21 | if (selectedAddresses.length === resolvedAddresses.length) {
22 | setSelectedAddresses([]);
23 | return;
24 | }
25 | setSelectedAddresses(resolvedAddresses.map((addr) => addr.address));
26 | };
27 |
28 | const handleAddressToggle = (address: string, isChecked: boolean) => {
29 | if (isChecked) {
30 | setSelectedAddresses([...selectedAddresses, address]);
31 | return;
32 | }
33 | setSelectedAddresses(selectedAddresses.filter((addr) => addr !== address));
34 | };
35 |
36 | if (resolvedAddresses.length <= 1) return null;
37 |
38 | return (
39 |
40 | {showFilters && (
41 |
48 |
52 | Accounts list
53 |
54 |
74 |
75 |
79 | {selectedAddresses.length === resolvedAddresses.length
80 | ? "Deselect all"
81 | : "Select all"}
82 |
83 |
84 |
85 | {resolvedAddresses.map((address) => (
86 |
91 | handleAddressToggle(address.address, checked)
92 | }
93 | />
94 | ))}
95 |
96 |
97 | )}
98 |
99 | );
100 | }
101 |
102 | function AddressCheckbox({
103 | address,
104 | checked,
105 | onChange,
106 | }: {
107 | address: ResolvedAddress;
108 | checked: boolean;
109 | onChange: (checked: boolean) => void;
110 | }) {
111 | const { data: account } = useAccount(address);
112 |
113 | return (
114 |
115 |
116 |
117 | onChange(e.target.checked)}
122 | className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:checked:border-indigo-500 dark:checked:bg-indigo-600 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-600 dark:focus-visible:outline-indigo-500 dark:disabled:border-gray-700 dark:disabled:bg-gray-800 forced-colors:appearance-auto"
123 | />
124 |
129 |
130 |
131 |
132 |
136 | {address.nfd ? address.nfd : displayAlgoAddress(address.address)}
137 |
141 |
142 |
143 | {address.address}
144 |
145 | {account &&
}
146 |
147 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------
/src/components/address/address-view.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from "react";
2 | import { useBlocks } from "@/hooks/useRewardTransactions";
3 | import { useAlgorandAddresses } from "@/hooks/useAlgorandAddress";
4 | import { Error } from "@/components/error";
5 | import Heatmap from "@/components/heatmap/heatmap";
6 | import AddressBreadcrumb from "./address-breadcrumb";
7 | import AddressFilters from "./address-filters";
8 | import StatsPanels from "./stats/stats-panels";
9 | import AddAddress from "./add-address";
10 | import { useNavigate } from "@tanstack/react-router";
11 | import CopyButton from "@/components/copy-to-clipboard.tsx";
12 | import { displayAlgoAddress } from "@/lib/utils.ts";
13 | import CumulativeRewardsChart from "@/components/address/charts/cumulative-rewards-chart";
14 | import CumulativeBlocksChart from "@/components/address/charts/cumulative-blocks-chart";
15 | import RewardByDayHourChart from "@/components/address/charts/reward-by-day-hour-chart.tsx";
16 | import AccountStatus from "./stats/status/status";
17 | import BlockRewardIntervals from "./charts/block-reward-intervals";
18 |
19 | export default function AddressView({ addresses }: { addresses: string }) {
20 | const navigate = useNavigate();
21 | const [showFilters, setShowFilters] = useState(false);
22 | const [showAddAddress, setShowAddAddress] = useState(false);
23 | const addressesArray = useMemo(
24 | () => addresses.split(",").filter(Boolean),
25 | [addresses],
26 | );
27 | const { resolvedAddresses } = useAlgorandAddresses(addressesArray);
28 |
29 | // Function to update addresses in both state and URL
30 | const handleAddAddresses = (newAddresses: string[]) => {
31 | // Create a unique set of addresses (including only non-empty values)
32 | const uniqueAddresses = [...new Set([...newAddresses])].filter(Boolean);
33 |
34 | // Update the URL without triggering a full navigation
35 | navigate({
36 | to: "/$addresses",
37 | params: { addresses: uniqueAddresses.join(",") },
38 | replace: true,
39 | search: (prev) => ({
40 | hideBalance: false,
41 | theme: prev.theme ?? "system",
42 | statsPanelTheme: prev.statsPanelTheme ?? "indigo",
43 | }),
44 | });
45 | };
46 | // Track selected addresses with a state
47 | const [selectedAddresses, setSelectedAddresses] = useState([]);
48 |
49 | // Set all addresses as selected when resolvedAddresses changes
50 | useMemo(() => {
51 | if (resolvedAddresses?.length > 0) {
52 | setSelectedAddresses(resolvedAddresses.map((addr) => addr.address));
53 | }
54 | }, [resolvedAddresses]);
55 |
56 | const { data: blocks, loading, hasError } = useBlocks(resolvedAddresses);
57 |
58 | // Filter blocks based on selected addresses
59 | const filteredBlocks = useMemo(() => {
60 | if (!blocks) return [];
61 | if (selectedAddresses.length === 0) return [];
62 |
63 | return blocks.filter(
64 | (block) =>
65 | block.proposer && selectedAddresses.includes(block.proposer.toString()),
66 | );
67 | }, [blocks, selectedAddresses]);
68 |
69 | if (hasError) {
70 | return ;
71 | }
72 |
73 | return (
74 |
75 |
76 |
77 |
78 |
79 |
80 |
88 |
93 |
99 | {resolvedAddresses.length === 1 && !showAddAddress && (
100 |
101 |
102 |
103 | {displayAlgoAddress(resolvedAddresses[0].address)}
104 |
105 |
106 | {resolvedAddresses[0].address}
107 |
108 |
109 |
110 |
111 |
112 | )}
113 |
114 |
115 |
116 |
121 |
122 |
123 |
124 |
128 |
129 |
130 |
131 |
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/src/components/address/charts/reward-by-day-hour-chart.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import {
3 | ScatterChart,
4 | Scatter,
5 | XAxis,
6 | YAxis,
7 | ZAxis,
8 | Tooltip,
9 | ResponsiveContainer,
10 | Cell,
11 | } from "recharts";
12 | import { Block } from "algosdk/client/indexer";
13 | import { useTheme } from "@/components/theme-provider";
14 |
15 | const formatHourRange = (hour: number) => {
16 | // Format start time
17 | const startHour = hour % 12 || 12; // Convert 0 to 12 for 12AM
18 | const startAmPm = hour < 12 ? "AM" : "PM";
19 |
20 | // Format end time (next hour)
21 | const endHour = (hour + 1) % 12 || 12;
22 | const endAmPm = hour + 1 < 12 || hour + 1 === 24 ? "AM" : "PM";
23 |
24 | return `from ${startHour}${startAmPm} to ${endHour}${endAmPm}`;
25 | };
26 |
27 | interface RewardByDayHourChartProps {
28 | blocks: Block[];
29 | }
30 |
31 | type DayHourData = {
32 | x: number; // hour (0-23)
33 | y: number; // day (0-6, 0 is Monday)
34 | z: number; // count of blocks
35 | day: string; // day name
36 | };
37 |
38 | // Days starting with Monday
39 | const DAYS = [
40 | "Monday",
41 | "Tuesday",
42 | "Wednesday",
43 | "Thursday",
44 | "Friday",
45 | "Saturday",
46 | "Sunday",
47 | ];
48 |
49 | export default function RewardByDayHourChart({
50 | blocks,
51 | }: RewardByDayHourChartProps) {
52 | const { theme } = useTheme();
53 | const isDarkMode = theme === "dark";
54 |
55 | const dayHourData = useMemo(() => {
56 | if (!blocks?.length) return [];
57 |
58 | // Initialize data structure for all day/hour combinations
59 | const dayHourMap: Record = {};
60 |
61 | // Create entries for all possible day/hour combinations
62 | for (let day = 0; day < 7; day++) {
63 | for (let hour = 0; hour < 24; hour++) {
64 | const key = `${day}-${hour}`;
65 | dayHourMap[key] = {
66 | x: hour,
67 | y: day,
68 | z: 0,
69 | day: DAYS[day],
70 | };
71 | }
72 | }
73 |
74 | // Count blocks for each day/hour combination
75 | blocks.forEach((block) => {
76 | // Use local time, not UTC
77 | const date = new Date(block.timestamp * 1000);
78 | // Convert Sunday=0 to Monday=0 format
79 | let day = date.getDay() - 1; // Adjust to make Monday=0
80 | if (day === -1) day = 6; // Sunday becomes 6
81 |
82 | const hour = date.getHours(); // 0-23
83 |
84 | const key = `${day}-${hour}`;
85 | if (dayHourMap[key]) {
86 | dayHourMap[key].z += 1;
87 | }
88 | });
89 |
90 | // Convert map to array, filtering out entries with no blocks
91 | return Object.values(dayHourMap).filter((entry) => entry.z > 0);
92 | }, [blocks]);
93 |
94 | if (!blocks?.length) {
95 | return (
96 |
97 | No data available
98 |
99 | );
100 | }
101 |
102 | const maxCount = Math.max(...dayHourData.map((d) => d.z));
103 |
104 | // Get bubble color based on count and theme
105 | const getBubbleColor = (count: number) => {
106 | const ratio = count / maxCount;
107 |
108 | // Light mode colors
109 | const lightModeStart = { r: 240, g: 240, b: 255 }; // Faded indigo (almost white)
110 | const lightModeEnd = { r: 79, g: 70, b: 229 }; // Dark indigo
111 |
112 | // Dark mode colors - deeper blue that looks good in dark mode
113 | const darkModeStart = { r: 30, g: 41, b: 59 }; // Faded indigo dark
114 | const darkModeEnd = { r: 97, g: 95, b: 255 }; // Bright indigo
115 |
116 | const start = isDarkMode ? darkModeStart : lightModeStart;
117 | const end = isDarkMode ? darkModeEnd : lightModeEnd;
118 |
119 | const r = Math.round(start.r + (end.r - start.r) * ratio);
120 | const g = Math.round(start.g + (end.g - start.g) * ratio);
121 | const b = Math.round(start.b + (end.b - start.b) * ratio);
122 |
123 | return `rgb(${r}, ${g}, ${b})`;
124 | };
125 |
126 | return (
127 |
128 |
129 | Block Distribution by Day and Hour
130 |
131 |
132 |
133 |
141 | `${hour}h`}
150 | padding={{ left: 10, right: 10 }}
151 | />
152 | DAYS[day].slice(0, 3)}
161 | reversed={true} // Reverse the Y-axis to put Monday (0) at the top
162 | padding={{ top: 10, bottom: 10 }}
163 | />
164 |
165 | {
168 | const { active, payload } = props;
169 |
170 | if (active && payload && payload.length) {
171 | const data = payload[0] && payload[0].payload;
172 |
173 | return (
174 |
179 |
180 | {data.z} blocks proposed on {DAYS[data.y]}s{" "}
181 | {formatHourRange(data.x)}{" "}
182 |
183 |
184 | );
185 | }
186 |
187 | return null;
188 | }}
189 | />
190 |
191 | {dayHourData.map((entry, index) => (
192 | |
193 | ))}
194 |
195 |
196 |
197 |
198 |
199 | Bubble size and color intensity represent the number of blocks proposed
200 |
201 |
202 | );
203 | }
204 |
--------------------------------------------------------------------------------
/src/components/address/search-bar.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import AlgorandLogo from "@/components/algorand-logo.tsx";
3 | import { displayAlgoAddress } from "@/lib/utils.ts";
4 | import { XIcon } from "lucide-react";
5 | import { cn } from "@/lib/utils";
6 |
7 | export default function SearchBar({
8 | addresses,
9 | setAddresses,
10 | }: {
11 | addresses: string[];
12 | setAddresses: (addresses: string[]) => void;
13 | }) {
14 | const [inputValue, setInputValue] = useState("");
15 | const [isInputValid, setIsInputValid] = useState(true);
16 |
17 | const validateAddress = (address: string): boolean => {
18 | const trimmedAddress = address.trim();
19 | // Check if it's a 58-char alphanumeric string or ends with .algo (case insensitive)
20 | return (
21 | /^[A-Za-z0-9]{58}$/.test(trimmedAddress) ||
22 | /\.algo$/i.test(trimmedAddress)
23 | );
24 | };
25 |
26 | const addAddress = () => {
27 | const trimmedInput = inputValue.trim();
28 |
29 | if (trimmedInput === "") return;
30 |
31 | if (!validateAddress(trimmedInput)) {
32 | setIsInputValid(false);
33 | return;
34 | }
35 |
36 | if (!addresses.includes(trimmedInput)) {
37 | setAddresses([...addresses, trimmedInput]);
38 | setInputValue("");
39 | setIsInputValid(true);
40 | }
41 | };
42 |
43 | const handleInputChange = (e: React.ChangeEvent) => {
44 | setInputValue(e.target.value);
45 | setIsInputValid(true); // Reset validation state when input changes
46 | };
47 |
48 | const removeAddress = (indexToRemove: number) => {
49 | setAddresses(addresses.filter((_, index) => index !== indexToRemove));
50 | };
51 |
52 | const handleKeyDown = (e: React.KeyboardEvent) => {
53 | if (e.key === "Enter") {
54 | e.preventDefault(); // Prevent form submission
55 | addAddress();
56 | }
57 | };
58 |
59 | return (
60 |
61 |
65 | Enter a list of Algorand addresses or{" "}
66 | NFD to get stats
67 |
68 |
69 | {/* Address tags */}
70 |
71 | {addresses.length > 0 &&
72 | addresses.map((address, index) => (
73 |
77 |
78 | {address.length === 58 ? displayAlgoAddress(address) : address}
79 |
80 | {addresses.length > 1 && (
81 | removeAddress(index)}
84 | className="text-indigo-400 hover:text-indigo-600 dark:text-indigo-300 dark:hover:text-indigo-200"
85 | >
86 |
87 |
88 | )}
89 |
90 | ))}
91 |
92 |
93 |
94 |
119 |
120 |
131 | +
132 |
133 |
134 | {!isInputValid && (
135 |
136 | Please enter a valid Algorand address (58 characters) or NFD domain
137 | (.algo)
138 |
139 | )}
140 |
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/src/components/address/stats/panel.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { useSearch } from "@tanstack/react-router";
3 |
4 | export function Panel({ children }: { children: React.ReactNode }) {
5 | const search = useSearch({ from: "/$addresses" });
6 | const statsPanelTheme = search.statsPanelTheme;
7 |
8 | return (
9 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/address/stats/panels/apy-panel.tsx:
--------------------------------------------------------------------------------
1 | import { ResolvedAddress } from "@/components/heatmap/types";
2 | import { useAccounts } from "@/hooks/useAccounts";
3 | import { BlockStats } from "@/hooks/useBlocksStats";
4 | import { useSearch } from "@tanstack/react-router";
5 | import StatBox from "../stat-box";
6 | import { calculateAPYAndProjection } from "@/lib/utils";
7 | import {
8 | Tooltip,
9 | TooltipContent,
10 | TooltipProvider,
11 | TooltipTrigger,
12 | } from "@/components/ui/mobile-tooltip";
13 | import NumberDisplay from "@/components/number-display";
14 | import AlgoAmountDisplay from "@/components/algo-amount-display";
15 | import { Panel } from "../panel";
16 |
17 | function ApyDisplay({
18 | totalRewardsOverPeriod,
19 | amountStacked,
20 | nbDays,
21 | hidden,
22 | }: {
23 | totalRewardsOverPeriod: number;
24 | amountStacked: number;
25 | nbDays: number;
26 | hidden: boolean;
27 | }) {
28 | const apy = calculateAPYAndProjection(
29 | totalRewardsOverPeriod,
30 | amountStacked,
31 | nbDays,
32 | );
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
40 | %
41 |
42 |
48 |
49 |
50 |
51 |
56 | {` rewarded over ${nbDays} days with `}
57 | {" "}
62 | stacked.
63 | APY is calculated as:{" "}
64 |
65 | (rewards / algoStaked) * (365 / nbDays) * 100)
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | export function ApyPanel({
74 | stats,
75 | loading,
76 | resolvedAddresses,
77 | }: {
78 | stats: BlockStats;
79 | loading: boolean;
80 | resolvedAddresses: ResolvedAddress[];
81 | }) {
82 | const search = useSearch({ from: "/$addresses" });
83 | const isAmountHidden = search.hideBalance;
84 |
85 | const { data, pending } = useAccounts(resolvedAddresses);
86 |
87 | let totalAmount = 0n;
88 | if (!pending && data && data.length > 0) {
89 | for (const account of data) {
90 | if (account && account.amount) {
91 | totalAmount += BigInt(account.amount);
92 | }
93 | }
94 | }
95 |
96 | return (
97 |
98 |
99 |
109 | }
110 | />
111 |
121 | }
122 | />
123 |
133 | }
134 | />
135 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/src/components/address/stats/panels/blocks-per-day-panel.tsx:
--------------------------------------------------------------------------------
1 | import { BlockStats } from "@/hooks/useBlocksStats";
2 | import { Panel } from "../panel";
3 | import StatBox from "../stat-box";
4 | import {
5 | Tooltip,
6 | TooltipContent,
7 | TooltipProvider,
8 | TooltipTrigger,
9 | } from "@/components/ui/mobile-tooltip";
10 | import NumberDisplay from "@/components/number-display";
11 | import PercentageChange from "../percentage-change";
12 | import { AVERAGE_DAY_IN_MONTH } from "@/constants";
13 |
14 | function PreviousBlocksTooltip({
15 | total,
16 | average,
17 | startDate,
18 | endDate,
19 | }: {
20 | total: number;
21 | average: number;
22 | startDate: Date;
23 | endDate: Date;
24 | }) {
25 | return (
26 |
27 | {total} blocks proposed from {startDate.toLocaleDateString()} to{" "}
28 | {endDate.toLocaleDateString()}, so {average} per day in average
29 |
30 | );
31 | }
32 |
33 | export function BlocksPerDayPanel({
34 | stats,
35 | loading,
36 | }: {
37 | stats: BlockStats;
38 | loading: boolean;
39 | }) {
40 | return (
41 |
42 |
43 |
48 |
49 |
50 |
51 |
52 |
53 | {stats.allTime.startDate !== null &&
54 | stats.allTime.endDate &&
55 | `${stats.allTime.totalBlocks} blocks rewarded in ${stats.allTime.totalDays} days (from ${stats.allTime.startDate.toLocaleDateString()} to ${stats.allTime.endDate.toLocaleDateString()})`}
56 |
57 |
58 |
59 | }
60 | />
61 |
66 |
67 |
68 |
71 |
72 |
73 | {stats.allTime.startDate !== null &&
74 | stats.allTime.endDate &&
75 | `${stats.allTime.avgBlocksPerDay.toFixed(3)} blocks per day x ${AVERAGE_DAY_IN_MONTH} days in a month (365/12)`}
76 |
77 |
78 |
79 | }
80 | />
81 |
82 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | {`${stats.last30Days.totalBlocks} blocks proposed from ${stats.last30Days.startDate.toLocaleString()} to ${stats.last30Days.endDate.toLocaleString()}`}
94 |
95 |
96 |
97 |
107 | }
108 | />
109 |
110 | }
111 | />
112 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | {`${stats.last7Days.totalBlocks} blocks proposed from ${stats.last7Days.startDate.toLocaleDateString()} to ${stats.last7Days.endDate.toLocaleDateString()}`}
124 |
125 |
126 |
127 |
137 | }
138 | />
139 |
140 | }
141 | />
142 |
143 |
144 | );
145 | }
146 |
--------------------------------------------------------------------------------
/src/components/address/stats/panels/total-panel.tsx:
--------------------------------------------------------------------------------
1 | import AlgoAmountDisplay from "@/components/algo-amount-display";
2 | import NumberDisplay from "@/components/number-display";
3 | import {
4 | TooltipProvider,
5 | Tooltip,
6 | TooltipTrigger,
7 | TooltipContent,
8 | } from "@/components/ui/mobile-tooltip";
9 | import { BlockStats } from "@/hooks/useBlocksStats";
10 | import StatBox from "../stat-box";
11 | import { Panel } from "../panel";
12 |
13 | export function TotalsPanel({
14 | stats,
15 | loading,
16 | }: {
17 | stats: BlockStats;
18 | loading: boolean;
19 | }) {
20 | return (
21 |
22 |
23 |
}
27 | />
28 |
}
32 | />
33 |
38 |
39 |
40 |
41 |
42 |
43 | {stats.maxBlocksInDayDateString}
44 |
45 |
46 |
47 | }
48 | />
49 |
54 |
55 |
56 |
57 |
58 |
59 | {stats.maxRewardsInDayDateString}
60 |
61 |
62 |
63 | }
64 | />
65 |
70 |
71 |
72 |
73 |
74 | {stats.minRewardDate}
75 |
76 |
77 | }
78 | />
79 |
84 |
85 |
86 |
87 |
88 | {stats.maxRewardDate}
89 |
90 |
91 | }
92 | />
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/address/stats/percentage-change.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tooltip,
3 | TooltipContent,
4 | TooltipProvider,
5 | TooltipTrigger,
6 | } from "@/components/ui/mobile-tooltip";
7 | import { cn } from "@/lib/utils";
8 | import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
9 |
10 | export default function PercentageChange({
11 | percentage,
12 | direction,
13 | previousValueDisplay,
14 | className,
15 | }: {
16 | percentage: number;
17 | direction: "up" | "down" | "none";
18 | previousValueDisplay: React.ReactNode;
19 | className?: string;
20 | }) {
21 | if (direction === "none") return null;
22 |
23 | const Icon = direction === "up" ? ArrowUpIcon : ArrowDownIcon;
24 | return (
25 |
26 |
27 |
28 |
36 |
44 | {percentage}%
45 |
46 |
47 | {previousValueDisplay}
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/address/stats/stat-box.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundary } from "react-error-boundary";
2 | import { AlertCircleIcon } from "lucide-react";
3 | import Spinner from "@/components/spinner.tsx";
4 | import { useSearch } from "@tanstack/react-router";
5 | import { cn } from "@/lib/utils";
6 |
7 | export default function StatBox({
8 | title,
9 | content,
10 | loading,
11 | }: {
12 | title: string;
13 | content: React.ReactNode;
14 | loading: boolean;
15 | }) {
16 | const search = useSearch({ from: "/$addresses" });
17 | const statsPanelTheme = search.statsPanelTheme;
18 | return (
19 |
27 |
{title}
28 |
29 |
35 |
38 |
39 | Error loading stat
40 |
41 | }
42 | >
43 | {loading ?
: content}
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/address/stats/stats-panels.tsx:
--------------------------------------------------------------------------------
1 | import { Block } from "algosdk/client/indexer";
2 | import { useBlocksStats } from "@/hooks/useBlocksStats";
3 | import { ResolvedAddress } from "@/components/heatmap/types";
4 | import { BlocksPerDayPanel } from "./panels/blocks-per-day-panel";
5 | import { RewardsPerDayPanel } from "./panels/rewards-per-day-panel";
6 | import { ApyPanel } from "./panels/apy-panel";
7 | import { TotalsPanel } from "./panels/total-panel";
8 |
9 | export default function StatsPanels({
10 | filteredBlocks,
11 | loading,
12 | resolvedAddresses,
13 | }: {
14 | filteredBlocks: Block[];
15 | loading: boolean;
16 | resolvedAddresses: ResolvedAddress[];
17 | }) {
18 | const stats = useBlocksStats(filteredBlocks);
19 |
20 | return (
21 |
22 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/anticipated-time-between-blocks-badge.tsx:
--------------------------------------------------------------------------------
1 | import { formatMinutes } from "@/lib/utils";
2 | import { ClockFadingIcon } from "lucide-react";
3 |
4 | export default function AnticipatedTimeBetweenBlocksBadge({
5 | ancitipatedTimeInMinutes,
6 | hidden,
7 | }: {
8 | ancitipatedTimeInMinutes: number;
9 | hidden: boolean;
10 | }) {
11 | return (
12 |
13 |
14 |
15 | Estimated block interval:{" "}
16 | {hidden ? "***" : formatMinutes(Math.round(ancitipatedTimeInMinutes))}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/anxiety-box.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import { useAverageBlockTime } from "@/hooks/useAverageBlockTime";
3 | import { useStakeInfo } from "@/hooks/useStakeInfo";
4 | import { formatMinutes } from "@/lib/utils";
5 | import { AlgoAmount } from "@algorandfoundation/algokit-utils/types/amount";
6 | import { Account } from "algosdk/client/indexer";
7 | import { AnxietyGauge } from "./anxiety-gauge";
8 | import { LastBlockProposedBadge } from "./last-block-proposed-badge";
9 | import AnticipatedTimeBetweenBlocksBadge from "./anticipated-time-between-blocks-badge";
10 | import {
11 | TooltipProvider,
12 | Tooltip,
13 | TooltipContent,
14 | TooltipTrigger,
15 | } from "@/components/ui/mobile-tooltip";
16 | import AlgoAmountDisplay from "@/components/algo-amount-display";
17 | import Spinner from "@/components/spinner";
18 | import { useSearch } from "@tanstack/react-router";
19 |
20 | /**
21 | * Calculate the likelihood of not receiving rewards based on stake and rounds since last reward
22 | * @param accountStake The stake amount for the account
23 | * @param totalOnlineStake The total online stake in the network
24 | * @param roundsSinceLastReward Number of rounds since the account last proposed a block
25 | * @returns Probability (0-100) that the account should have proposed a block by now
26 | */
27 | function calculateLikelihoodOfNoRewards(
28 | accountStake: AlgoAmount,
29 | totalOnlineStake: AlgoAmount,
30 | roundsSinceLastReward: number,
31 | ): number {
32 | const percentageOfTotalStake = accountStake.algos / totalOnlineStake.algos;
33 |
34 | return (1 - percentageOfTotalStake) ** roundsSinceLastReward * 100;
35 | }
36 |
37 | export function AnxietyBox({ account }: { account: Account }) {
38 | const search = useSearch({ from: "/$addresses" });
39 |
40 | const isBalanceHidden = search.hideBalance;
41 |
42 | const {
43 | data: stakeInfo,
44 | isPending: isStakeInfoPending,
45 | error: stakeInfoError,
46 | } = useStakeInfo();
47 | const {
48 | data: averageBlockTime,
49 | isPending: isBlockTimePending,
50 | error: blockTimeError,
51 | } = useAverageBlockTime();
52 |
53 | if (isStakeInfoPending || isBlockTimePending) {
54 | return ;
55 | }
56 | if (stakeInfoError) {
57 | return (
58 |
59 | Error fetching stake info: {stakeInfoError.message}
60 |
61 | );
62 | }
63 |
64 | if (blockTimeError) {
65 | return (
66 |
67 | Error fetching stake info: {blockTimeError.message}
68 |
69 | );
70 | }
71 |
72 | if (account.lastProposed === undefined) {
73 | return (
74 |
75 | );
76 | }
77 |
78 | const accountStake = new AlgoAmount({ microAlgos: account.amount });
79 | const totalStake = new AlgoAmount({
80 | microAlgos: Number(stakeInfo.stake_micro_algo),
81 | });
82 |
83 | const stakeRatio =
84 | Number(accountStake.microAlgos) / Number(totalStake.microAlgos);
85 | const roundsBetweenBlocks =
86 | Number(totalStake.microAlgos) / Number(accountStake.microAlgos);
87 | const anticipatedBlockTimeMinutes = averageBlockTime / stakeRatio / 60;
88 | const likelihoodOfNoRewards = calculateLikelihoodOfNoRewards(
89 | accountStake,
90 | totalStake,
91 | Number(account.round) - account.lastProposed,
92 | );
93 |
94 | return (
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | Shows how normal it is to not have proposed a block since the last
104 | one, based on expected timing between proposed blocks.
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
117 |
118 |
119 | The estimated number of rounds between proposed blocks is{" "}
120 |
121 | total stake / your stake
122 |
123 | .
124 |
125 | Based on the total stake of{" "}
126 | {" "}
131 | and your stake{" "}
132 | {" "}
137 | you should propose every{" "}
138 | {Math.round(roundsBetweenBlocks)} rounds .
139 | With a average round time of {averageBlockTime}s it's{" "}
140 | {Math.round(anticipatedBlockTimeMinutes)} minutes
141 | so {formatMinutes(Math.round(anticipatedBlockTimeMinutes))}.
142 |
143 |
144 |
145 |
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/anxiety-card.tsx:
--------------------------------------------------------------------------------
1 | import { Account } from "algosdk/client/indexer";
2 | import { CloverIcon } from "lucide-react";
3 | import { AnxietyBox } from "./anxiety-box";
4 | import { BLOCK_ANXIETY_BLOG_POST_URL } from "@/constants";
5 |
6 | export function AnxietyCard({ account }: { account: Account }) {
7 | return (
8 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/anxiety-gauge.tsx:
--------------------------------------------------------------------------------
1 | import GaugeComponent from "react-gauge-component";
2 |
3 | export function AnxietyGauge({ value }: { value: number }) {
4 | return (
5 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/balance-badge.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import { ALGO_BALANCE_THRESHOLD_FOR_REWARDS } from "@/constants";
3 | import { AlgoAmount } from "@algorandfoundation/algokit-utils/types/amount";
4 | import { Account } from "algosdk/client/indexer";
5 |
6 | export function BalanceThresholdBadge({ account }: { account: Account }) {
7 | const isBalanceOverThreshold =
8 | new AlgoAmount({ microAlgos: account.amount }) >=
9 | new AlgoAmount({ algos: ALGO_BALANCE_THRESHOLD_FOR_REWARDS });
10 |
11 | if (isBalanceOverThreshold) {
12 | return (
13 |
18 | );
19 | }
20 | return (
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/balance-card.tsx:
--------------------------------------------------------------------------------
1 | import AlgoAmountDisplay from "@/components/algo-amount-display";
2 | import { Button } from "@/components/ui/button";
3 | import { useSearch, useNavigate } from "@tanstack/react-router";
4 | import { Account } from "algosdk/client/indexer";
5 | import { CoinsIcon, EyeOffIcon, EyeIcon } from "lucide-react";
6 |
7 | export function BalanceCard({ account }: { account: Account }) {
8 | // Read from search params instead of local state
9 | const search = useSearch({ from: "/$addresses" });
10 | const navigate = useNavigate({ from: "/$addresses" });
11 |
12 | const isBalanceHidden = search.hideBalance;
13 |
14 | // Toggle handler that updates the URL
15 | const toggleBalanceVisibility = () => {
16 | navigate({
17 | search: (prev) => ({
18 | ...prev,
19 | hideBalance: !isBalanceHidden,
20 | }),
21 | replace: true, // Replace the URL to avoid adding to history stack
22 | });
23 | };
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | Staked
31 |
32 | {
36 | toggleBalanceVisibility();
37 | }}
38 | >
39 | {isBalanceHidden ? (
40 |
41 | ) : (
42 |
43 | )}
44 |
45 |
46 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/incentive-eligibility-badge.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import { Account } from "algosdk/client/indexer";
3 |
4 | export function IncentiveEligibilityBadge({ account }: { account: Account }) {
5 | if (account.incentiveEligible) {
6 | return (
7 |
8 | );
9 | }
10 | return (
11 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/last-block-proposed-badge.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import { ExplorerLink } from "@/components/explorer-link";
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from "@/components/ui/mobile-tooltip";
9 | import { useBlock } from "@/hooks/useBlock";
10 | import { Account } from "algosdk/client/indexer";
11 | import { format, formatDistanceToNow } from "date-fns";
12 | import { BoxIcon } from "lucide-react";
13 | import React from "react";
14 |
15 | export function LastBlockProposedBadge({
16 | account,
17 | hidden,
18 | }: {
19 | account: Account;
20 | hidden: boolean;
21 | }) {
22 | const { data: block } = useBlock(account.lastProposed);
23 | // Add a state to trigger re-renders
24 | const [, setForceUpdate] = React.useState(0);
25 |
26 | // Set up an interval to update the component every minute
27 | React.useEffect(() => {
28 | const intervalId = setInterval(() => {
29 | setForceUpdate((prev) => prev + 1); // Increment to trigger re-render
30 | }, 60000); // 60000ms = 1 minute
31 |
32 | // Clean up the interval when component unmounts
33 | return () => clearInterval(intervalId);
34 | }, []);
35 |
36 | if (account.lastProposed === undefined || block === undefined) {
37 | return (
38 |
39 | );
40 | }
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 | Last block:{" "}
49 | {hidden
50 | ? "about *** ago"
51 | : formatDistanceToNow(new Date(block.timestamp * 1000), {
52 | addSuffix: true,
53 | })}
54 |
55 |
56 |
57 |
62 | Block #{account.lastProposed}
63 | {" "}
64 | {format(new Date(block.timestamp * 1000), "PPpp")}
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/last-heartbeat-badge.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import { ExplorerLink } from "@/components/explorer-link";
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from "@/components/ui/mobile-tooltip";
9 | import { useBlock } from "@/hooks/useBlock";
10 | import { Account } from "algosdk/client/indexer";
11 | import { format, formatDistanceToNow } from "date-fns";
12 | import { HeartPulseIcon } from "lucide-react";
13 |
14 | export function LastHeartbeatBadge({ account }: { account: Account }) {
15 | const { data: block } = useBlock(account.lastHeartbeat);
16 |
17 | if (account.lastHeartbeat === undefined || block === undefined) {
18 | return ;
19 | }
20 | return (
21 |
22 |
23 |
24 |
25 | Last heartbeat:{" "}
26 | {formatDistanceToNow(new Date(block.timestamp * 1000), {
27 | addSuffix: true,
28 | })}
29 |
30 |
31 |
32 | {" "}
33 |
38 | Block #{block.round}
39 | {" "}
40 | {format(new Date(block.timestamp * 1000), "PPpp")}
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/participation-key-badge.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import Spinner from "@/components/spinner";
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from "@/components/ui/mobile-tooltip";
9 | import { useAverageBlockTime } from "@/hooks/useAverageBlockTime";
10 | import { Account } from "algosdk/client/indexer";
11 | import { format, formatDistanceToNow } from "date-fns";
12 | import { KeyRoundIcon } from "lucide-react";
13 |
14 | export function ParticipationKeyBadge({ account }: { account: Account }) {
15 | const { data: averageBlockTime, isPending } = useAverageBlockTime();
16 |
17 | if (isPending) return ;
18 |
19 | if (!account.participation) {
20 | return (
21 |
22 | );
23 | }
24 | const remainingRounds = account.participation.voteLastValid - account.round;
25 |
26 | if (!averageBlockTime)
27 | return (
28 |
29 |
30 |
31 |
32 |
33 | {`Participation key remaining rounds: ${remainingRounds}`}
34 |
35 |
36 |
37 | Until round {account.participation.voteLastValid}
38 |
39 |
40 |
41 | );
42 |
43 | const expirationTimeInSeconds = Number(remainingRounds) * averageBlockTime;
44 | const expirationDate = new Date(Date.now() + expirationTimeInSeconds * 1000);
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 | {`Participation key expiration: ${formatDistanceToNow(
53 | expirationDate,
54 | { addSuffix: true },
55 | )}`}
56 |
57 |
58 |
59 | Key expire on round {account.participation.voteLastValid}. That's{" "}
60 | {remainingRounds} rounds from now.
61 |
62 | With an average block time of {averageBlockTime} seconds, it will
63 | expire approximately on {format(expirationDate, "PPpp")}
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/status-badge.tsx:
--------------------------------------------------------------------------------
1 | import { DotBadge } from "@/components/dot-badge";
2 | import { Account } from "algosdk/client/indexer";
3 |
4 | export function StatusBadge({ account }: { account: Account }) {
5 | if (account.status === "Offline") {
6 | return ;
7 | }
8 | if (account.status === "Online") {
9 | return ;
10 | }
11 | return null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/address/stats/status/status.tsx:
--------------------------------------------------------------------------------
1 | import { useAccount } from "@/hooks/useAccounts";
2 | import { ResolvedAddress } from "../../../heatmap/types";
3 | import Spinner from "@/components/spinner.tsx";
4 | import { BalanceCard } from "./balance-card";
5 | import { BalanceThresholdBadge } from "./balance-badge";
6 | import { IncentiveEligibilityBadge } from "./incentive-eligibility-badge";
7 | import { LastHeartbeatBadge } from "./last-heartbeat-badge";
8 | import { ParticipationKeyBadge } from "./participation-key-badge";
9 | import { StatusBadge } from "./status-badge";
10 | import { AnxietyCard } from "./anxiety-card";
11 |
12 | export default function AccountStatus({
13 | address,
14 | }: {
15 | address: ResolvedAddress;
16 | }) {
17 | const { data: account, isPending, isError, error } = useAccount(address);
18 |
19 | if (isPending) {
20 | return ;
21 | }
22 |
23 | if (isError) {
24 | return (
25 |
26 | Error fetching account status: {error.message}
27 |
28 | );
29 | }
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/algo-amount-display.tsx:
--------------------------------------------------------------------------------
1 | import { AlgoAmount } from "@algorandfoundation/algokit-utils/types/amount";
2 | import AlgorandLogo from "@/components/algorand-logo.tsx";
3 | import { animate, motion, useMotionValue } from "motion/react";
4 | import { useEffect, useState } from "react";
5 | import { useAlgoPrice } from "@/hooks/useAlgoPrice";
6 |
7 | export default function AlgoAmountDisplay({
8 | microAlgoAmount,
9 | className,
10 | showAnimation = true,
11 | showUsdValue = true,
12 | hidden = false,
13 | }: {
14 | microAlgoAmount: bigint | number;
15 | className?: string;
16 | showAnimation?: boolean;
17 | showUsdValue?: boolean;
18 | hidden?: boolean;
19 | }) {
20 | // Ensure microAlgoAmount is a BigInt
21 | const algoAmount = new AlgoAmount({
22 | microAlgos:
23 | typeof microAlgoAmount === "number"
24 | ? BigInt(Math.round(microAlgoAmount))
25 | : microAlgoAmount,
26 | });
27 |
28 | const value = useMotionValue(0);
29 | const [displayValue, setDisplayValue] = useState("0.000");
30 |
31 | const { price: algoPrice } = useAlgoPrice();
32 |
33 | useEffect(() => {
34 | const algoValue = Number(algoAmount.algos);
35 |
36 | if (!showAnimation) {
37 | // Skip animation if showAnimation is false
38 | setDisplayValue(formatNumber(algoValue));
39 | return;
40 | }
41 |
42 | const controls = animate(value, algoValue, {
43 | duration: 0.5,
44 | ease: "easeOut",
45 | onUpdate: (latest) => {
46 | setDisplayValue(formatNumber(latest));
47 | },
48 | });
49 |
50 | return controls.stop;
51 | }, [algoAmount.algos, showAnimation, value]);
52 |
53 | // Format number with commas for thousands and fixed 3 decimal places
54 | function formatNumber(num: number): string {
55 | return new Intl.NumberFormat("en-US", {
56 | minimumFractionDigits: 3,
57 | maximumFractionDigits: 3,
58 | }).format(num);
59 | }
60 |
61 | // Format USD value
62 | function formatUsdValue(algoValue: number, price: number): string {
63 | const usdValue = algoValue * price;
64 | return new Intl.NumberFormat("en-US", {
65 | style: "currency",
66 | currency: "USD",
67 | minimumFractionDigits: 2,
68 | maximumFractionDigits: 2,
69 | }).format(usdValue);
70 | }
71 |
72 | const algoValue = Number(algoAmount.algos);
73 | const usdValue =
74 | algoPrice && showUsdValue ? formatUsdValue(algoValue, algoPrice) : null;
75 |
76 | return (
77 |
78 |
79 |
85 | {hidden ? "*****" : displayValue}
86 |
87 |
88 |
89 | {showUsdValue && usdValue && (
90 |
96 | {hidden ? "*****" : usdValue}
97 |
98 | )}
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/algorand-logo.tsx:
--------------------------------------------------------------------------------
1 | export default function AlgorandLogo({ className }: { className?: string }) {
2 | return (
3 |
11 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/coins-spread.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "motion/react";
2 | import { cn } from "@/lib/utils.ts";
3 |
4 | function getRandomSize() {
5 | const sizes = [
6 | "sm:size-16",
7 | "sm:size-18",
8 | "sm:size-20",
9 | "sm:size-22",
10 | "sm:size-24",
11 | ];
12 | return sizes[Math.floor(Math.random() * sizes.length)];
13 | }
14 |
15 | function getRandomRotationDelta() {
16 | return Math.floor(Math.random() * 40) - 20;
17 | }
18 |
19 | function getRandomRotationPositionDelta() {
20 | return Math.floor(Math.random() * 40) - 20;
21 | }
22 |
23 | function getRandomDelayAndDuration() {
24 | return Math.random() * 0.5 + 0.3;
25 | }
26 |
27 | const CoinsSpread = () => {
28 | return (
29 |
30 |
31 |
49 |
50 |
51 |
69 |
70 |
71 |
89 |
90 |
91 |
109 |
110 |
111 |
129 |
130 |
131 |
149 |
150 |
151 | );
152 | };
153 |
154 | export default CoinsSpread;
155 |
--------------------------------------------------------------------------------
/src/components/copy-to-clipboard.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { motion } from "motion/react";
3 | import { ClipboardCheckIcon, CopyIcon } from "lucide-react";
4 |
5 | const CopyButton = ({
6 | address,
7 | small = false,
8 | }: {
9 | address: string;
10 | small?: boolean;
11 | }) => {
12 | const [copied, setCopied] = useState(false);
13 |
14 | const copyToClipboard = (e: React.MouseEvent) => {
15 | e.preventDefault();
16 | e.stopPropagation();
17 | navigator.clipboard.writeText(address);
18 | setCopied(true);
19 | setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds
20 | };
21 |
22 | return (
23 |
29 | {copied ? (
30 |
31 | ) : (
32 |
33 | )}
34 | {!small && (copied ? "Copied" : "Copy Address")}
35 |
36 | );
37 | };
38 |
39 | export default CopyButton;
40 |
--------------------------------------------------------------------------------
/src/components/dot-badge.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | export type DotBadgeColor =
4 | | "red"
5 | | "yellow"
6 | | "green"
7 | | "blue"
8 | | "indigo"
9 | | "purple"
10 | | "pink";
11 |
12 | interface DotBadgeProps {
13 | label: string;
14 | color: DotBadgeColor;
15 | className?: string;
16 | }
17 |
18 | const colorClasses: Record = {
19 | red: "fill-red-500 dark:fill-red-400",
20 | yellow: "fill-yellow-500 dark:fill-yellow-400",
21 | green: "fill-green-500 dark:fill-green-400",
22 | blue: "fill-blue-500 dark:fill-blue-400",
23 | indigo: "fill-indigo-500 dark:fill-indigo-400",
24 | purple: "fill-purple-500 dark:fill-purple-400",
25 | pink: "fill-pink-500 dark:fill-pink-400",
26 | };
27 |
28 | export function DotBadge({ label, color, className }: DotBadgeProps) {
29 | return (
30 |
36 |
41 |
42 |
43 | {label}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/error.tsx:
--------------------------------------------------------------------------------
1 | export function Error() {
2 | return (
3 |
4 |
5 | Cannot load transactions
6 |
7 |
8 | Sorry, we couldn’t get your transactions. Please verify your address and
9 | try again later.
10 |
11 |
12 | We rely on Nodely to fetch your transactions. If you are experiencing
13 | issues, please check{" "}
14 | their status page
15 |
16 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/explorer-link.tsx:
--------------------------------------------------------------------------------
1 | interface ExplorerLinkProps {
2 | id: string;
3 | children: React.ReactNode;
4 | className?: string;
5 | type: "address" | "transaction" | "block";
6 | }
7 |
8 | const alloExplorerPrefixConfig = {
9 | address: "account",
10 | transaction: "tx",
11 | block: "block",
12 | };
13 |
14 | export function ExplorerLink({
15 | id,
16 | children,
17 | className,
18 | type,
19 | }: ExplorerLinkProps) {
20 | return (
21 |
26 | {children}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import CopyButton from "@/components/copy-to-clipboard.tsx";
2 |
3 | const Footer = () => {
4 | return (
5 |
6 |
7 |
8 | Brought to you by{" "}
9 |
13 | @cryptomalgo
14 |
15 |
16 |
17 | ☕️ If you like this website, you can buy me a coffee by sending some
18 | Algo to{" "}
19 |
20 | noderewards.algo {" "}
21 |
22 |
23 |
24 |
25 |
26 | Data kindly provided by{" "}
27 |
31 | Nodely
32 |
33 |
34 |
35 |
36 | Not affiliated with The Algorand Foundation or any other entity.
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default Footer;
44 |
--------------------------------------------------------------------------------
/src/components/github-corner.tsx:
--------------------------------------------------------------------------------
1 | export function GithubCorner() {
2 | return (
3 |
11 |
18 |
19 |
23 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/heatmap/heatmap.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useCallback, useState, useRef } from "react";
2 | import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
3 | import { Block } from "algosdk/client/indexer";
4 | import MonthView from "@/components/heatmap/month-view.tsx";
5 | import { DayWithRewards, DisplayMonth } from "@/components/heatmap/types.ts";
6 | function generateDays(
7 | month: number,
8 | year: number,
9 | startOnMonday: boolean = true,
10 | ): Date[] {
11 | // Create dates without time components to avoid timezone issues
12 | const firstDayOfMonth = new Date(year, month, 1);
13 | const lastDayOfMonth = new Date(year, month + 1, 0);
14 |
15 | const start = new Date(firstDayOfMonth);
16 | // Use getDay instead of getUTCDay
17 | while (start.getDay() !== (startOnMonday ? 1 : 0)) {
18 | start.setDate(start.getDate() - 1);
19 | }
20 |
21 | const end = new Date(lastDayOfMonth);
22 | while (end.getDay() !== (startOnMonday ? 0 : 6)) {
23 | end.setDate(end.getDate() + 1);
24 | }
25 |
26 | const days: Date[] = [];
27 | const current = new Date(start);
28 |
29 | while (current <= end) {
30 | days.push(new Date(current));
31 | current.setDate(current.getDate() + 1);
32 | }
33 |
34 | return days;
35 | }
36 |
37 | const Heatmap: React.FC<{ blocks: Block[] }> = ({ blocks }) => {
38 | const [displayMonths, setDisplayMonths] = useState(() => {
39 | const now = new Date();
40 | const currentMonth = now.getMonth();
41 | const previousMonth = currentMonth === 0 ? 11 : currentMonth - 1;
42 | const twoMonthsAgo =
43 | currentMonth <= 1 ? 11 + (currentMonth - 1) : currentMonth - 2;
44 | const currentYear = now.getFullYear();
45 | const previousYear = currentMonth === 0 ? currentYear - 1 : currentYear;
46 | const twoYearsAgo = currentMonth <= 1 ? currentYear - 1 : currentYear;
47 |
48 | return [
49 | { month: twoMonthsAgo, year: twoYearsAgo },
50 | { month: previousMonth, year: previousYear },
51 | { month: currentMonth, year: currentYear },
52 | ];
53 | });
54 |
55 | // Process transactions once into a date-based lookup map
56 | const { transactionsByDate, maxCount } = useMemo(() => {
57 | const dateMap = new Map();
58 | let maxCount = 0;
59 |
60 | blocks.forEach((block) => {
61 | if (!block.timestamp) return;
62 |
63 | const date = new Date(block.timestamp * 1000);
64 | const dateStr = date.toLocaleDateString("en-US");
65 |
66 | const existing = dateMap.get(dateStr) || { count: 0, totalAmount: 0 };
67 | existing.count += 1;
68 | existing.totalAmount += block?.proposerPayout ?? 0;
69 |
70 | dateMap.set(dateStr, existing);
71 |
72 | if (existing.count > maxCount) maxCount = existing.count;
73 | });
74 |
75 | return { transactionsByDate: dateMap, maxCount: Math.max(maxCount, 1) };
76 | }, [blocks]);
77 |
78 | // Cache generated days to avoid redundant calculations
79 | const daysCache = useRef(new Map());
80 |
81 | const getDaysWithRewards = useCallback(
82 | (month: number, year: number): DayWithRewards[] => {
83 | const cacheKey = `${month}-${year}`;
84 | let days = daysCache.current.get(cacheKey);
85 |
86 | if (!days) {
87 | days = generateDays(month, year, true);
88 | daysCache.current.set(cacheKey, days);
89 | }
90 |
91 | return days.map((day) => {
92 | const dateStr = day.toLocaleDateString("en-US");
93 | const dayData = transactionsByDate.get(dateStr) || {
94 | count: 0,
95 | totalAmount: 0,
96 | };
97 |
98 | return {
99 | date: dateStr,
100 | count: dayData.count,
101 | totalAmount: dayData.totalAmount,
102 | };
103 | });
104 | },
105 | [transactionsByDate],
106 | );
107 |
108 | const handlePreviousMonth = () => {
109 | setDisplayMonths((prev) => {
110 | const newMonth = prev[0].month === 0 ? 11 : prev[0].month - 1;
111 | const newYear = prev[0].month === 0 ? prev[0].year - 1 : prev[0].year;
112 | return [{ month: newMonth, year: newYear }, prev[0], prev[1]];
113 | });
114 | };
115 |
116 | const handleNextMonth = () => {
117 | setDisplayMonths((prev) => {
118 | const newMonth = prev[2].month === 11 ? 0 : prev[2].month + 1;
119 | const newYear = prev[2].month === 11 ? prev[2].year + 1 : prev[2].year;
120 | return [prev[1], prev[2], { month: newMonth, year: newYear }];
121 | });
122 | };
123 |
124 | return (
125 |
126 |
127 |
132 | Previous month
133 |
134 |
135 |
140 | Next month
141 |
142 |
143 | {displayMonths.map((month) => (
144 |
151 | ))}
152 |
153 |
154 | );
155 | };
156 |
157 | export default React.memo(Heatmap);
158 |
--------------------------------------------------------------------------------
/src/components/heatmap/month-view.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import AlgoAmountDisplay from "@/components/algo-amount-display.tsx";
3 | import DayView from "./day-view";
4 | import { DayWithRewards } from "./types";
5 | import NumberDisplay from "@/components/number-display.tsx";
6 |
7 | const MonthView: React.FC<{
8 | month: number;
9 | year: number;
10 | daysWithRewards: DayWithRewards[];
11 | maxRewardCount: number;
12 | }> = ({ month, year, daysWithRewards, maxRewardCount }) => {
13 | const monthName = new Date(year, month).toLocaleString("default", {
14 | month: "long",
15 | });
16 |
17 | const totalRewards = daysWithRewards.reduce((sum, day) => {
18 | const dayDate = new Date(day.date);
19 | const isCurrentMonth = dayDate.getMonth() === month;
20 | const isCurrentYear = dayDate.getFullYear() === year;
21 | return isCurrentMonth && isCurrentYear ? sum + day.count : sum;
22 | }, 0);
23 |
24 | const totalAmount = daysWithRewards.reduce((sum, day) => {
25 | const dayDate = new Date(day.date);
26 | const isCurrentMonth = dayDate.getMonth() === month;
27 | const isCurrentYear = dayDate.getFullYear() === year;
28 |
29 | return isCurrentMonth && isCurrentYear ? sum + day.totalAmount : sum;
30 | }, 0);
31 |
32 | return (
33 |
34 |
35 | {monthName}
36 |
37 |
38 |
39 | blocks
40 |
41 |
42 |
43 |
44 |
M
45 |
T
46 |
W
47 |
T
48 |
F
49 |
S
50 |
S
51 |
52 |
53 | {daysWithRewards.map((day, dayIdx) => (
54 |
62 | ))}
63 |
64 |
65 | );
66 | };
67 |
68 | export default MonthView;
69 |
--------------------------------------------------------------------------------
/src/components/heatmap/types.ts:
--------------------------------------------------------------------------------
1 | export interface DayWithRewards {
2 | date: string;
3 | count: number;
4 | totalAmount: number;
5 | }
6 |
7 | export interface DisplayMonth {
8 | month: number;
9 | year: number;
10 | }
11 |
12 | export type ResolvedAddress = {
13 | nfd: string | null;
14 | address: string;
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/number-display.tsx:
--------------------------------------------------------------------------------
1 | import { animate, motion, useMotionValue } from "motion/react";
2 | import { useEffect, useState } from "react";
3 |
4 | interface NumberDisplayProps {
5 | value: number;
6 | className?: string;
7 | duration?: number;
8 | formatOptions?: Intl.NumberFormatOptions;
9 | animate?: boolean;
10 | }
11 |
12 | export default function NumberDisplay({
13 | value,
14 | className,
15 | duration = 0.5,
16 | formatOptions,
17 | animate: shouldAnimate = true,
18 | }: NumberDisplayProps) {
19 | const motionValue = useMotionValue(0);
20 | const [displayValue, setDisplayValue] = useState(0);
21 |
22 | useEffect(() => {
23 | if (!shouldAnimate) {
24 | setDisplayValue(value);
25 | return;
26 | }
27 |
28 | const controls = animate(motionValue, value, {
29 | duration,
30 | ease: "easeOut",
31 | onUpdate: (latest) => {
32 | setDisplayValue(Math.round(latest * 1000) / 1000);
33 | },
34 | });
35 |
36 | return controls.stop;
37 | }, [value, duration, motionValue, shouldAnimate]);
38 |
39 | const formattedValue = formatOptions
40 | ? new Intl.NumberFormat(undefined, formatOptions).format(displayValue)
41 | : displayValue.toString();
42 |
43 | return (
44 |
51 | {formattedValue}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/search-bar.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useState } from "react";
2 | import { useNavigate } from "@tanstack/react-router";
3 | import AlgorandLogo from "@/components/algorand-logo.tsx";
4 | import { displayAlgoAddress } from "@/lib/utils.ts";
5 | import { XIcon } from "lucide-react";
6 |
7 | export default function SearchBar() {
8 | const [addresses, setAddresses] = useState([]);
9 | const [inputValue, setInputValue] = useState("");
10 | const [isInputValid, setIsInputValid] = useState(true);
11 | const navigate = useNavigate();
12 |
13 | const validateAddress = (address: string): boolean => {
14 | const trimmedAddress = address.trim();
15 | // Check if it's a 58-char alphanumeric string or ends with .algo (case insensitive)
16 | return (
17 | /^[A-Za-z0-9]{58}$/.test(trimmedAddress) ||
18 | /\.algo$/i.test(trimmedAddress)
19 | );
20 | };
21 |
22 | const handleSubmit = (e: FormEvent) => {
23 | e.preventDefault();
24 |
25 | // Create the final list of addresses to use for navigation
26 | const finalAddresses = [...addresses];
27 |
28 | // Add the current input if it's not empty and valid
29 | if (inputValue.trim() !== "" && validateAddress(inputValue)) {
30 | finalAddresses.push(inputValue.trim());
31 | } else if (inputValue.trim() !== "") {
32 | setIsInputValid(false);
33 | return;
34 | }
35 |
36 | // Only navigate if we have addresses
37 | if (finalAddresses.length > 0) {
38 | const uniqueAddresses = [
39 | ...new Set(
40 | finalAddresses.map((address: string) => address.trim().toUpperCase()),
41 | ),
42 | ];
43 | navigate({
44 | to: "/$addresses",
45 | params: {
46 | addresses: uniqueAddresses.join(","),
47 | },
48 | search: (prev) => ({
49 | hideBalance: prev.hideBalance ?? false,
50 | theme: prev.theme ?? "system",
51 | statsPanelTheme: prev.statsPanelTheme ?? "indigo",
52 | }),
53 | });
54 | }
55 | };
56 |
57 | const addAddress = () => {
58 | const trimmedInput = inputValue.trim();
59 |
60 | if (trimmedInput === "") return;
61 |
62 | if (!validateAddress(trimmedInput)) {
63 | setIsInputValid(false);
64 | return;
65 | }
66 |
67 | if (!addresses.includes(trimmedInput)) {
68 | setAddresses([...addresses, trimmedInput]);
69 | setInputValue("");
70 | setIsInputValid(true);
71 | }
72 | };
73 |
74 | const handleInputChange = (e: React.ChangeEvent) => {
75 | setInputValue(e.target.value);
76 | setIsInputValid(true); // Reset validation state when input changes
77 | };
78 |
79 | const removeAddress = (indexToRemove: number) => {
80 | setAddresses(addresses.filter((_, index) => index !== indexToRemove));
81 | };
82 |
83 | const handleKeyDown = (e: React.KeyboardEvent) => {
84 | if (e.key === "Enter") {
85 | e.preventDefault(); // Prevent form submission
86 | addAddress();
87 | }
88 | };
89 |
90 | return (
91 |
170 | );
171 | }
172 |
--------------------------------------------------------------------------------
/src/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | const Spinner = () => {
2 | return (
3 |
29 | );
30 | };
31 |
32 | export default Spinner;
33 |
--------------------------------------------------------------------------------
/src/components/staking-cta.tsx:
--------------------------------------------------------------------------------
1 | const StakingCta = () => {
2 | return (
3 |
4 |
5 |
6 |
7 | Not staking yet?
8 |
9 |
10 | Algorand is designed for decentralization. You can collect rewards
11 | for running nodes or staking Algo, doing your part to secure the
12 | network.
13 |
14 |
22 |
27 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default StakingCta;
48 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react";
2 |
3 | export type Theme = "dark" | "light";
4 | export type ThemeSetting = "dark" | "light" | "system";
5 |
6 | interface ThemeContextType {
7 | theme: Theme;
8 | themeSetting: ThemeSetting;
9 | setThemeSetting: (theme: ThemeSetting) => void;
10 | }
11 |
12 | const ThemeContext = createContext(undefined);
13 |
14 | const getThemeFromUrl = (): ThemeSetting => {
15 | if (typeof window === "undefined") return "system";
16 |
17 | const url = new URL(window.location.href);
18 | const themeParam = url.searchParams.get("theme");
19 |
20 | if (
21 | themeParam === "dark" ||
22 | themeParam === "light" ||
23 | themeParam === "system"
24 | ) {
25 | return themeParam;
26 | }
27 |
28 | return "system";
29 | };
30 |
31 | const setThemeSettingInUrl = (themeSetting: ThemeSetting) => {
32 | if (typeof window === "undefined") return;
33 |
34 | const url = new URL(window.location.href);
35 | url.searchParams.set("theme", themeSetting);
36 |
37 | // Update URL without reloading the page
38 | window.history.replaceState({}, "", url.toString());
39 | };
40 |
41 | export function ThemeProvider({
42 | children,
43 | defaultThemeSetting = "system",
44 | }: {
45 | children: React.ReactNode;
46 | defaultThemeSetting?: ThemeSetting;
47 | }) {
48 | const [themeSetting, setThemeSettingState] = useState(() => {
49 | return getThemeFromUrl() || defaultThemeSetting;
50 | });
51 |
52 | const [actualTheme, setActualTheme] = useState("light");
53 |
54 | const setThemeSetting = (newTheme: ThemeSetting) => {
55 | setThemeSettingState(newTheme);
56 | setThemeSettingInUrl(newTheme);
57 | };
58 |
59 | useEffect(() => {
60 | const root = window.document.documentElement;
61 |
62 | root.classList.remove("light", "dark");
63 |
64 | if (themeSetting === "system") {
65 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
66 | .matches
67 | ? "dark"
68 | : "light";
69 | root.classList.add(systemTheme);
70 | setActualTheme(systemTheme);
71 | } else {
72 | root.classList.add(themeSetting);
73 | setActualTheme(themeSetting);
74 | }
75 | }, [themeSetting]);
76 |
77 | useEffect(() => {
78 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
79 |
80 | const handleChange = () => {
81 | if (themeSetting === "system") {
82 | const root = window.document.documentElement;
83 | const newTheme = mediaQuery.matches ? "dark" : "light";
84 | root.classList.remove("light", "dark");
85 | root.classList.add(newTheme);
86 | setActualTheme(newTheme);
87 | }
88 | };
89 |
90 | mediaQuery.addEventListener("change", handleChange);
91 |
92 | return () => {
93 | mediaQuery.removeEventListener("change", handleChange);
94 | };
95 | }, [themeSetting]);
96 |
97 | return (
98 |
105 |
109 | {children}
110 |
111 | );
112 | }
113 |
114 | export const useTheme = (): ThemeContextType => {
115 | const context = useContext(ThemeContext);
116 |
117 | if (!context) {
118 | throw new Error("useTheme must be used within a ThemeProvider");
119 | }
120 |
121 | return context;
122 | };
123 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-card text-card-foreground",
12 | destructive:
13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | },
20 | );
21 |
22 | function Alert({
23 | className,
24 | variant,
25 | ...props
26 | }: React.ComponentProps<"div"> & VariantProps) {
27 | return (
28 |
34 | );
35 | }
36 |
37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38 | return (
39 |
47 | );
48 | }
49 |
50 | function AlertDescription({
51 | className,
52 | ...props
53 | }: React.ComponentProps<"div">) {
54 | return (
55 |
63 | );
64 | }
65 |
66 | export { Alert, AlertTitle, AlertDescription };
67 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
16 | outline:
17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | },
35 | );
36 |
37 | function Button({
38 | className,
39 | variant,
40 | size,
41 | asChild = false,
42 | ...props
43 | }: React.ComponentProps<"button"> &
44 | VariantProps & {
45 | asChild?: boolean;
46 | }) {
47 | const Comp = asChild ? Slot : "button";
48 |
49 | return (
50 |
55 | );
56 | }
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ChevronLeft, ChevronRight } from "lucide-react";
3 | import { DayPicker } from "react-day-picker";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { buttonVariants } from "@/components/ui/button";
7 |
8 | function Calendar({
9 | className,
10 | classNames,
11 | showOutsideDays = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 | button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground",
46 | range_end:
47 | "day-range-end !bg-accent rounded-r-md [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground",
48 | range_middle:
49 | "aria-selected:bg-accent aria-selected:text-accent-foreground",
50 | selected: cn(
51 | props.mode === "range"
52 | ? "bg-primary hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground"
53 | : "[&>button]:focus:bg-primary [&>button]:focus:text-primary-foreground",
54 | ),
55 | today: "bg-accent text-accent-foreground !rounded-md",
56 | outside:
57 | "day-outside text-muted-foreground opacity-50 !aria-selected:bg-accent/50 !aria-selected:text-muted-foreground !aria-selected:opacity-30",
58 | disabled: "text-muted-foreground opacity-50",
59 | hidden: "invisible",
60 | ...classNames,
61 | }}
62 | components={{
63 | Chevron: ({ ...props }) =>
64 | props.orientation === "left" ? (
65 |
66 | ) : (
67 |
68 | ),
69 | }}
70 | {...props}
71 | />
72 | );
73 | }
74 |
75 | export { Calendar };
76 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | );
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | );
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | );
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | );
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | );
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | );
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | );
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | };
93 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
3 | import { CheckIcon } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | function Checkbox({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export { Checkbox };
31 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DialogPrimitive from "@radix-ui/react-dialog";
3 | import { XIcon } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return ;
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return ;
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | ...props
51 | }: React.ComponentProps) {
52 | return (
53 |
54 |
55 |
63 | {children}
64 |
65 |
66 | Close
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | );
81 | }
82 |
83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
93 | );
94 | }
95 |
96 | function DialogTitle({
97 | className,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
106 | );
107 | }
108 |
109 | function DialogDescription({
110 | className,
111 | ...props
112 | }: React.ComponentProps) {
113 | return (
114 |
119 | );
120 | }
121 |
122 | export {
123 | Dialog,
124 | DialogClose,
125 | DialogContent,
126 | DialogDescription,
127 | DialogFooter,
128 | DialogHeader,
129 | DialogOverlay,
130 | DialogPortal,
131 | DialogTitle,
132 | DialogTrigger,
133 | };
134 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | );
19 | }
20 |
21 | export { Input };
22 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | function Label({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | export { Label };
23 |
--------------------------------------------------------------------------------
/src/components/ui/mobile-tooltip.tsx:
--------------------------------------------------------------------------------
1 | //From https://github.com/shadcn-ui/ui/issues/2402
2 |
3 | import { createContext, useContext, useEffect, useState } from "react";
4 | import {
5 | TooltipProvider as OriginalTooltipProvider,
6 | Tooltip as OriginalTooltip,
7 | TooltipTrigger as OriginalTooltipTrigger,
8 | TooltipContent as OriginalTooltipContent,
9 | } from "./tooltip";
10 | import { Popover, PopoverTrigger, PopoverContent } from "./popover";
11 | import {
12 | TooltipContentProps,
13 | TooltipProps,
14 | TooltipTriggerProps,
15 | TooltipProviderProps,
16 | } from "@radix-ui/react-tooltip";
17 | import {
18 | PopoverContentProps,
19 | PopoverProps,
20 | PopoverTriggerProps,
21 | } from "@radix-ui/react-popover";
22 | import { cn } from "@/lib/utils";
23 |
24 | const TouchContext = createContext(undefined);
25 | const useTouch = () => useContext(TouchContext);
26 |
27 | export const TooltipProvider = ({
28 | children,
29 | ...props
30 | }: TooltipProviderProps) => {
31 | const [isTouch, setTouch] = useState(undefined);
32 |
33 | useEffect(() => {
34 | setTouch(window.matchMedia("(pointer: coarse)").matches);
35 | }, []);
36 |
37 | return (
38 |
39 | {children}
40 |
41 | );
42 | };
43 |
44 | export const Tooltip = (props: TooltipProps & PopoverProps) => {
45 | const isTouch = useTouch();
46 |
47 | return isTouch ? : ;
48 | };
49 |
50 | export const TooltipTrigger = (
51 | props: TooltipTriggerProps & PopoverTriggerProps,
52 | ) => {
53 | const isTouch = useTouch();
54 | return isTouch ? (
55 |
56 | ) : (
57 |
58 | );
59 | };
60 |
61 | export const TooltipContent = (
62 | props: TooltipContentProps & PopoverContentProps,
63 | ) => {
64 | const isTouch = useTouch();
65 |
66 | return isTouch ? (
67 |
74 | ) : (
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as PopoverPrimitive from "@radix-ui/react-popover";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | function Popover({
7 | ...props
8 | }: React.ComponentProps) {
9 | return ;
10 | }
11 |
12 | function PopoverTrigger({
13 | ...props
14 | }: React.ComponentProps) {
15 | return ;
16 | }
17 |
18 | function PopoverContent({
19 | className,
20 | align = "center",
21 | sideOffset = 4,
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
36 |
37 | );
38 | }
39 |
40 | function PopoverAnchor({
41 | ...props
42 | }: React.ComponentProps) {
43 | return ;
44 | }
45 |
46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
47 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SelectPrimitive from "@radix-ui/react-select";
3 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | function Select({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function SelectGroup({
14 | ...props
15 | }: React.ComponentProps) {
16 | return ;
17 | }
18 |
19 | function SelectValue({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function SelectTrigger({
26 | className,
27 | children,
28 | ...props
29 | }: React.ComponentProps) {
30 | return (
31 | span]:line-clamp-1",
35 | className,
36 | )}
37 | {...props}
38 | >
39 | {children}
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | function SelectContent({
48 | className,
49 | children,
50 | position = "popper",
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
66 |
67 |
74 | {children}
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | function SelectLabel({
83 | className,
84 | ...props
85 | }: React.ComponentProps) {
86 | return (
87 |
92 | );
93 | }
94 |
95 | function SelectItem({
96 | className,
97 | children,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | );
117 | }
118 |
119 | function SelectSeparator({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | );
130 | }
131 |
132 | function SelectScrollUpButton({
133 | className,
134 | ...props
135 | }: React.ComponentProps) {
136 | return (
137 |
145 |
146 |
147 | );
148 | }
149 |
150 | function SelectScrollDownButton({
151 | className,
152 | ...props
153 | }: React.ComponentProps) {
154 | return (
155 |
163 |
164 |
165 | );
166 | }
167 |
168 | export {
169 | Select,
170 | SelectContent,
171 | SelectGroup,
172 | SelectItem,
173 | SelectLabel,
174 | SelectScrollDownButton,
175 | SelectScrollUpButton,
176 | SelectSeparator,
177 | SelectTrigger,
178 | SelectValue,
179 | };
180 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SliderPrimitive from "@radix-ui/react-slider";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | function Slider({
7 | className,
8 | defaultValue,
9 | value,
10 | min = 0,
11 | max = 100,
12 | ...props
13 | }: React.ComponentProps) {
14 | const _values = React.useMemo(
15 | () =>
16 | Array.isArray(value)
17 | ? value
18 | : Array.isArray(defaultValue)
19 | ? defaultValue
20 | : [min, max],
21 | [value, defaultValue, min, max],
22 | );
23 |
24 | return (
25 |
37 |
43 |
49 |
50 | {Array.from({ length: _values.length }, (_, index) => (
51 |
56 | ))}
57 |
58 | );
59 | }
60 |
61 | export { Slider };
62 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes";
2 | import { Toaster as Sonner, ToasterProps } from "sonner";
3 |
4 | const Toaster = ({ ...props }: ToasterProps) => {
5 | const { theme = "system" } = useTheme();
6 |
7 | return (
8 |
20 | );
21 | };
22 |
23 | export { Toaster };
24 |
--------------------------------------------------------------------------------
/src/components/ui/start-date-picker.tsx:
--------------------------------------------------------------------------------
1 | import { format, subDays } from "date-fns";
2 | import { Calendar as CalendarIcon } from "lucide-react";
3 | import { cn } from "@/lib/utils";
4 | import { Button } from "@/components/ui/button";
5 | import { Calendar } from "@/components/ui/calendar";
6 | import {
7 | Popover,
8 | PopoverContent,
9 | PopoverTrigger,
10 | } from "@/components/ui/popover";
11 |
12 | interface StartDatePickerProps {
13 | startDate: Date | undefined;
14 | onStartDateChange: (date: Date | undefined) => void;
15 | minDate?: Date;
16 | maxDate?: Date;
17 | disabled?: boolean;
18 | className?: string;
19 | placeholder?: string;
20 | showReset?: boolean;
21 | onReset?: () => void;
22 | }
23 |
24 | export function StartDatePicker({
25 | startDate,
26 | onStartDateChange,
27 | minDate,
28 | maxDate,
29 | disabled = false,
30 | className,
31 | placeholder = "Select start date",
32 | showReset = false,
33 | onReset,
34 | }: StartDatePickerProps) {
35 | // Format date to display in UTC
36 | const formatDateUTC = (date: Date): string => {
37 | return format(date, "LLL dd, y");
38 | };
39 |
40 | const endDate = new Date(); // Always today
41 |
42 | const handleLast7Days = () => {
43 | const thirtyDaysAgo = subDays(endDate, 7);
44 | // Ensure we don't go before the minimum date
45 | const startDateToSet =
46 | minDate && thirtyDaysAgo < minDate ? minDate : thirtyDaysAgo;
47 | onStartDateChange(startDateToSet);
48 | };
49 |
50 | const handleLast30Days = () => {
51 | const thirtyDaysAgo = subDays(endDate, 30);
52 | // Ensure we don't go before the minimum date
53 | const startDateToSet =
54 | minDate && thirtyDaysAgo < minDate ? minDate : thirtyDaysAgo;
55 | onStartDateChange(startDateToSet);
56 | };
57 |
58 | const handleLast90Days = () => {
59 | const ninetyDaysAgo = subDays(endDate, 90);
60 | // Ensure we don't go before the minimum date
61 | const startDateToSet =
62 | minDate && ninetyDaysAgo < minDate ? minDate : ninetyDaysAgo;
63 | onStartDateChange(startDateToSet);
64 | };
65 |
66 | return (
67 |
68 | {/* Quick date selection buttons */}
69 |
70 |
77 | Last 7 days
78 |
79 |
86 | Last 30 days
87 |
88 |
95 | Last 90 days
96 |
97 | {showReset && onReset && (
98 |
105 | All time
106 |
107 | )}
108 |
109 |
110 | {/* Date picker */}
111 |
112 |
113 |
114 |
123 |
124 | {startDate ? (
125 | <>
126 | {formatDateUTC(startDate)} - {formatDateUTC(endDate)}
127 | >
128 | ) : (
129 | {placeholder}
130 | )}
131 |
132 |
133 |
134 | {
141 | if (minDate && date < minDate) return true;
142 | if (maxDate && date > maxDate) return true;
143 | return false;
144 | }}
145 | numberOfMonths={1}
146 | />
147 |
148 |
149 |
150 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------
/src/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
3 | import { type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { toggleVariants } from "@/components/ui/toggle";
7 |
8 | const ToggleGroupContext = React.createContext<
9 | VariantProps
10 | >({
11 | size: "default",
12 | variant: "default",
13 | });
14 |
15 | function ToggleGroup({
16 | className,
17 | variant,
18 | size,
19 | children,
20 | ...props
21 | }: React.ComponentProps &
22 | VariantProps) {
23 | return (
24 |
34 |
35 | {children}
36 |
37 |
38 | );
39 | }
40 |
41 | function ToggleGroupItem({
42 | className,
43 | children,
44 | variant,
45 | size,
46 | ...props
47 | }: React.ComponentProps &
48 | VariantProps) {
49 | const context = React.useContext(ToggleGroupContext);
50 |
51 | return (
52 |
66 | {children}
67 |
68 | );
69 | }
70 |
71 | export { ToggleGroup, ToggleGroupItem };
72 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TogglePrimitive from "@radix-ui/react-toggle";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const toggleVariants = cva(
10 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
11 | {
12 | variants: {
13 | variant: {
14 | default: "bg-transparent",
15 | outline:
16 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
17 | },
18 | size: {
19 | default: "h-9 px-2 min-w-9",
20 | sm: "h-8 px-1.5 min-w-8",
21 | lg: "h-10 px-2.5 min-w-10",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | },
29 | );
30 |
31 | function Toggle({
32 | className,
33 | variant,
34 | size,
35 | ...props
36 | }: React.ComponentProps &
37 | VariantProps) {
38 | return (
39 |
44 | );
45 | }
46 |
47 | export { Toggle, toggleVariants };
48 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | function TooltipProvider({
7 | delayDuration = 0,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
16 | );
17 | }
18 |
19 | function Tooltip({
20 | ...props
21 | }: React.ComponentProps) {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | function TooltipTrigger({
30 | ...props
31 | }: React.ComponentProps) {
32 | return ;
33 | }
34 |
35 | function TooltipContent({
36 | className,
37 | sideOffset = 4,
38 | children,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
43 |
52 | {children}
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
60 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const ALGO_BALANCE_THRESHOLD_FOR_REWARDS = 30_000;
2 | export const AVERAGE_DAY_IN_MONTH = Number((365 / 12).toFixed(2)); // 30.42
3 | export const BLOCK_ANXIETY_BLOG_POST_URL =
4 | "https://valar-staking.medium.com/where-is-my-block-70113da4d817";
5 |
--------------------------------------------------------------------------------
/src/hooks/useAccounts.ts:
--------------------------------------------------------------------------------
1 | import { ResolvedAddress } from "@/components/heatmap/types";
2 | import { indexerClient } from "@/lib/indexer-client";
3 | import { useQueries, useQuery } from "@tanstack/react-query";
4 |
5 | const getAccount = (address: string) => {
6 | return indexerClient
7 | .lookupAccountByID(address)
8 | .do()
9 | .then((res) => res.account);
10 | };
11 |
12 | export const useAccount = (address: ResolvedAddress) => {
13 | return useQuery({
14 | queryKey: ["account", address.address],
15 | queryFn: () => getAccount(address.address),
16 | refetchInterval: 1000 * 60 * 15, // 15 minutes
17 | });
18 | };
19 |
20 | export const useAccounts = (addresses: ResolvedAddress[]) => {
21 | return useQueries({
22 | queries: addresses.map((address) => ({
23 | queryKey: ["account", address.address],
24 | queryFn: () => getAccount(address.address),
25 | refetchInterval: 1000 * 60 * 15, // 15 minutes
26 | })),
27 | combine: (results) => {
28 | return {
29 | data: results.map((result) => result.data),
30 | pending: results.some((result) => result.isPending),
31 | };
32 | },
33 | });
34 | };
35 |
--------------------------------------------------------------------------------
/src/hooks/useAlgoPrice.ts:
--------------------------------------------------------------------------------
1 | // src/hooks/useAlgoPrice.ts
2 | import { useEffect, useState } from "react";
3 |
4 | type BinancePrice = {
5 | symbol: string;
6 | price: string;
7 | };
8 |
9 | // Cache the price data with timestamp
10 | let priceCache: {
11 | price: number;
12 | timestamp: number;
13 | } | null = null;
14 |
15 | // Cache validity in milliseconds (5 minutes)
16 | const CACHE_VALIDITY = 5 * 60 * 1000;
17 |
18 | // Pending request promise
19 | let pendingRequest: Promise | null = null;
20 |
21 | export function useAlgoPrice() {
22 | const [price, setPrice] = useState(priceCache?.price || null);
23 | const [loading, setLoading] = useState(!priceCache);
24 | const [error, setError] = useState(null);
25 |
26 | useEffect(() => {
27 | async function fetchPrice() {
28 | // Return cached price if it's still valid
29 | if (priceCache && Date.now() - priceCache.timestamp < CACHE_VALIDITY) {
30 | setPrice(priceCache.price);
31 | setLoading(false);
32 | return;
33 | }
34 |
35 | setLoading(true);
36 |
37 | try {
38 | // If there's already a pending request, reuse it
39 | if (!pendingRequest) {
40 | pendingRequest = fetch(
41 | "https://www.binance.com/api/v3/ticker/price?symbol=ALGOUSDC",
42 | )
43 | .then((response) => {
44 | if (!response.ok) {
45 | throw new Error(
46 | `Binance API responded with status: ${response.status}`,
47 | );
48 | }
49 | return response.json();
50 | })
51 | .then((data: BinancePrice) => {
52 | const numericPrice = parseFloat(data.price);
53 |
54 | // Update cache
55 | priceCache = {
56 | price: numericPrice,
57 | timestamp: Date.now(),
58 | };
59 |
60 | // Clear the pending request reference
61 | setTimeout(() => {
62 | pendingRequest = null;
63 | }, 0);
64 |
65 | return numericPrice;
66 | });
67 | }
68 |
69 | // Use the shared promise
70 | const numericPrice = await pendingRequest;
71 | setPrice(numericPrice);
72 | setError(null);
73 | } catch (err) {
74 | setError(err instanceof Error ? err : new Error(String(err)));
75 | // Clear pending request on error
76 | pendingRequest = null;
77 | } finally {
78 | setLoading(false);
79 | }
80 | }
81 |
82 | fetchPrice();
83 | }, []);
84 |
85 | return { price, loading, error };
86 | }
87 |
--------------------------------------------------------------------------------
/src/hooks/useAlgorandAddress.ts:
--------------------------------------------------------------------------------
1 | import { resolveNFD } from "@/queries/getResolvedNFD";
2 | import * as React from "react";
3 | import { ResolvedAddress } from "@/components/heatmap/types.ts";
4 |
5 | export const useAlgorandAddresses = (addresses: string[]) => {
6 | const [resolvedAddresses, setResolvedAddresses] = React.useState<
7 | ResolvedAddress[]
8 | >([]);
9 | const [loading, setLoading] = React.useState(true);
10 | const [hasError, setError] = React.useState(false);
11 |
12 | React.useEffect(() => {
13 | const resolveAddress = async () => {
14 | try {
15 | // Use Promise.all to wait for all the async operations to complete
16 | const resolved = await Promise.all(
17 | addresses.map(async (address) => {
18 | if (address.toLowerCase().endsWith(".algo")) {
19 | return { address: await resolveNFD(address), nfd: address };
20 | }
21 | return { address, nfd: null };
22 | }),
23 | );
24 |
25 | setResolvedAddresses(resolved);
26 | } catch (err) {
27 | console.error(err);
28 | setError(true);
29 | } finally {
30 | setLoading(false);
31 | }
32 | };
33 | resolveAddress();
34 | }, [addresses]);
35 |
36 | return { resolvedAddresses, loading, hasError };
37 | };
38 |
--------------------------------------------------------------------------------
/src/hooks/useAverageBlockTime.ts:
--------------------------------------------------------------------------------
1 | import { indexerClient } from "@/lib/indexer-client";
2 | import { useQuery } from "@tanstack/react-query";
3 |
4 | const DEFAULT_BLOCK_TIME = 2.8; // Default block time in seconds
5 | const getAverageBlockTime = () => {
6 | const minutesAgo = 5;
7 | const dateAgo = new Date(new Date().getTime() - minutesAgo * 60 * 1000);
8 |
9 | return indexerClient
10 | .searchForBlockHeaders()
11 | .limit(50)
12 | .afterTime(dateAgo.toISOString())
13 | .do()
14 | .then((res) => {
15 | const blocks = res.blocks;
16 | const timeDifferences: number[] = [];
17 |
18 | for (let i = 0; i < blocks.length - 1; i++) {
19 | const currentBlock = blocks[i];
20 | const nextBlock = blocks[i + 1];
21 |
22 | if (currentBlock.timestamp && nextBlock.timestamp) {
23 | const timeDiff = nextBlock.timestamp - currentBlock.timestamp;
24 | timeDifferences.push(timeDiff);
25 | }
26 | }
27 |
28 | if (timeDifferences.length === 0) {
29 | console.error(
30 | "No time differences found, returning default block time",
31 | );
32 | return DEFAULT_BLOCK_TIME;
33 | }
34 |
35 | const averageTimeDiff =
36 | timeDifferences.reduce((sum, diff) => sum + diff, 0) /
37 | timeDifferences.length;
38 |
39 | return Math.round(averageTimeDiff * 100) / 100;
40 | });
41 | };
42 | export const useAverageBlockTime = () => {
43 | return useQuery({
44 | queryKey: ["averageBlockTime"],
45 | queryFn: getAverageBlockTime,
46 | });
47 | };
48 |
--------------------------------------------------------------------------------
/src/hooks/useBlock.ts:
--------------------------------------------------------------------------------
1 | import { indexerClient } from "@/lib/indexer-client";
2 | import { useQuery } from "@tanstack/react-query";
3 |
4 | const getBlock = (round: number) => {
5 | return indexerClient.lookupBlock(round).do();
6 | };
7 |
8 | export const useBlock = (round?: number) => {
9 | return useQuery({
10 | queryKey: ["block", round],
11 | enabled: round !== undefined,
12 | queryFn: () => {
13 | // Safety check inside query function
14 | if (round === undefined) {
15 | throw new Error("Block round is required");
16 | }
17 | return getBlock(round);
18 | },
19 | });
20 | };
21 |
--------------------------------------------------------------------------------
/src/hooks/useCurrentRound.ts:
--------------------------------------------------------------------------------
1 | import { indexerClient } from "@/lib/indexer-client";
2 | import { useQuery } from "@tanstack/react-query";
3 |
4 | const getCurrentRound = () => {
5 | return indexerClient
6 | .searchForBlockHeaders()
7 | .limit(1)
8 | .do()
9 | .then((res) => res.currentRound);
10 | };
11 | export const useCurrentRound = () => {
12 | return useQuery({
13 | queryKey: ["currentRound"],
14 | queryFn: getCurrentRound,
15 | refetchInterval: 1000 * 60, // Refetch every minute
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/src/hooks/useIsSmallScreen.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | /**
4 | * Custom hook that returns a boolean indicating if the current screen width
5 | * is less than the provided threshold
6 | *
7 | * @param threshold - The width threshold in pixels (default: 768px for md breakpoint)
8 | * @returns boolean - True if screen width is less than threshold
9 | */
10 | export function useIsSmallScreen(threshold = 768): boolean {
11 | const [isSmallScreen, setIsSmallScreen] = useState(false);
12 |
13 | useEffect(() => {
14 | // Initial check
15 | const checkScreenSize = () => {
16 | setIsSmallScreen(window.innerWidth < threshold);
17 | };
18 |
19 | // Set initial value
20 | checkScreenSize();
21 |
22 | // Add event listener for resize
23 | window.addEventListener("resize", checkScreenSize);
24 |
25 | // Cleanup
26 | return () => {
27 | window.removeEventListener("resize", checkScreenSize);
28 | };
29 | }, [threshold]);
30 |
31 | return isSmallScreen;
32 | }
33 |
--------------------------------------------------------------------------------
/src/hooks/useRewardTransactions.ts:
--------------------------------------------------------------------------------
1 | import { getAccountsBlockHeaders } from "@/queries/getAccountsBlockHeaders";
2 | import * as React from "react";
3 | import { Block } from "algosdk/client/indexer";
4 | import { ResolvedAddress } from "@/components/heatmap/types.ts";
5 |
6 | export const useBlocks = (addresses: ResolvedAddress[]) => {
7 | const [data, setData] = React.useState([]);
8 | const [loading, setLoading] = React.useState(true);
9 | const [hasError, setError] = React.useState(false);
10 |
11 | React.useEffect(() => {
12 | if (addresses.length === 0) {
13 | return;
14 | }
15 |
16 | const loadData = async () => {
17 | try {
18 | setLoading(true);
19 | const result = await getAccountsBlockHeaders(addresses);
20 | setData(result);
21 | } catch (err) {
22 | console.error(err);
23 | setError(true);
24 | } finally {
25 | setLoading(false);
26 | }
27 | };
28 |
29 | loadData();
30 | }, [addresses]);
31 |
32 | return { data, loading, hasError };
33 | };
34 |
--------------------------------------------------------------------------------
/src/hooks/useStakeInfo.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | //https://afmetrics.api.nodely.io/v1/api-docs/#get-/v1/realtime/participation/online
4 |
5 | type RewardsStats = {
6 | apy_pct: number; // Annual Percentage Yield (annualized rate with compounding)
7 | as_of_round: string; // Data valid as of round
8 | avg_payout_24hr: number; // Avg block reward payout in microAlgo
9 | eligible: number; // Number of rewards eligible online accounts
10 | eligible_stake_micro_algo: string; // Total eligible stake in microAlgo
11 | max_stake: string; // Maximum stake
12 | online: number; // Number of online accounts
13 | online_above30k: number; // Number of online accounts above 30K Algo
14 | p10_stake: string; // 10th percentile stake
15 | p25_stake: string; // 25th percentile stake
16 | p50_stake: string; // Median stake
17 | reward_rate_pct: number; // Last 24hr reward rate in pct (annualized)
18 | stake_micro_algo: string; // Total stake in microAlgo
19 | };
20 |
21 | const fetchStakeInfo = async (): Promise => {
22 | const response = await fetch(
23 | "https://afmetrics.api.nodely.io/v1/realtime/participation/online",
24 | );
25 |
26 | if (!response.ok) {
27 | throw new Error(
28 | `Failed to fetch stake information: ${response.status} ${response.statusText}`,
29 | );
30 | }
31 |
32 | return response.json();
33 | };
34 |
35 | export function useStakeInfo() {
36 | return useQuery({
37 | queryKey: ["stakeInfo"],
38 | queryFn: fetchStakeInfo,
39 | });
40 | }
41 |
--------------------------------------------------------------------------------
/src/lib/csv-columns.ts:
--------------------------------------------------------------------------------
1 | export type CsvColumnId =
2 | | "timestamp"
3 | | "utc_date"
4 | | "utc_time"
5 | | "address"
6 | | "round"
7 | | "payout_micro_algo"
8 | | "payout_algo"
9 | | "vestige_vwap"
10 | | "binance_open_time"
11 | | "binance_close_time"
12 | | "binance_open_price"
13 | | "binance_close_price"
14 | | "binance_high_price"
15 | | "binance_low_price";
16 |
17 | export type CsvColumn = {
18 | id: CsvColumnId;
19 | label: string;
20 | isPriceColumn: boolean;
21 | example?: string;
22 | help?: string;
23 | };
24 |
25 | export const CSV_COLUMNS: CsvColumn[] = [
26 | {
27 | id: "timestamp",
28 | label: "Timestamp",
29 | isPriceColumn: false,
30 | example: "1651234567",
31 | help: "Unix timestamp (seconds since epoch) of the block proposal",
32 | },
33 | {
34 | id: "utc_date",
35 | label: "UTC Date",
36 | isPriceColumn: false,
37 | example: "2023-05-01",
38 | help: "Calendar date in YYYY-MM-DD format (UTC Timezone)",
39 | },
40 | {
41 | id: "utc_time",
42 | label: "UTC Time",
43 | isPriceColumn: false,
44 | example: "12:34:56Z",
45 | help: "Time of day in HH:MM:SSZ format (UTC Timezone)",
46 | },
47 | {
48 | id: "address",
49 | label: "Address",
50 | isPriceColumn: false,
51 | example: "ABC...XYZ",
52 | help: "Algorand wallet address of the block proposer",
53 | },
54 | {
55 | id: "round",
56 | label: "Round",
57 | isPriceColumn: false,
58 | example: "25123456",
59 | help: "Algorand block round number",
60 | },
61 | {
62 | id: "payout_micro_algo",
63 | label: "Payout (microAlgo)",
64 | isPriceColumn: false,
65 | example: "1234567",
66 | help: "Block proposer reward in microAlgos (1 Algo = 1,000,000 microAlgos)",
67 | },
68 | {
69 | id: "payout_algo",
70 | label: "Payout (Algo)",
71 | isPriceColumn: false,
72 | example: "1.234567",
73 | help: "Block proposer reward converted to Algo units",
74 | },
75 | {
76 | id: "vestige_vwap",
77 | label: "Vestige VWAP (USDC)",
78 | isPriceColumn: true,
79 | example: "0.123456",
80 | help: "Vestige's volume-weighted average price (in USDC) for Algorand for a given day (UTC)",
81 | },
82 | {
83 | id: "binance_open_time",
84 | label: "Binance Open Time",
85 | isPriceColumn: true,
86 | example: "1651219200000",
87 | help: "Timestamp for the start of the day's trading period on Binance",
88 | },
89 | {
90 | id: "binance_close_time",
91 | label: "Binance Close Time",
92 | isPriceColumn: true,
93 | example: "1651305599999",
94 | help: "Timestamp for the end of the day's trading period on Binance",
95 | },
96 | {
97 | id: "binance_open_price",
98 | label: "Binance Open Price (USDC)",
99 | isPriceColumn: true,
100 | example: "0.29450000",
101 | help: "Opening price of Algorand in USDC for the day",
102 | },
103 | {
104 | id: "binance_close_price",
105 | label: "Binance Close Price (USDC)",
106 | isPriceColumn: true,
107 | example: "0.29750000",
108 | help: "Closing price of Algorand in USDC for the day",
109 | },
110 | {
111 | id: "binance_high_price",
112 | label: "Binance High Price (USDC)",
113 | isPriceColumn: true,
114 | example: "0.30100000",
115 | help: "Highest price of Algorand in USDC during the day",
116 | },
117 | {
118 | id: "binance_low_price",
119 | label: "Binance Low Price (USDC)",
120 | isPriceColumn: true,
121 | example: "0.28950000",
122 | help: "Lowest price of Algorand in USDC during the day",
123 | },
124 | ];
125 |
--------------------------------------------------------------------------------
/src/lib/date-utils.ts:
--------------------------------------------------------------------------------
1 | // src/lib/date-utils.ts
2 | export function generateDateRange(startTimestamp?: number): string[] {
3 | // Create an array of all dates between first timestamp and today
4 | const firstDate = startTimestamp
5 | ? new Date(startTimestamp * 1000)
6 | : new Date();
7 |
8 | // Set to start of day in local timezone
9 | firstDate.setHours(0, 0, 0, 0);
10 |
11 | const endDate = new Date(); // Today's date
12 | const allDates = [];
13 | const currentDate = new Date(firstDate);
14 |
15 | // Generate all dates between first date and today (inclusive)
16 | while (currentDate <= endDate) {
17 | // Use date formatting that respects local timezone instead of ISO
18 | const year = currentDate.getFullYear();
19 | const month = String(currentDate.getMonth() + 1).padStart(2, "0");
20 | const day = String(currentDate.getDate()).padStart(2, "0");
21 | const dateStr = `${year}-${month}-${day}`;
22 | allDates.push(dateStr);
23 |
24 | // Move to next day
25 | currentDate.setDate(currentDate.getDate() + 1);
26 | }
27 |
28 | return allDates;
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/indexer-client.ts:
--------------------------------------------------------------------------------
1 | import algosdk from "algosdk";
2 |
3 | export const indexerClient = new algosdk.Indexer(
4 | "",
5 | "https://mainnet-idx.4160.nodely.dev",
6 | 443,
7 | );
8 |
--------------------------------------------------------------------------------
/src/lib/mockTimezone.ts:
--------------------------------------------------------------------------------
1 | import { register, unregister, TimeZone } from "timezone-mock";
2 |
3 | /**
4 | * Converts standard GMT notation to timezone-mock format
5 | * Note: timezone-mock uses reversed notation where Etc/GMT+2 means GMT-2
6 | */
7 | export function setTimezone(zone: `GMT${"+" | "-"}${number}`): () => void {
8 | // Extract the sign and number from the input
9 | const match = zone.match(/^GMT([+-])(\d+)$/);
10 | if (!match) {
11 | throw new Error(
12 | `Invalid timezone format: ${zone}. Use format "GMT+n" or "GMT-n"`,
13 | );
14 | }
15 |
16 | const [, sign, numStr] = match;
17 | const num = parseInt(numStr, 10);
18 |
19 | if (num < 0 || num > 14) {
20 | throw new Error(`Timezone offset must be between 0 and 14 (got ${num})`);
21 | }
22 |
23 | // Reverse the sign for timezone-mock's reversed notation
24 | const reversedSign = sign === "+" ? "-" : "+";
25 | const mockZone = `Etc/GMT${reversedSign}${num}` as TimeZone;
26 |
27 | register(mockZone);
28 |
29 | // Return cleanup function
30 | return () => unregister();
31 | }
32 |
33 | /**
34 | * Type-safe GMT timezone offsets
35 | * Allows values from GMT-12 to GMT+14
36 | */
37 | export type GMTOffset =
38 | | "GMT+0"
39 | | "GMT+1"
40 | | "GMT+2"
41 | | "GMT+3"
42 | | "GMT+4"
43 | | "GMT+5"
44 | | "GMT+6"
45 | | "GMT+7"
46 | | "GMT+8"
47 | | "GMT+9"
48 | | "GMT+10"
49 | | "GMT+11"
50 | | "GMT+12"
51 | | "GMT+13"
52 | | "GMT+14"
53 | | "GMT-0"
54 | | "GMT-1"
55 | | "GMT-2"
56 | | "GMT-3"
57 | | "GMT-4"
58 | | "GMT-5"
59 | | "GMT-6"
60 | | "GMT-7"
61 | | "GMT-8"
62 | | "GMT-9"
63 | | "GMT-10"
64 | | "GMT-11"
65 | | "GMT-12";
66 |
67 | /**
68 | * Type-safe timezone setter
69 | * @returns A cleanup function to restore the timezone
70 | */
71 | export function setGMTTimezone(zone: GMTOffset): () => void {
72 | return setTimezone(zone);
73 | }
74 |
75 | /**
76 | * @returns A cleanup function to restore the timezone
77 | */
78 | export function setUTCTimezone(): () => void {
79 | register("UTC");
80 | return () => unregister();
81 | }
82 |
--------------------------------------------------------------------------------
/src/lib/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { formatMinutes } from "./utils";
2 | import { describe, it, expect } from "vitest";
3 |
4 | describe("formatMinutes", () => {
5 | it("should format minutes into hours and minutes when appropriate", () => {
6 | // Basic cases
7 | expect(formatMinutes(70)).toBe("1h 10m");
8 | expect(formatMinutes(0)).toBe("0m");
9 | expect(formatMinutes(1)).toBe("1m");
10 |
11 | // Edge cases
12 | expect(formatMinutes(60)).toBe("1h 0m");
13 | expect(formatMinutes(59)).toBe("59m");
14 | expect(formatMinutes(61)).toBe("1h 1m");
15 |
16 | // Larger numbers
17 | expect(formatMinutes(60000)).toBe("1000h 0m");
18 | expect(formatMinutes(90)).toBe("1h 30m");
19 | expect(formatMinutes(120)).toBe("2h 0m");
20 | expect(formatMinutes(1439)).toBe("23h 59m");
21 | });
22 |
23 | it("should handle decimal values by rounding down", () => {
24 | expect(formatMinutes(70.8)).toBe("1h 10m");
25 | expect(formatMinutes(1.9)).toBe("1m");
26 | expect(formatMinutes(0.4)).toBe("0m");
27 | });
28 |
29 | it("should throw an error for negative values", () => {
30 | expect(() => formatMinutes(-70)).toThrow("Minutes cannot be negative");
31 | expect(() => formatMinutes(-1)).toThrow("Minutes cannot be negative");
32 | expect(() => formatMinutes(-60)).toThrow("Minutes cannot be negative");
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function displayAlgoAddress(address: string, lettersToDisplay = 5) {
9 | return `${address.slice(0, lettersToDisplay)}...${address.slice(
10 | -lettersToDisplay,
11 | )}`;
12 | }
13 |
14 | export function calculateAPYAndProjection(
15 | rewards: number, // rewards received in the period
16 | algoStaked: number, // amount of Algo staked/held
17 | days: number, // period duration
18 | ): { apy: number; projectedTotal: number } {
19 | if (algoStaked === 0 || days === 0) return { apy: 0, projectedTotal: 0 };
20 |
21 | const apy = parseFloat(
22 | ((rewards / algoStaked) * (365 / days) * 100).toFixed(2),
23 | );
24 | const projectedTotal = (rewards / days) * 365;
25 | return { apy, projectedTotal };
26 | }
27 |
28 | export function formatMinutes(minutes: number): string {
29 | if (minutes < 0) {
30 | throw new Error("Minutes cannot be negative");
31 | }
32 |
33 | // Calculate hours and remaining minutes
34 | const hours = Math.floor(minutes / 60);
35 | const mins = Math.floor(minutes % 60);
36 |
37 | // Format the result
38 | if (hours > 0) {
39 | return `${hours}h ${mins}m`;
40 | } else {
41 | return `${mins}m`;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { scan } from "react-scan"; // must be imported before React and React DOM
2 |
3 | import { StrictMode } from "react";
4 | import { createRoot } from "react-dom/client";
5 | import "./App.css";
6 | import { RouterProvider, createRouter } from "@tanstack/react-router";
7 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
8 |
9 | import { routeTree } from "./routeTree.gen";
10 | import { ThemeProvider } from "@/components/theme-provider.tsx";
11 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
12 | scan({
13 | enabled: false,
14 | });
15 |
16 | // Create a new router instance
17 | const router = createRouter({ routeTree });
18 | const queryClient = new QueryClient();
19 |
20 | // Register the router instance for type safety
21 | declare module "@tanstack/react-router" {
22 | interface Register {
23 | router: typeof router;
24 | }
25 | }
26 |
27 | createRoot(document.getElementById("root")!).render(
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ,
36 | );
37 |
--------------------------------------------------------------------------------
/src/queries/getAccountsBlockHeaders.ts:
--------------------------------------------------------------------------------
1 | import { executePaginatedRequest } from "@algorandfoundation/algokit-utils";
2 | import { Block, BlockHeadersResponse } from "algosdk/client/indexer";
3 | import { ResolvedAddress } from "@/components/heatmap/types.ts";
4 | import { indexerClient } from "@/lib/indexer-client";
5 |
6 | export async function getAccountsBlockHeaders(
7 | addresses: ResolvedAddress[],
8 | ): Promise {
9 | const blocks = await executePaginatedRequest(
10 | (response: BlockHeadersResponse) => {
11 | return response.blocks;
12 | },
13 | (nextToken) => {
14 | let s = indexerClient
15 | .searchForBlockHeaders()
16 | .minRound(46512890)
17 | .limit(1000)
18 | .proposers(addresses.map((a: ResolvedAddress) => a.address));
19 | if (nextToken) {
20 | s = s.nextToken(nextToken);
21 | }
22 | return s;
23 | },
24 | );
25 | return blocks.filter(
26 | (block) => block.proposerPayout && block.proposerPayout > 0,
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/queries/getResolvedNFD.ts:
--------------------------------------------------------------------------------
1 | export async function resolveNFD(nfd: string): Promise {
2 | try {
3 | const response = await fetch(
4 | `https://api.nf.domains/nfd/${nfd.toLowerCase()}`,
5 | );
6 | const data = await response.json();
7 | return data.depositAccount;
8 | } catch (error) {
9 | console.error("Error resolving NFD:", error);
10 | return "";
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/routeTree.gen.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | // @ts-nocheck
4 |
5 | // noinspection JSUnusedGlobalSymbols
6 |
7 | // This file was automatically generated by TanStack Router.
8 | // You should NOT make any changes in this file as it will be overwritten.
9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10 |
11 | // Import Routes
12 |
13 | import { Route as rootRoute } from "./routes/__root";
14 | import { Route as AddressesImport } from "./routes/$addresses";
15 | import { Route as IndexImport } from "./routes/index";
16 |
17 | // Create/Update Routes
18 |
19 | const AddressesRoute = AddressesImport.update({
20 | id: "/$addresses",
21 | path: "/$addresses",
22 | getParentRoute: () => rootRoute,
23 | } as any);
24 |
25 | const IndexRoute = IndexImport.update({
26 | id: "/",
27 | path: "/",
28 | getParentRoute: () => rootRoute,
29 | } as any);
30 |
31 | // Populate the FileRoutesByPath interface
32 |
33 | declare module "@tanstack/react-router" {
34 | interface FileRoutesByPath {
35 | "/": {
36 | id: "/";
37 | path: "/";
38 | fullPath: "/";
39 | preLoaderRoute: typeof IndexImport;
40 | parentRoute: typeof rootRoute;
41 | };
42 | "/$addresses": {
43 | id: "/$addresses";
44 | path: "/$addresses";
45 | fullPath: "/$addresses";
46 | preLoaderRoute: typeof AddressesImport;
47 | parentRoute: typeof rootRoute;
48 | };
49 | }
50 | }
51 |
52 | // Create and export the route tree
53 |
54 | export interface FileRoutesByFullPath {
55 | "/": typeof IndexRoute;
56 | "/$addresses": typeof AddressesRoute;
57 | }
58 |
59 | export interface FileRoutesByTo {
60 | "/": typeof IndexRoute;
61 | "/$addresses": typeof AddressesRoute;
62 | }
63 |
64 | export interface FileRoutesById {
65 | __root__: typeof rootRoute;
66 | "/": typeof IndexRoute;
67 | "/$addresses": typeof AddressesRoute;
68 | }
69 |
70 | export interface FileRouteTypes {
71 | fileRoutesByFullPath: FileRoutesByFullPath;
72 | fullPaths: "/" | "/$addresses";
73 | fileRoutesByTo: FileRoutesByTo;
74 | to: "/" | "/$addresses";
75 | id: "__root__" | "/" | "/$addresses";
76 | fileRoutesById: FileRoutesById;
77 | }
78 |
79 | export interface RootRouteChildren {
80 | IndexRoute: typeof IndexRoute;
81 | AddressesRoute: typeof AddressesRoute;
82 | }
83 |
84 | const rootRouteChildren: RootRouteChildren = {
85 | IndexRoute: IndexRoute,
86 | AddressesRoute: AddressesRoute,
87 | };
88 |
89 | export const routeTree = rootRoute
90 | ._addFileChildren(rootRouteChildren)
91 | ._addFileTypes();
92 |
93 | /* ROUTE_MANIFEST_START
94 | {
95 | "routes": {
96 | "__root__": {
97 | "filePath": "__root.tsx",
98 | "children": [
99 | "/",
100 | "/$addresses"
101 | ]
102 | },
103 | "/": {
104 | "filePath": "index.tsx"
105 | },
106 | "/$addresses": {
107 | "filePath": "$addresses.tsx"
108 | }
109 | }
110 | }
111 | ROUTE_MANIFEST_END */
112 |
--------------------------------------------------------------------------------
/src/routes/$addresses.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import AddressView from "@/components/address/address-view";
3 | import { ThemeSetting } from "@/components/theme-provider";
4 |
5 | type AddressSearch = {
6 | hideBalance: boolean;
7 | theme: ThemeSetting;
8 | statsPanelTheme: "light" | "indigo";
9 | };
10 |
11 | export const Route = createFileRoute("/$addresses")({
12 | component: Address,
13 | validateSearch: (search: Record): AddressSearch => {
14 | return {
15 | hideBalance: search.hideBalance === true,
16 | statsPanelTheme:
17 | typeof search.statsPanelTheme === "string" &&
18 | ["light", "indigo"].includes(search.statsPanelTheme)
19 | ? (search.statsPanelTheme as "light" | "indigo")
20 | : "indigo",
21 |
22 | theme:
23 | typeof search.theme === "string" &&
24 | ["dark", "light", "system"].includes(search.theme)
25 | ? (search.theme as ThemeSetting)
26 | : "system",
27 | };
28 | },
29 | });
30 |
31 | function Address() {
32 | const { addresses } = Route.useParams();
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/src/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import { createRootRoute, Outlet } from "@tanstack/react-router";
2 | import React, { Suspense } from "react";
3 | import Footer from "@/components/footer.tsx";
4 | import { Toaster } from "@/components/ui/sonner";
5 | import { GithubCorner } from "@/components/github-corner";
6 |
7 | const TanStackRouterDevtools =
8 | process.env.NODE_ENV === "production"
9 | ? () => null // Render nothing in production
10 | : React.lazy(() =>
11 | // Lazy load in development
12 | import("@tanstack/react-router-devtools").then((res) => ({
13 | default: res.TanStackRouterDevtools,
14 | // For Embedded Mode
15 | // default: res.TanStackRouterDevtoolsPanel
16 | })),
17 | );
18 |
19 | export const Route = createRootRoute({
20 | component: () => (
21 | <>
22 |
23 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | >
54 | ),
55 | });
56 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import SearchBar from "../components/search-bar.tsx";
3 | import StakingCta from "@/components/staking-cta.tsx";
4 |
5 | import { motion } from "motion/react";
6 | import CoinsSpread from "@/components/coins-spread.tsx";
7 |
8 | export const Route = createFileRoute("/")({
9 | component: Index,
10 | });
11 |
12 | function Index() {
13 | return (
14 | <>
15 |
16 |
28 |
29 |
30 |
31 |
32 |
33 |
42 | Cool stats for your Algorand staking rewards
43 |
44 |
54 | Get your total node rewards and identify peak performance periods
55 | with our detailed rewards heatmap
56 |
57 |
58 |
59 |
60 |
61 |
62 |
74 |
75 |
76 | >
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 |
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/*": ["./src/*"]
28 | }
29 | },
30 | "include": ["src"]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | }
8 | },
9 | "references": [
10 | { "path": "./tsconfig.app.json" },
11 | { "path": "./tsconfig.node.json" }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import tailwindcss from "@tailwindcss/vite";
4 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
5 | import path from "path";
6 |
7 | // https://vite.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | TanStackRouterVite({ autoCodeSplitting: true }),
11 | react(),
12 | tailwindcss(),
13 | ],
14 | server: {
15 | allowedHosts: true,
16 | },
17 | resolve: {
18 | alias: {
19 | "@": path.resolve(__dirname, "./src"),
20 | },
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: "jsdom",
6 | },
7 | });
8 |
--------------------------------------------------------------------------------