Compare commits

...

78 Commits

Author SHA1 Message Date
Yana 5bdc4220f8 Add .env.example 2025-07-14 21:35:24 +03:00
Yana 5a3a761465 fix build 2025-07-14 16:50:37 +03:00
Yana cf27909eb3 fix build 2025-07-14 16:39:46 +03:00
Yana e0b4b7a4e7 banner 2025-07-14 16:31:36 +03:00
Yana d55c5f2dff feat: add conditional card wrappers to prevent empty grid spaces 2025-07-14 15:39:35 +03:00
Yana 28bd071237 Improve NoiseCard fix - check array length before accessing elements 2025-07-12 00:22:41 +03:00
Yana 7bc997f420 Fix TypeError in NoiseCard component - add null checks and optional chaining for data access 2025-07-12 00:00:43 +03:00
Yana 1661f6546d Add rewards and not bonded chip to stake table 2025-06-25 21:30:08 +03:00
Yana 63b78b24cc Add light mode styles 2025-06-25 19:19:19 +03:00
Yana 8926ade582 fix build 2025-06-25 18:55:21 +03:00
Yana 8a367ea4cf Adjust header menus 2025-06-25 18:49:51 +03:00
Yana 1eb39d1383 fix build 2025-06-24 15:33:52 +03:00
Yana 60ce7a4d58 Add basepath on environment change 2025-06-24 15:26:58 +03:00
Yana d0b530e52d Fix build 2025-06-19 12:19:00 +03:00
Yana 95476eee0c Fix build 2025-06-19 12:11:43 +03:00
Yana 08388c0792 fixes 2025-06-18 20:51:13 +03:00
Yana 884d216728 Clean up types 2025-06-18 20:37:27 +03:00
Yana 9533c52a30 Add Sandbox Gateways 2025-06-18 20:30:49 +03:00
Yana 7f1a320ef7 Fix redeem rewards disabled button 2025-06-18 20:25:31 +03:00
Yana 614933f7eb Fix Sandbox staking pending events 2025-06-18 20:14:41 +03:00
Yana 05c6768474 Add Sandbox to wallet, staking, replace SpectreDao balances 2025-06-18 19:57:13 +03:00
Yana d80dcd1876 Add Sandbox to Account page 2025-06-17 19:49:18 +03:00
Yana 3f0624c0c7 Add Sandbox to Node page 2025-06-17 19:45:01 +03:00
Yana 0db8f1f839 Add Sandbox to landing page 2025-06-17 19:12:44 +03:00
Yana 202ca37745 refactor EnvironmentProvider, add environment to NodesTable 2025-06-16 20:57:20 +03:00
Yana 6268544c5b Add environment and urls 2025-06-16 16:12:04 +03:00
Yana b0de91b34e Replace balances endpoint 2025-06-09 21:37:15 +03:00
Yana 28b4fe7e7e add 10 recommended nodes 2025-06-05 12:33:43 +03:00
Yana 9479d2a383 Add recommended nodes 2025-06-04 19:47:53 +03:00
Yana 886b4410aa Fix open in new tab click on NodeTable 2025-06-03 14:28:17 +03:00
Yana b51358fb12 Style fixes 2025-05-22 14:24:24 +03:00
Yana 53e3acaa37 Add countries and locations to WorldMap 2025-05-21 17:11:52 +03:00
Yana 978817baf7 fix build 2025-05-15 19:20:16 +03:00
Yana 9319a5ec04 fix self-bond, redirect articles to nym/blog 2025-05-15 19:15:29 +03:00
Yana 3186db2915 style fixes 2025-05-14 20:47:26 +03:00
Yana ff7671f28a update copy 2025-05-14 20:38:07 +03:00
Yana cbe8eec2a4 fix dark mode font color 2025-05-14 19:53:07 +03:00
Yana 42f9edd408 Add self-bond and operating costs to NodeTable 2025-05-14 19:40:31 +03:00
Yana 128cf7c070 Add colors on uptime 2025-05-09 15:46:50 +03:00
Yana 79e5004849 revamp NodeTable 2025-05-09 15:27:54 +03:00
Yana 0d6722f9f5 'Change footer version to 2.2 2025-05-08 15:17:28 +03:00
Yana d458df9c34 fix build 2025-05-08 15:08:48 +03:00
Yana 7a8ac59a36 Add default sorting by country to Node tables 2025-05-08 14:56:04 +03:00
Yana ad3eb7a84c fix build 2025-05-07 19:54:09 +03:00
Yana 135f248eba Replace spectreDao delegations 2025-05-07 18:59:05 +03:00
Yana 7012bf9886 Add node count on every quick filter 2025-05-06 16:25:40 +03:00
Yana 88aa32ddeb Fix advanced filtering UI 2025-05-06 16:15:23 +03:00
Yana 7c1c9976f0 fix build 2025-05-04 19:27:47 +03:00
Yana 4ee7f7eaf5 Fix saturation filter 2025-05-04 19:23:35 +03:00
Yana 778772d96a fix build 2025-05-04 19:16:30 +03:00
Yana 5b791b41aa Add advanced filters 2025-05-04 19:13:34 +03:00
Yana 4b7e51fc3b Add quick filters on NodeTable 2025-05-04 11:27:29 +03:00
Yana 0a42dd3e0d fix mobile map 2025-04-22 20:20:44 +03:00
Yana 7cf49f642d fix images 2025-04-22 19:47:40 +03:00
Yana 089ab65dd7 Fix maps 2025-04-22 18:51:29 +03:00
Yana c1fabae770 Clean up 2025-04-17 18:25:43 +03:00
Yana 3ed7cfa381 Replace SpectreDao on AccountPageButtonGroup 2025-04-17 18:21:30 +03:00
Yana 4fe83da99d Replace SpectreDao api in Staking Table 2025-04-17 18:16:13 +03:00
Yana 4f81fc7400 Replace SpectreDao api on Magic Search 2025-04-17 17:55:52 +03:00
Yana 6d601ca654 Replace SpectreDao api on Stakers Card 2025-04-17 17:46:35 +03:00
Yana cea3ad9908 Add dark mode on error cards 2025-04-17 17:36:27 +03:00
Yana e4ecd099cc Add dark mode on error cards 2025-04-17 17:28:08 +03:00
Yana 0723542c39 clean up 2025-04-16 21:20:14 +03:00
Yana 523e559ff8 clean up 2025-04-16 21:17:15 +03:00
Yana 02b27573de clean up 2025-04-16 21:08:31 +03:00
Yana 8f229737a3 Replace SpectreDao on NodeTable and Node page 2025-04-16 21:06:12 +03:00
Yana 1afd13d6e0 Clean up 2025-04-16 15:27:53 +03:00
Yana df10b5595a Add styles 2025-04-16 15:23:05 +03:00
Yana 443031ba66 test data fetching 2025-04-16 13:37:35 +03:00
Yana 8d340a49d3 fix data fetching 2025-04-16 09:57:27 +03:00
Yana e0925d3c7f clean up 2025-04-16 08:40:34 +03:00
Yana 89d391da29 fix build 2025-04-16 08:13:21 +03:00
Yana cc2d7d34d2 reset last changes 2025-04-16 08:05:04 +03:00
Yana 969070f938 fix build, fix map sizes 2025-04-15 21:38:05 +03:00
Yana 3dfcae9369 fix build 2025-04-15 21:04:58 +03:00
Yana 32a4bf1172 fix build 2025-04-15 20:54:37 +03:00
Yana 433cac8c58 Fix map sizing 2025-04-15 18:15:00 +03:00
Yana 4fc64a072c Add WorldMap 2025-04-15 16:47:37 +03:00
85 changed files with 52358 additions and 48080 deletions
View File
+57
View File
@@ -0,0 +1,57 @@
import { type Environment } from "../src/providers/EnvironmentProvider";
interface EnvConfig {
envName: Environment;
basePath: string;
apiUrl?: string;
}
function log(message?: any, ...optionalParams: any[]) {
if (
process.env.NODE_ENV === "development" ||
process.env.DEBUG_CONFIG_LOGS === "true"
) {
console.log(message, ...optionalParams);
}
}
function getMainnetEnv(): EnvConfig {
return {
envName: "mainnet",
basePath: "/explorer",
// apiUrl:
// process.env.NEXT_PUBLIC_MAINNET_API_URL || "https://nym.com/explorer",
};
}
function getSandboxEnv(): EnvConfig {
return {
envName: "sandbox",
basePath: "/sandbox-explorer",
// apiUrl:
// process.env.NEXT_PUBLIC_SANDBOX_API_URL ||
// "https://nym.com/sandbox-explorer",
};
}
export const getEnvByName = (name: Environment): EnvConfig => {
if (name === "sandbox") {
return getSandboxEnv();
}
if (name === "mainnet") {
return getMainnetEnv();
}
// Default to mainnet
log("🐼 using mainnet env vars");
return getMainnetEnv();
};
export const getBasePathByEnv = (env: Environment): string => {
return getEnvByName(env).basePath;
};
// export const getApiUrlByEnv = (env: Environment): string => {
// return getEnvByName(env).apiUrl;
// };
+19 -9
View File
@@ -2,19 +2,29 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
basePath: "/explorer",
assetPrefix: "/explorer",
trailingSlash: false,
async redirects() {
async rewrites() {
return [
// Change the basePath to /explorer
// Rewrite /sandbox-explorer to root
{
source: "/",
destination: "/explorer",
basePath: false,
permanent: true,
source: "/sandbox-explorer",
destination: "/",
},
// Rewrite /explorer to root
{
source: "/explorer",
destination: "/",
},
// Rewrite /sandbox-explorer/* to /*
{
source: "/sandbox-explorer/:path*",
destination: "/:path*",
},
// Rewrite /explorer/* to /*
{
source: "/explorer/:path*",
destination: "/:path*",
},
];
},
+4
View File
@@ -35,6 +35,7 @@
"@uidotdev/usehooks": "^2.4.1",
"chain-registry": "^1.69.64",
"cldr-compact-number": "^0.4.0",
"d3-scale": "^4.0.2",
"date-fns": "^4.1.0",
"i18next": "^24.2.2",
"i18next-resources-to-backend": "^1.2.1",
@@ -49,6 +50,8 @@
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.3",
"react-random-avatars": "^1.3.1",
"react-simple-maps": "^3.0.0",
"react-tooltip": "^5.28.1",
"react-world-flags": "^1.6.0",
"zod": "^3.24.1"
},
@@ -57,6 +60,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-simple-maps": "^3.0.6",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"lefthook": "^1.8.5",
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

@@ -0,0 +1,34 @@
"use client";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../../../../envs/config";
interface AccountNotFoundClientProps {
address: string;
}
export default function AccountNotFoundClient({
address,
}: AccountNotFoundClientProps) {
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
return (
<ExplorerButtonGroup
onPage="Account"
options={[
{
label: "Nym Node",
isSelected: true,
link: `${basePath}/account/${address}/not-found/`,
},
{
label: "Account",
isSelected: false,
link: `${basePath}/account/${address}`,
},
]}
/>
);
}
@@ -1,62 +1,33 @@
// import BlogArticlesCards from "@/components/blogs/BlogArticleCards";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import Markdown from "react-markdown";
import AccountNotFoundClient from "./AccountNotFoundClient";
export default async function Account({
export default async function AccountNotFound({
params,
}: {
params: Promise<{ address: string }>;
}) {
try {
const address = (await params).address;
const { address } = await params;
return (
<ContentLayout>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<SectionHeading title="Nym Node Details" />
<ExplorerButtonGroup
onPage="Account"
options={[
{
label: "Nym Node",
isSelected: true,
link: `/account/${address}/not-found/`,
},
{
label: "Account",
isSelected: false,
link: `/account/${address}`,
},
]}
/>
</Box>
</Grid>
return (
<ContentLayout>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<SectionHeading title="Nym Node Details" />
<AccountNotFoundClient address={address} />
</Box>
</Grid>
<Typography variant="h5">
<Markdown className="reactMarkDownLink">
This account doest have a Nym node bonded. Is this your account?
Start [setting up your node](https://nym.com/docs) today!
</Markdown>
</Typography>
{/* <Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Onboarding" />
</Grid>
<BlogArticlesCards ids={[1]} />
</Grid> */}
</ContentLayout>
);
} catch (error) {
let errorMessage = "An error occurred";
if (error instanceof Error) {
errorMessage = error.message;
}
throw new Error(errorMessage);
}
</Grid>
<Typography variant="h5">
<Markdown className="reactMarkDownLink">
This account doesn&apos;t have a Nym node bonded. Is this your
account? Start [setting up your node](https://nym.com/docs) today!
</Markdown>
</Typography>
</ContentLayout>
);
}
@@ -12,7 +12,7 @@ export default function ExplorerPage() {
<ContentLayout>
<Wrapper>
<Stack gap={5}>
<SectionHeading title="Explorer" />
<SectionHeading title="Servers" />
<NodeAndAddressSearch />
</Stack>
<Box sx={{ mt: 5 }}>
@@ -13,12 +13,6 @@ export default async function StakingPage() {
<SubHeaderRow />
<OverviewCards />
<StakeTableWithAction />
{/* <Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Onboarding" />
</Grid>
<BlogArticlesCards ids={[1]} />
</Grid> */}
</ContentLayout>
);
}
+249 -74
View File
@@ -1,31 +1,49 @@
import { countryCodeMap } from "@/assets/countryCodes";
import { addSeconds } from "date-fns";
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
CountryDataResponse,
CurrentEpochData,
Environment,
ExplorerData,
GatewayStatus,
IAccountBalancesInfo,
IObservatoryNode,
IPacketsAndStakingData,
IRewardDetails,
NS_NODE,
NodeRewardDetails,
NymTokenomics,
ObservatoryBalance,
} from "./types";
import {
CURRENT_EPOCH,
CURRENT_EPOCH_REWARDS,
DATA_OBSERVATORY_BALANCES_URL,
DATA_OBSERVATORY_NODES_URL,
NS_API_MIXNODES_STATS,
NS_API_NODES,
NYM_ACCOUNT_ADDRESS,
NYM_PRICES_API,
OBSERVATORY_GATEWAYS_URL,
NS_GATEWAYS_URL,
SANDBOX_CURRENT_EPOCH,
SANDBOX_CURRENT_EPOCH_REWARDS,
SANDBOX_NS_API_MIXNODES_STATS,
SANDBOX_NS_API_NODES,
SANDBOX_NYM_ACCOUNT_ADDRESS,
SANDBOX_NS_GATEWAYS_URL,
} from "./urls";
import { Delegation } from "@nymproject/contract-clients/Mixnet.types";
// Fetch function for epoch rewards
export const fetchEpochRewards = async (): Promise<
ExplorerData["currentEpochRewardsData"]
> => {
const response = await fetch(CURRENT_EPOCH_REWARDS, {
export const fetchEpochRewards = async (
environment: Environment
): Promise<ExplorerData["currentEpochRewardsData"]> => {
const baseUrl =
environment === "sandbox"
? SANDBOX_CURRENT_EPOCH_REWARDS
: CURRENT_EPOCH_REWARDS;
if (!baseUrl) {
throw new Error("CURRENT_EPOCH_REWARDS URL is not defined");
}
const response = await fetch(baseUrl, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -43,8 +61,12 @@ export const fetchEpochRewards = async (): Promise<
// Fetch gateway status based on identity key
export const fetchGatewayStatus = async (
identityKey: string,
environment: Environment
): Promise<GatewayStatus | null> => {
const response = await fetch(`${OBSERVATORY_GATEWAYS_URL}/${identityKey}`);
const baseUrl =
environment === "sandbox" ? SANDBOX_NS_GATEWAYS_URL : NS_GATEWAYS_URL;
const response = await fetch(`${baseUrl}/${identityKey}`);
if (!response.ok) {
throw new Error("Failed to fetch gateway status");
@@ -54,17 +76,21 @@ export const fetchGatewayStatus = async (
};
export const fetchNodeDelegations = async (
id: number,
environment: Environment,
id: number
): Promise<NodeRewardDetails[]> => {
const response = await fetch(
`${DATA_OBSERVATORY_NODES_URL}/${id}/delegations`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
const baseUrl =
environment === "sandbox" ? SANDBOX_NS_API_NODES : NS_API_NODES;
if (!baseUrl) {
throw new Error("NS_API_NODES URL is not defined");
}
const response = await fetch(`${baseUrl}/${id}/delegations`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
);
});
if (!response.ok) {
throw new Error("Failed to fetch delegations");
@@ -73,8 +99,14 @@ export const fetchNodeDelegations = async (
return response.json();
};
export const fetchCurrentEpoch = async () => {
const response = await fetch(CURRENT_EPOCH, {
export const fetchCurrentEpoch = async (environment: Environment) => {
const baseUrl =
environment === "sandbox" ? SANDBOX_CURRENT_EPOCH : CURRENT_EPOCH;
if (!baseUrl) {
throw new Error("NS_API_NODES URL is not defined");
}
const response = await fetch(baseUrl, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -89,15 +121,22 @@ export const fetchCurrentEpoch = async () => {
const data: CurrentEpochData = await response.json();
const epochEndTime = addSeconds(
new Date(data.current_epoch_start),
data.epoch_length.secs,
data.epoch_length.secs
).toISOString();
return { ...data, current_epoch_end: epochEndTime };
};
// Fetch balances based on the address
export const fetchBalances = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
export const fetchBalances = async (
address: string,
environment: Environment
): Promise<number> => {
const baseUrl =
environment === "sandbox"
? SANDBOX_NYM_ACCOUNT_ADDRESS
: NYM_ACCOUNT_ADDRESS;
const response = await fetch(`${baseUrl}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -108,20 +147,26 @@ export const fetchBalances = async (address: string): Promise<number> => {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
const balances: IAccountBalancesInfo = await response.json();
// Calculate total stake
return (
Number(balances.rewards.staking_rewards.amount) +
Number(balances.delegated.amount)
Number(balances.claimable_rewards.amount) +
Number(balances.total_delegations.amount)
);
};
// Fetch function to get total staker rewards
export const fetchTotalStakerRewards = async (
address: string,
environment: Environment
): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
const baseUrl =
environment === "sandbox"
? SANDBOX_NYM_ACCOUNT_ADDRESS
: NYM_ACCOUNT_ADDRESS;
const response = await fetch(`${baseUrl}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -132,15 +177,22 @@ export const fetchTotalStakerRewards = async (
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
const balances: IAccountBalancesInfo = await response.json();
// Return the staking rewards amount
return Number(balances.rewards.staking_rewards.amount);
return Number(balances.claimable_rewards.amount);
};
// Fetch function to get the original stake
export const fetchOriginalStake = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
export const fetchOriginalStake = async (
address: string,
environment: Environment
): Promise<number> => {
const baseUrl =
environment === "sandbox"
? SANDBOX_NYM_ACCOUNT_ADDRESS
: NYM_ACCOUNT_ADDRESS;
const response = await fetch(`${baseUrl}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -151,19 +203,50 @@ export const fetchOriginalStake = async (address: string): Promise<number> => {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
const balances: IAccountBalancesInfo = await response.json();
// Return the delegated amount
return Number(balances.delegated.amount);
return Number(balances.total_delegations.amount);
};
export const fetchNoise = async (): Promise<IPacketsAndStakingData[]> => {
if (!process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS) {
throw new Error(
"NEXT_PUBLIC_NS_API_MIXNODES_STATS environment variable is not defined",
);
export const fetchStakerRewards = async (
address: string,
environment: Environment
): Promise<IRewardDetails[]> => {
const baseUrl =
environment === "sandbox"
? SANDBOX_NYM_ACCOUNT_ADDRESS
: NYM_ACCOUNT_ADDRESS;
const response = await fetch(`${baseUrl}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const response = await fetch(process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS, {
const balances: IAccountBalancesInfo = await response.json();
// Return the delegated amount
return balances.accumulated_rewards;
};
export const fetchNoise = async (
environment: Environment
): Promise<IPacketsAndStakingData[]> => {
const baseUrl =
environment === "sandbox"
? SANDBOX_NS_API_MIXNODES_STATS
: NS_API_MIXNODES_STATS;
if (!baseUrl) {
throw new Error("NS_API_MIXNODES_STATS URL is not defined");
}
const response = await fetch(baseUrl, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -177,8 +260,17 @@ export const fetchNoise = async (): Promise<IPacketsAndStakingData[]> => {
// Fetch Account Balance
export const fetchAccountBalance = async (
address: string,
environment: Environment
): Promise<IAccountBalancesInfo> => {
const res = await fetch(`${NYM_ACCOUNT_ADDRESS}/${address}`, {
const baseUrl =
environment === "sandbox"
? SANDBOX_NYM_ACCOUNT_ADDRESS
: NYM_ACCOUNT_ADDRESS;
if (!baseUrl) {
throw new Error("NYM_ACCOUNT_ADDRESS URL is not defined");
}
const res = await fetch(`${baseUrl}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@@ -192,39 +284,6 @@ export const fetchAccountBalance = async (
return data;
};
export const fetchObservatoryNodes = async (): Promise<IObservatoryNode[]> => {
const allNodes: IObservatoryNode[] = [];
let page = 1;
const PAGE_SIZE = 200;
let hasMoreData = true;
while (hasMoreData) {
const response = await fetch(
`${DATA_OBSERVATORY_NODES_URL}?page=${page}&limit=${PAGE_SIZE}`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch observatory nodes (page ${page})`);
}
const nodes: IObservatoryNode[] = await response.json();
allNodes.push(...nodes);
if (nodes.length < PAGE_SIZE) {
hasMoreData = false; // Stop fetching when the last page has fewer than 200 items
} else {
page++; // Move to the next page
}
}
return allNodes;
};
// 🔹 Fetch NYM Price
export const fetchNymPrice = async (): Promise<NymTokenomics> => {
const res = await fetch(NYM_PRICES_API, {
@@ -239,3 +298,119 @@ export const fetchNymPrice = async (): Promise<NymTokenomics> => {
const data: NymTokenomics = await res.json();
return data;
};
export const fetchNSApiNodes = async (
environment: Environment
): Promise<NS_NODE[]> => {
const baseUrl =
environment === "sandbox" ? SANDBOX_NS_API_NODES : NS_API_NODES;
if (!baseUrl) {
throw new Error("NS_API_NODES URL is not defined");
}
const allNodes: any[] = [];
let page = 0;
const PAGE_SIZE = 200;
let totalItems = 0;
let hasMoreData = true;
while (hasMoreData) {
const response = await fetch(`${baseUrl}?page=${page}&size=${PAGE_SIZE}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch NS API nodes (page ${page}): ${response.statusText}`
);
}
const data = await response.json();
const nodes: any[] = data.items || [];
allNodes.push(...nodes);
// Get total count from response
totalItems = data.total || 0;
// Check if we've fetched all items
if (allNodes.length >= totalItems) {
hasMoreData = false;
} else {
page++; // Move to the next page
}
}
return allNodes;
};
export const fetchWorldMapCountries = async (
environment: Environment
): Promise<{
countries: CountryDataResponse;
totalCountries: number;
uniqueLocations: number;
totalServers: number;
}> => {
// Fetch all nodes from the NS API
const nodes = await fetchNSApiNodes(environment);
// Create a map to count nodes by country
const countryCounts: Record<string, number> = {};
// Set to track unique cities
const uniqueCities = new Set<string>();
// Process each node
for (const node of nodes) {
// Get the 2-letter country code from the node's geoip data
const twoLetterCode = node.geoip?.country;
if (twoLetterCode) {
// Convert to 3-letter country code
const threeLetterCode = countryCodeMap[twoLetterCode] || twoLetterCode;
// Increment the count for this country
countryCounts[threeLetterCode] =
(countryCounts[threeLetterCode] || 0) + 1;
// Add city to unique cities set if it exists
if (node.geoip?.city) {
uniqueCities.add(node.geoip.city);
}
} else {
// If no geoip data, count it as unknown
countryCounts[""] = (countryCounts[""] || 0) + 1;
}
}
// Convert the counts to the required format
const result: CountryDataResponse = {};
for (const [threeLetterCode, count] of Object.entries(countryCounts)) {
result[threeLetterCode] = {
ISO3: threeLetterCode,
nodes: count,
};
}
return {
countries: result,
totalCountries: Object.keys(countryCounts).length,
uniqueLocations: uniqueCities.size,
totalServers: nodes.length,
};
};
export const fetchDelegations = async (
address: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
nymClient: any
): Promise<Delegation[]> => {
const data = await nymClient.getDelegatorDelegations({ delegator: address });
return data.delegations;
};
-23417
View File
File diff suppressed because it is too large Load Diff
+100 -199
View File
@@ -1,7 +1,3 @@
export type API_RESPONSE<T> = {
data: T[];
};
export type Denom = "unym" | "nym";
export interface IPacketsAndStakingData {
@@ -72,91 +68,62 @@ export interface ExplorerData {
}
export type NodeDescription = {
last_polled: string;
authenticator: {
address: string;
};
auxiliary_details: {
accepted_operator_terms_and_conditions: boolean;
announce_ports: {
mix_port: number | null;
verloc_port: number | null;
};
location: string;
};
build_information: {
binary_name: string;
build_timestamp: string;
build_version: string;
cargo_profile: string;
cargo_triple: string;
commit_branch: string;
commit_sha: string;
commit_timestamp: string;
rustc_channel: string;
rustc_version: string;
};
declared_role: {
entry: boolean;
exit_ipr: boolean;
exit_nr: boolean;
mixnode: boolean;
};
host_information: {
ip_address: string[];
hostname: string;
ip_address: [string, string];
keys: {
ed25519: string;
x25519: string;
x25519_noise: string | null;
};
};
declared_role: {
mixnode: boolean;
entry: boolean;
exit_nr: boolean;
exit_ipr: boolean;
ip_packet_router: {
address: string;
};
auxiliary_details: {
location: string;
announce_ports: {
verloc_port: number | null;
mix_port: number | null;
};
accepted_operator_terms_and_conditions: boolean;
};
build_information: {
binary_name: string;
build_timestamp: string;
build_version: string;
commit_sha: string;
commit_timestamp: string;
commit_branch: string;
rustc_version: string;
rustc_channel: string;
cargo_profile: string;
cargo_triple: string;
last_polled: string;
mixnet_websockets: {
ws_port: number;
wss_port: number;
};
network_requester: {
address: string;
uses_exit_policy: boolean;
};
ip_packet_router: {
address: string;
};
authenticator: {
address: string;
};
wireguard: string | null;
mixnet_websockets: {
ws_port: number;
wss_port: number | null;
wireguard: {
port: number;
public_key: string;
};
} | null;
export type BondInformation = {
node_id: number;
owner: string;
original_pledge: {
denom: string;
amount: string;
};
bonding_height: number;
is_unbonding: boolean;
node: {
host: string;
custom_http_port: number;
identity_key: string;
};
};
export type RewardingDetails = {
cost_params: {
profit_margin_percent: string;
interval_operating_cost: {
denom: string;
amount: string;
};
};
operator: string;
delegates: string;
total_unit_reward: string;
unit_delegation: string;
last_rewarded_epoch: number;
unique_delegations: number;
};
export type Location = {
two_letter_iso_country_code?: string;
three_letter_iso_country_code?: string;
@@ -165,15 +132,6 @@ export type Location = {
longitude?: number;
};
export type NodeData = {
node_id: number;
contract_node_type: string;
description: NodeDescription;
bond_information: BondInformation;
rewarding_details: RewardingDetails;
location: Location;
};
// ACCOUNT BALANCES
export interface IRewardDetails {
@@ -193,12 +151,13 @@ export interface IDelegationDetails {
delegated: IAmountDetails;
height: number;
proxy: null | string;
node_bonded: boolean;
}
export interface IAccountBalancesInfo {
accumulated_rewards: IRewardDetails[];
address: string;
balances: IAmountDetails[];
balance: IAmountDetails;
claimable_rewards: IAmountDetails;
delegations: IDelegationDetails[];
operator_rewards?: null | IAmountDetails;
@@ -207,111 +166,15 @@ export interface IAccountBalancesInfo {
vesting_account?: null | string;
}
export interface IObservatoryNode {
accepted_tnc: boolean;
bonded: boolean;
bonding_address: string;
description: {
authenticator: {
address: string;
};
auxiliary_details: {
accepted_operator_terms_and_conditions: boolean;
announce_ports: {
mix_port: number | null;
verloc_port: number | null;
};
location: string | null;
};
build_information: {
binary_name: string;
build_timestamp: string;
build_version: string;
cargo_profile: string;
cargo_triple: string;
commit_branch: string;
commit_sha: string;
commit_timestamp: string;
rustc_channel: string;
rustc_version: string;
};
declared_role: {
entry: boolean;
exit_ipr: boolean;
exit_nr: boolean;
mixnode: boolean;
};
host_information: {
hostname: string | null;
ip_address: string[];
};
keys: {
ed25519: string;
x25519: string;
x25519_noise: string | null;
};
ip_packet_router: {
address: string;
};
last_polled: string;
mixnet_websockets: {
ws_port: number;
wss_port: number | null;
};
network_requester: {
address: string;
uses_exit_policy: boolean;
};
wireguard: string | null;
geoip: {
city: string;
country: string;
ip_address: string;
loc: string;
node_id: number;
org: string;
postal: string;
region: string;
};
};
identity_key: string;
ip_address: string;
node_id: number;
node_type: string;
original_pledge: number;
rewarding_details: {
cost_params: {
interval_operating_cost: {
amount: string;
denom: string;
};
profit_margin_percent: string;
};
delegates: string;
last_rewarded_epoch: number;
operator: string;
total_unit_reward: string;
unique_delegations: number;
unit_delegation: string;
};
self_description: {
details: string;
moniker: string;
security_contact: string;
website: string;
};
total_stake: number;
uptime: number;
}
export interface NodeRewardDetails {
amount: {
amount: string;
denom: string;
};
block_height: number;
cumulative_reward_ratio: string;
height: number;
node_id: number;
owner: string;
proxy: string;
}
export type LastProbeResult = {
@@ -425,25 +288,6 @@ export type GatewayStatus = {
};
};
type BalanceDetails = {
amount: number;
denom: string;
};
export type ObservatoryRewards = {
operator_commissions: BalanceDetails;
staking_rewards: BalanceDetails;
unlocked: BalanceDetails;
};
export type ObservatoryBalance = {
delegated: BalanceDetails;
locked: BalanceDetails;
rewards: ObservatoryRewards;
self_bonded: BalanceDetails;
spendable: BalanceDetails;
};
export type Quote = {
ath_date: string;
ath_price: number;
@@ -480,3 +324,60 @@ export type NymTokenomics = {
symbol: string;
total_supply: number;
};
export type CountryData = {
ISO3: string;
nodes: number;
};
export interface CountryDataResponse {
[threeLetterCountryCode: string]: CountryData;
}
export type NS_NODE = {
accepted_tnc: boolean;
bonded: boolean;
bonding_address: string;
description: {
details: string;
moniker: string;
security_contact: string;
website: string;
};
geoip?: {
city: string;
country: string;
ip_address: string;
latitude: string;
longitude: string;
org: string;
postal: string;
region: string;
timezone: string;
};
identity_key: string;
ip_address: string;
node_id: number;
node_type: string;
original_pledge: number;
rewarding_details?: {
cost_params: {
interval_operating_cost: {
amount: string;
denom: string;
};
profit_margin_percent: string;
};
delegates: string;
last_rewarded_epoch: number;
operator: string;
total_unit_reward: string;
unique_delegations: number;
unit_delegation: string;
} | null;
self_description?: NodeDescription;
total_stake: string;
uptime: number;
};
export type Environment = "mainnet" | "sandbox";
+22 -8
View File
@@ -1,24 +1,38 @@
export const CURRENT_EPOCH =
"https://validator.nymtech.net/api/v1/epoch/current";
export const SANDBOX_CURRENT_EPOCH =
"https://sandbox-nym-api1.nymtech.net/api/v1/epoch/current";
export const CURRENT_EPOCH_REWARDS =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const SANDBOX_CURRENT_EPOCH_REWARDS =
"https://sandbox-nym-api1.nymtech.net/api/v1/epoch/reward_params";
export const NYM_ACCOUNT_ADDRESS =
"https://explorer.nymtech.net/api/v1/tmp/unstable/account";
"https://validator.nymtech.net/api/v1/unstable/account";
export const SANDBOX_NYM_ACCOUNT_ADDRESS =
"https://sandbox-nym-api1.nymtech.net/api/v1/unstable/account";
export const NYM_PRICES_API = "https://api.nym.spectredao.net/api/v1/nym-price";
export const VALIDATOR_BASE_URL =
process.env.NEXT_PUBLIC_VALIDATOR_URL || "https://rpc.nymtech.net";
export const DATA_OBSERVATORY_NODES_URL =
"https://api.nym.spectredao.net/api/v1/nodes";
export const SANDBOX_VALIDATOR_BASE_URL = "https://rpc.sandbox.nymtech.net";
export const DATA_OBSERVATORY_DELEGATIONS_URL =
"https://api.nym.spectredao.net/api/v1/delegations";
export const DATA_OBSERVATORY_BALANCES_URL =
"https://api.nym.spectredao.net/api/v1/balances";
export const OBSERVATORY_GATEWAYS_URL =
export const NS_GATEWAYS_URL =
"https://mainnet-node-status-api.nymtech.cc/v2/gateways";
export const SANDBOX_NS_GATEWAYS_URL =
"https://sandbox-node-status-api.nymte.ch/v2/gateways";
export const NS_API_MIXNODES_STATS =
process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS;
export const SANDBOX_NS_API_MIXNODES_STATS =
"https://sandbox-node-status-api.nymte.ch/v2/mixnodes/stats";
export const NS_API_NODES = process.env.NEXT_PUBLIC_NS_API_NODES;
export const SANDBOX_NS_API_NODES =
"https://sandbox-node-status-api.nymte.ch/explorer/v3/nym-nodes";
+4
View File
@@ -1 +1,5 @@
export const TABLET_WIDTH = "(min-width:700px)";
export const RECOMMENDED_NODES = [
1362, 291, 1719, 1768, 1772, 1512, 896, 1415, 2114, 2010,
];
@@ -0,0 +1,36 @@
// API
import { client } from "../../../lib/strapiClient";
// Types
import type { Languages } from "../../../i18n";
import type { components } from "@/app/lib/strapi";
// Constants
import { bannerApiPath } from "../../banner/config/constants";
// Fetch footer data
export const getBanner = async (
locale: Languages
): Promise<{
id?: number;
attributes?: components["schemas"]["ExplorerBanner"];
} | null> => {
const banner = await client.GET(bannerApiPath, {
params: {
query: {
locale,
// @ts-expect-error - populate is not typed correctly?
populate: {
links: {
populate: "*",
},
icon: {
populate: "*",
},
},
},
},
});
return banner?.data?.data ? banner?.data?.data : null;
};
@@ -0,0 +1 @@
export const bannerApiPath = "/explorer-banner";
@@ -1,72 +0,0 @@
// Types
import type { components } from "../../../lib/strapi";
// Components
import { Link } from "@/components/muiLink";
// MUI Components
import { Box, Grid2, Typography } from "@mui/material";
export const FooterLinks = ({
linkBlocks = [],
}: {
linkBlocks: components["schemas"]["FooterLinkBlockComponent"][];
}) => {
return (
<Grid2
container
spacing={{ xs: 2, md: 3 }}
columns={{ xs: 1, sm: 8, md: 5 }}
>
{linkBlocks?.map((block) => {
return (
<Grid2 key={block.id} size={{ xs: 1, sm: 4, md: 1 }}>
<Typography
component={block?.heading?.level || "h3"}
variant="subtitle1"
sx={{ mb: 4 }}
>
{block?.heading?.title}
</Typography>
<Box
component={"ul"}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{block?.links?.map((link) => {
const isLinkExternal = link.url?.startsWith("http");
return (
<Box
sx={{
listStyle: "none",
}}
component={"li"}
key={link.id}
>
<Link
href={link?.url || ""}
sx={{
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
}}
>
<Typography variant="body3">
{link.title}
{isLinkExternal ? " ↗" : ""}
</Typography>
</Link>
</Box>
);
})}
</Box>
</Grid2>
);
})}
</Grid2>
);
};
+2
View File
@@ -1,4 +1,5 @@
import { Header } from "@/components/header";
import { Banner } from "@/components/banner/Banner";
import { Wrapper } from "@/components/wrapper";
import Providers from "@/providers";
import type { Metadata } from "next";
@@ -21,6 +22,7 @@ export default function RootLayout({
<body>
<Providers>
<Header />
<Banner />
<Wrapper>{children}</Wrapper>
<Footer />
</Providers>
+35223 -22138
View File
File diff suppressed because it is too large Load Diff
+15 -23
View File
@@ -1,13 +1,14 @@
import { WorldMap } from "@/components/worldMap/WorldMap";
import { Stack } from "@mui/material";
import Grid from "@mui/material/Grid2";
import BlogArticlesCards from "../components/blogs/BlogArticleCards";
import { ContentLayout } from "../components/contentLayout/ContentLayout";
import SectionHeading from "../components/headings/SectionHeading";
import { CurrentEpochCard } from "../components/landingPageComponents/CurrentEpochCard";
import { NetworkStakeCard } from "../components/landingPageComponents/NetworkStakeCard";
import { NoiseCard } from "../components/landingPageComponents/NoiseCard";
import { StakersNumberCard } from "../components/landingPageComponents/StakersNumberCard";
import { TokenomicsCard } from "../components/landingPageComponents/TokenomicsCard";
import { CurrentEpochCardWrapper } from "../components/landingPageComponents/CurrentEpochCardWrapper";
import { NetworkStakeCardWrapper } from "../components/landingPageComponents/NetworkStakeCardWrapper";
import { NoiseCardWrapper } from "../components/landingPageComponents/NoiseCardWrapper";
import { StakersNumberCardWrapper } from "../components/landingPageComponents/StakersNumberCardWrapper";
import { TokenomicsCardWrapper } from "../components/landingPageComponents/TokenomicsCardWrapper";
import NodeTable from "../components/nodeTable/NodeTableWithAction";
import NodeAndAddressSearch from "../components/search/NodeAndAddressSearch";
@@ -16,38 +17,29 @@ export default async function Home() {
<ContentLayout>
<Stack gap={5}>
<NodeAndAddressSearch />
<WorldMap />
</Stack>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Noise Generating Network Overview" />
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<NoiseCard />
<SectionHeading title="Network Overview" />
</Grid>
<NoiseCardWrapper />
<Grid
container
columnSpacing={5}
rowSpacing={5}
size={{ xs: 12, sm: 6, lg: 3 }}
>
<Grid size={12}>
<StakersNumberCard />
</Grid>
<Grid size={12}>
<CurrentEpochCard />
</Grid>
<StakersNumberCardWrapper />
<CurrentEpochCardWrapper />
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<NetworkStakeCard />
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<TokenomicsCard />
</Grid>
<NetworkStakeCardWrapper />
<TokenomicsCardWrapper />
</Grid>
<Grid container>
<Grid container rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Nym Nodes" />
<SectionHeading title="Nym Servers" />
</Grid>
<Grid size={12}>
<NodeTable />
+195
View File
@@ -0,0 +1,195 @@
// Map of 2-letter country codes to 3-letter country codes
export const countryCodeMap: Record<string, string> = {
AF: "AFG", // Afghanistan
AL: "ALB", // Albania
DZ: "DZA", // Algeria
AD: "AND", // Andorra
AO: "AGO", // Angola
AG: "ATG", // Antigua and Barbuda
AR: "ARG", // Argentina
AM: "ARM", // Armenia
AU: "AUS", // Australia
AT: "AUT", // Austria
AZ: "AZE", // Azerbaijan
BS: "BHS", // Bahamas
BH: "BHR", // Bahrain
BD: "BGD", // Bangladesh
BB: "BRB", // Barbados
BY: "BLR", // Belarus
BE: "BEL", // Belgium
BZ: "BLZ", // Belize
BJ: "BEN", // Benin
BT: "BTN", // Bhutan
BO: "BOL", // Bolivia
BA: "BIH", // Bosnia and Herzegovina
BW: "BWA", // Botswana
BR: "BRA", // Brazil
BN: "BRN", // Brunei
BG: "BGR", // Bulgaria
BF: "BFA", // Burkina Faso
BI: "BDI", // Burundi
KH: "KHM", // Cambodia
CM: "CMR", // Cameroon
CA: "CAN", // Canada
CV: "CPV", // Cape Verde
CF: "CAF", // Central African Republic
TD: "TCD", // Chad
CL: "CHL", // Chile
CN: "CHN", // China
CO: "COL", // Colombia
KM: "COM", // Comoros
CG: "COG", // Congo
CR: "CRI", // Costa Rica
HR: "HRV", // Croatia
CU: "CUB", // Cuba
CY: "CYP", // Cyprus
CZ: "CZE", // Czech Republic
DK: "DNK", // Denmark
DJ: "DJI", // Djibouti
DM: "DMA", // Dominica
DO: "DOM", // Dominican Republic
EC: "ECU", // Ecuador
EG: "EGY", // Egypt
SV: "SLV", // El Salvador
GQ: "GNQ", // Equatorial Guinea
ER: "ERI", // Eritrea
EE: "EST", // Estonia
ET: "ETH", // Ethiopia
FJ: "FJI", // Fiji
FI: "FIN", // Finland
FR: "FRA", // France
GA: "GAB", // Gabon
GM: "GMB", // Gambia
GE: "GEO", // Georgia
DE: "DEU", // Germany
GH: "GHA", // Ghana
GR: "GRC", // Greece
GD: "GRD", // Grenada
GT: "GTM", // Guatemala
GN: "GIN", // Guinea
GW: "GNB", // Guinea-Bissau
GY: "GUY", // Guyana
HT: "HTI", // Haiti
HN: "HND", // Honduras
HU: "HUN", // Hungary
IS: "ISL", // Iceland
IN: "IND", // India
ID: "IDN", // Indonesia
IR: "IRN", // Iran
IQ: "IRQ", // Iraq
IE: "IRL", // Ireland
IL: "ISR", // Israel
IT: "ITA", // Italy
JM: "JAM", // Jamaica
JP: "JPN", // Japan
JO: "JOR", // Jordan
KZ: "KAZ", // Kazakhstan
KE: "KEN", // Kenya
KI: "KIR", // Kiribati
KP: "PRK", // North Korea
KR: "KOR", // South Korea
KW: "KWT", // Kuwait
KG: "KGZ", // Kyrgyzstan
LA: "LAO", // Laos
LV: "LVA", // Latvia
LB: "LBN", // Lebanon
LS: "LSO", // Lesotho
LR: "LBR", // Liberia
LY: "LBY", // Libya
LI: "LIE", // Liechtenstein
LT: "LTU", // Lithuania
LU: "LUX", // Luxembourg
MG: "MDG", // Madagascar
MW: "MWI", // Malawi
MY: "MYS", // Malaysia
MV: "MDV", // Maldives
ML: "MLI", // Mali
MT: "MLT", // Malta
MH: "MHL", // Marshall Islands
MR: "MRT", // Mauritania
MU: "MUS", // Mauritius
MX: "MEX", // Mexico
FM: "FSM", // Micronesia
MD: "MDA", // Moldova
MC: "MCO", // Monaco
MN: "MNG", // Mongolia
ME: "MNE", // Montenegro
MA: "MAR", // Morocco
MZ: "MOZ", // Mozambique
MM: "MMR", // Myanmar
NA: "NAM", // Namibia
NR: "NRU", // Nauru
NP: "NPL", // Nepal
NL: "NLD", // Netherlands
NZ: "NZL", // New Zealand
NI: "NIC", // Nicaragua
NE: "NER", // Niger
NG: "NGA", // Nigeria
NO: "NOR", // Norway
OM: "OMN", // Oman
PK: "PAK", // Pakistan
PW: "PLW", // Palau
PA: "PAN", // Panama
PG: "PNG", // Papua New Guinea
PY: "PRY", // Paraguay
PE: "PER", // Peru
PH: "PHL", // Philippines
PL: "POL", // Poland
PT: "PRT", // Portugal
QA: "QAT", // Qatar
RO: "ROU", // Romania
RU: "RUS", // Russia
RW: "RWA", // Rwanda
KN: "KNA", // Saint Kitts and Nevis
LC: "LCA", // Saint Lucia
VC: "VCT", // Saint Vincent and the Grenadines
WS: "WSM", // Samoa
SM: "SMR", // San Marino
ST: "STP", // Sao Tome and Principe
SA: "SAU", // Saudi Arabia
SN: "SEN", // Senegal
RS: "SRB", // Serbia
SC: "SYC", // Seychelles
SL: "SLE", // Sierra Leone
SG: "SGP", // Singapore
SK: "SVK", // Slovakia
SI: "SVN", // Slovenia
SB: "SLB", // Solomon Islands
SO: "SOM", // Somalia
ZA: "ZAF", // South Africa
SS: "SSD", // South Sudan
ES: "ESP", // Spain
LK: "LKA", // Sri Lanka
SD: "SDN", // Sudan
SR: "SUR", // Suriname
SZ: "SWZ", // Swaziland
SE: "SWE", // Sweden
CH: "CHE", // Switzerland
SY: "SYR", // Syria
TW: "TWN", // Taiwan
TJ: "TJK", // Tajikistan
TZ: "TZA", // Tanzania
TH: "THA", // Thailand
TL: "TLS", // Timor-Leste
TG: "TGO", // Togo
TO: "TON", // Tonga
TT: "TTO", // Trinidad and Tobago
TN: "TUN", // Tunisia
TR: "TUR", // Turkey
TM: "TKM", // Turkmenistan
TV: "TUV", // Tuvalu
UG: "UGA", // Uganda
UA: "UKR", // Ukraine
AE: "ARE", // United Arab Emirates
GB: "GBR", // United Kingdom
US: "USA", // United States
UY: "URY", // Uruguay
UZ: "UZB", // Uzbekistan
VU: "VUT", // Vanuatu
VA: "VAT", // Vatican City
VE: "VEN", // Venezuela
VN: "VNM", // Vietnam
YE: "YEM", // Yemen
ZM: "ZMB", // Zambia
ZW: "ZWE", // Zimbabwe
};
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,11 @@
"use client";
import { fetchAccountBalance, fetchNymPrice } from "@/app/api";
import { Skeleton, Stack, Typography } from "@mui/material";
import { Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { IRewardDetails } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import { AccountBalancesTable } from "./AccountBalancesTable";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export interface IAccontStatsRowProps {
type: string;
@@ -45,7 +46,7 @@ const getAllocation = (unyms: number, totalUnyms: number): number => {
};
const calculateStakingRewards = (
accumulatedRewards: IRewardDetails[],
accumulatedRewards: IRewardDetails[]
): number => {
if (accumulatedRewards.length > 0) {
const totalRewards = accumulatedRewards.reduce((total, rewardDetail) => {
@@ -61,14 +62,17 @@ const calculateStakingRewards = (
export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
const { address } = props;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
const {
data: accountInfo,
isLoading,
isError,
} = useQuery({
queryKey: ["accountBalance", address],
queryFn: () => fetchAccountBalance(address),
queryKey: ["accountBalance", address, environment],
queryFn: () => fetchAccountBalance(address, environment),
enabled: !!address,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
@@ -77,17 +81,18 @@ export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
const {
data: nymPrice,
isLoading: isLoadingPrice,
error: priceError,
isLoading: isNymPriceLoading,
isError: isNymPriceError,
} = useQuery({
queryKey: ["nymPrice"],
queryFn: fetchNymPrice,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isLoading || isLoadingPrice) {
if (isLoading || isNymPriceLoading) {
return (
<ExplorerCard label="Total value">
<Stack gap={1}>
@@ -98,10 +103,13 @@ export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
);
}
if (isError || priceError || !accountInfo || !nymPrice) {
if (isError || isNymPriceError || !accountInfo || !nymPrice) {
return (
<ExplorerCard label="Total value">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to account data.
</Typography>
<Skeleton variant="text" height={238} />
@@ -113,60 +121,57 @@ export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
const totalBalanceUSD = getPriceInUSD(
Number(accountInfo.total_value.amount),
nymPriceData,
nymPriceData
);
const spendableNYM =
accountInfo.balances.length > 0
? getNymsFormated(Number(accountInfo.balances[0].amount))
: 0;
const spendableUSD =
accountInfo.balances.length > 0
? getPriceInUSD(Number(accountInfo.balances[0].amount), nymPriceData)
: 0;
const spendableAllocation =
accountInfo.balances.length > 0
? getAllocation(
Number(accountInfo.balances[0].amount),
Number(accountInfo.total_value.amount),
)
: 0;
const spendableNYM = accountInfo.balance
? getNymsFormated(Number(accountInfo.balance.amount))
: 0;
const spendableUSD = accountInfo.balance
? getPriceInUSD(Number(accountInfo.balance.amount), nymPriceData)
: 0;
const spendableAllocation = accountInfo.balance
? getAllocation(
Number(accountInfo.balance.amount),
Number(accountInfo.total_value.amount)
)
: 0;
const delegationsNYM = getNymsFormated(
Number(accountInfo.total_delegations.amount),
Number(accountInfo.total_delegations.amount)
);
const delegationsUSD = getPriceInUSD(
Number(accountInfo.total_delegations.amount),
nymPriceData,
nymPriceData
);
const delegationsAllocation = getAllocation(
Number(accountInfo.total_delegations.amount),
Number(accountInfo.total_value.amount),
Number(accountInfo.total_value.amount)
);
const operatorRewardsAllocation = getAllocation(
Number(accountInfo.operator_rewards?.amount || 0),
Number(accountInfo.total_value.amount),
Number(accountInfo.total_value.amount)
);
const operatorRewardsNYM = getNymsFormated(
Number(accountInfo.operator_rewards?.amount || 0),
Number(accountInfo.operator_rewards?.amount || 0)
);
const operatorRewardsUSD = getPriceInUSD(
Number(accountInfo.operator_rewards?.amount || 0),
nymPriceData,
nymPriceData
);
const claimableNYM = getNymsFormated(
Number(accountInfo.claimable_rewards.amount),
Number(accountInfo.claimable_rewards.amount)
);
const claimableUSD = getPriceInUSD(
Number(accountInfo.claimable_rewards.amount),
nymPriceData,
nymPriceData
);
const claimableAllocation = getAllocation(
Number(accountInfo.claimable_rewards.amount),
Number(accountInfo.total_value.amount),
Number(accountInfo.total_value.amount)
);
const stakingRewards =
@@ -1,11 +1,12 @@
"use client";
import { fetchAccountBalance } from "@/app/api";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import ExplorerListItem from "../list/ListItem";
import { CardQRCode } from "../qrCode/QrCode";
import { useEnvironment } from "@/providers/EnvironmentProvider";
interface IAccountInfoCardProps {
address: string;
@@ -13,10 +14,13 @@ interface IAccountInfoCardProps {
export const AccountInfoCard = (props: IAccountInfoCardProps) => {
const { address } = props;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
const { data, isLoading, isError } = useQuery({
queryKey: ["accountBalance", address],
queryFn: () => fetchAccountBalance(address),
queryKey: ["accountBalance", address, environment],
queryFn: () => fetchAccountBalance(address, environment),
enabled: !!address,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
@@ -38,7 +42,10 @@ export const AccountInfoCard = (props: IAccountInfoCardProps) => {
if (isError || !data) {
return (
<ExplorerCard label="Total NYM">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to account data.
</Typography>
<Skeleton variant="text" height={238} />
@@ -46,8 +53,7 @@ export const AccountInfoCard = (props: IAccountInfoCardProps) => {
);
}
const balance =
data.balances.length > 0 ? Number(data.total_value.amount) / 1000000 : 0;
const balance = data.balance ? Number(data.total_value.amount) / 1000000 : 0;
const balanceFormated = `${balance.toFixed(4)} NYM`;
return (
@@ -1,47 +1,58 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { useQuery } from "@tanstack/react-query";
import { getBasePathByEnv } from "../../../envs/config";
import { Box } from "@mui/material";
type Props = {
address: string;
};
export default function AccountPageButtonGroup({ address }: Props) {
const { data: nymNodes, isError } = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
const { data: nsApiNodes = [], isError: isNSApiNodesError } = useQuery({
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (!nymNodes || isError) return null;
if (!nsApiNodes || isNSApiNodesError) return null;
const nymNode = nymNodes.find((node) => node.bonding_address === address);
const nymNode = nsApiNodes.find(
(node: NS_NODE) => node.bonding_address === address
);
if (!nymNode) return null;
if (nymNode.bonding_address)
return (
<ExplorerButtonGroup
onPage="Account"
options={[
{
label: "Nym Node",
isSelected: false,
link: nymNode
? `/nym-node/${nymNode.node_id}`
: `/account/${address}/not-found`,
},
{
label: "Account",
isSelected: true,
link: `/account/${address}`,
},
]}
/>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<ExplorerButtonGroup
onPage="Account"
options={[
{
label: "Nym Node",
isSelected: false,
link: nymNode
? `${basePath}/nym-node/${nymNode.node_id}`
: `${basePath}/account/${address}/not-found`,
},
{
label: "Account",
isSelected: true,
link: `${basePath}/account/${address}`,
},
]}
/>
</Box>
);
}
@@ -0,0 +1,198 @@
"use client";
import { useState, useEffect } from "react";
import { Box, Typography, Button, IconButton, Stack } from "@mui/material";
import { Close, Launch } from "@mui/icons-material";
import { Link } from "../muiLink";
import { Wrapper } from "../wrapper";
import { getBanner } from "@/app/features/banner/api/getBanner";
import type { components } from "@/app/lib/strapi";
type BannerData = {
id?: number;
attributes?: components["schemas"]["ExplorerBanner"];
} | null;
export const Banner = () => {
const [bannerData, setBannerData] = useState<BannerData>(null);
const [isVisible, setIsVisible] = useState(true);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchBanner = async () => {
try {
const data = await getBanner("en");
setBannerData(data);
setIsLoading(false);
} catch (error) {
console.error("Failed to fetch banner:", error);
setIsLoading(false);
}
};
fetchBanner();
}, []);
const handleClose = () => {
setIsVisible(false);
};
// Don't render if not visible
if (!isVisible) {
return null;
}
// Only show banner if API data is available and show is true
const shouldShowBanner = bannerData?.attributes?.show === true;
const hasValidData = bannerData?.attributes && shouldShowBanner;
// If loading and no data yet, don't show anything
if (isLoading && !bannerData) {
return null;
}
// If API data says don't show and we have valid data, don't show
if (hasValidData && !shouldShowBanner) {
return null;
}
// If no valid data from API, don't show anything
if (!hasValidData) {
return null;
}
const { title, text, links, icon } = bannerData.attributes || {};
return (
<Box
sx={{
backgroundColor: "accent.main",
color: "base.black",
py: { xs: 1.5, md: 2 },
borderBottom: "1px solid",
borderColor: "divider",
}}
>
<Wrapper>
<Stack
direction={{ xs: "column", md: "row" }}
alignItems={{ xs: "flex-start", md: "center" }}
justifyContent="space-between"
spacing={{ xs: 1.5, md: 2 }}
>
<Stack
direction="row"
alignItems="center"
spacing={3}
flex={1}
sx={{ width: "100%" }}
>
{/* Icon */}
{icon?.data?.attributes?.url && (
<Box
component="img"
src={icon.data.attributes.url}
alt={icon.data.attributes.alternativeText || "Banner icon"}
sx={{
width: { xs: 20, md: 24 },
height: { xs: 20, md: 24 },
flexShrink: 0,
}}
/>
)}
{/* Content */}
<Box flex={1}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
mb: 0.5,
fontSize: { xs: "0.875rem", md: "1rem" },
}}
>
{title}
</Typography>
<Typography
variant="body2"
sx={{
opacity: 0.9,
fontSize: { xs: "0.75rem", md: "0.875rem" },
}}
>
{text}
</Typography>
</Box>
</Stack>
{/* Actions */}
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
alignSelf: { xs: "flex-end", md: "center" },
width: { xs: "auto", md: "auto" },
}}
>
{/* Links */}
{links && links.length > 0 && (
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ flexWrap: "wrap" }}
>
{links.map((link) => (
<Link
key={link.id}
href={link.url || "#"}
target={link.url?.startsWith("http") ? "_blank" : "_self"}
rel={
link.url?.startsWith("http") ? "noopener noreferrer" : ""
}
style={{ textDecoration: "none" }}
>
<Button
variant="outlined"
size="small"
endIcon={
link.url?.startsWith("http") ? <Launch /> : undefined
}
sx={{
color: "base.black",
borderColor: "base.black",
fontSize: { xs: "0.75rem", md: "0.875rem" },
px: { xs: 1, md: 2 },
py: { xs: 0.5, md: 1 },
"&:hover": {
borderColor: "base.black",
backgroundColor: "rgba(0, 0, 0, 0.1)",
},
}}
>
{link.title}
</Button>
</Link>
))}
</Stack>
)}
{/* Close button */}
<IconButton
onClick={handleClose}
size="small"
sx={{
color: "base.black",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.1)",
},
}}
>
<Close sx={{ fontSize: { xs: "1.25rem", md: "1.5rem" } }} />
</IconButton>
</Stack>
</Stack>
</Wrapper>
</Box>
);
};
@@ -26,7 +26,6 @@ const BlogArticlesCards = async ({
const blogArticle = JSON.parse(fileContent);
return {
...blogArticle,
link: `/onboarding/${filename.replace(".json", "")}`,
};
}),
);
+2 -3
View File
@@ -8,6 +8,7 @@ type BlogArticle = {
image: string;
iconLight: string;
iconDark: string;
link: string;
attributes: {
blogAuthors: string[];
date: Date;
@@ -23,8 +24,6 @@ type BlogArticle = {
}[];
};
export type BlogArticleWithLink = BlogArticle & {
link: string;
};
export type BlogArticleWithLink = BlogArticle;
export default BlogArticle;
@@ -68,7 +68,12 @@ const ExplorerHeroCard = ({
const iconSrc = isDarkMode ? iconDarkSrc : iconLightSrc;
return (
<Link href={link} sx={{ textDecoration: "none", height: "100%" }}>
<Link
href={link}
sx={{ textDecoration: "none", height: "100%" }}
target="_blank"
rel="noopener noreferrer"
>
<Card sx={dynamicCardStyles} elevation={0}>
<CardHeader
title={
@@ -5,6 +5,7 @@ import { useCopyToClipboard } from "@uidotdev/usehooks";
import { useEffect } from "react";
import CopyFile from "../icons/CopyFile";
import CopyFileDark from "../icons/CopyFileDark";
import CheckIcon from "@mui/icons-material/Check";
const CLEAR_AFTER_MS = 10_000;
@@ -34,12 +35,8 @@ const CopyToClipboard = ({
if (hasCopied) {
return (
<Typography
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
variant="h6"
color="textSecondary"
>
Copied
<Typography sx={{ color: isDarkMode ? "base.white" : "pine.950" }}>
<CheckIcon fontSize="small" />
</Typography>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export async function Footer() {
const locale = "en";
const footerData = await getFooter(locale);
const legalContent1 =
"Nym Noise Generating Network Explorer, V 2.1.0 Public Beta release.";
"Nym Noise Generating Network Explorer, V 2.2.0 Public Beta release.";
const legalContent2 = footerData?.attributes?.legalContent2 || false;
const footerLinkBlocks = footerData?.attributes?.linkBlocks || [];
@@ -1,13 +1,43 @@
import { Box, Divider } from "@mui/material";
"use client";
import { Box, Divider, Stack, Button } from "@mui/material";
import NymLogo from "../../components/icons/NymLogo";
import { Link } from "../../components/muiLink";
import { Wrapper } from "../../components/wrapper";
import ConnectWallet from "../wallet/ConnectWallet";
import HeaderItem from "./HeaderItem";
import { DarkLightSwitchDesktop } from "./Switch";
import MENU_DATA from "./menuItems";
import { EnvironmentSwitcher } from "./EnvironmentSwitcher";
import { usePathname } from "next/navigation";
import { getBasePathByEnv } from "../../../envs/config";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { Circle } from "@mui/icons-material";
export const DesktopHeader = () => {
const pathname = usePathname();
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
const explorerName = environment
? `${environment} Explorer`
: "Mainnet Explorer";
// Helper function to determine if a tab is active
const isTabActive = (tabTitle: string) => {
// Check if the current pathname matches the tab title
// For explorerName, check if we're on the base path
if (tabTitle === explorerName) {
return pathname === basePath || pathname === basePath + "/";
}
// For menu items, check if the pathname includes the menu URL
const menuItem = MENU_DATA.find((menu) => menu.title === tabTitle);
if (menuItem) {
return pathname.includes(menuItem.url);
}
return false;
};
return (
<Box
sx={{
@@ -21,12 +51,12 @@ export const DesktopHeader = () => {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "42px",
gap: "30px",
height: "100%",
}}
>
<Link
href={"/"}
href={"https://nym.com/"}
style={{
display: "flex",
alignItems: "center",
@@ -41,15 +71,62 @@ export const DesktopHeader = () => {
display: "flex",
flexGrow: 1,
alignItems: "center",
justifyContent: "start",
justifyContent: "center",
height: "100%",
gap: 5,
gap: 4,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "start",
height: "100%",
gap: 1,
}}
>
<Circle
sx={{
fontSize: 10,
opacity: isTabActive(explorerName) ? 1 : 0,
}}
/>
<Link href={basePath} passHref style={{ textDecoration: "none" }}>
<Button
sx={{
padding: 0,
}}
>
{explorerName}
</Button>
</Link>
</Box>
{MENU_DATA.map((menu) => (
<HeaderItem key={menu.id} menu={menu} />
<Stack direction="row" gap={1} key={menu.id} alignItems="center">
<Circle
sx={{
fontSize: 10,
opacity: isTabActive(menu.title) ? 1 : 0,
}}
/>
<Link
href={`${basePath}${menu.url}`}
style={{ textDecoration: "none" }}
passHref
>
<Button
sx={{
padding: 0,
}}
>
{menu.title}
</Button>
</Link>
</Stack>
))}
</Box>
<EnvironmentSwitcher />
<ConnectWallet size="small" />
<DarkLightSwitchDesktop />
</Wrapper>
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { Button } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useEnvironment } from "../../providers/EnvironmentProvider";
import { useRouter, usePathname } from "next/navigation";
import { getBasePathByEnv } from "../../../envs/config";
import { colours } from "@/theme/colours";
export const EnvironmentSwitcher: React.FC = () => {
const theme = useTheme();
const { environment, setEnvironment } = useEnvironment();
const router = useRouter();
const pathname = usePathname();
const switchNetworkText =
environment === "mainnet" ? "Switch to Sandbox" : "Switch to Mainnet";
const getCurrentInternalPath = () => {
// Remove the base path from the current pathname to get the internal path
return pathname.replace(/^\/(explorer|sandbox-explorer)/, "") || "/";
};
const handleSwitchEnvironment = () => {
const newEnvironment = environment === "mainnet" ? "sandbox" : "mainnet";
setEnvironment(newEnvironment);
// Get the current internal path and build the new path
const currentInternalPath = getCurrentInternalPath();
const newBasePath = getBasePathByEnv(newEnvironment);
const newPath =
currentInternalPath === "/"
? newBasePath
: `${newBasePath}${currentInternalPath}`;
router.push(newPath);
};
return (
<Button
variant="outlined"
color="inherit"
onClick={handleSwitchEnvironment}
sx={{
borderRadius: 2,
px: 2,
py: 1,
color:
theme.palette.mode === "light"
? `${theme.palette.common.black} !important`
: `${theme.palette.common.white} !important`,
borderColor:
theme.palette.mode === "light"
? theme.palette.common.black
: theme.palette.common.white,
borderStyle: environment === "sandbox" ? "solid" : "dashed",
backgroundColor:
environment === "sandbox" && theme.palette.mode === "dark"
? colours.pine[800]
: environment === "sandbox" && theme.palette.mode === "light"
? colours.pine[300]
: "transparent",
fontWeight: 500,
fontSize: 14,
}}
>
{switchNetworkText}
</Button>
);
};
@@ -4,6 +4,8 @@ import { Circle } from "@mui/icons-material";
import { Button, Stack } from "@mui/material";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEnvironment } from "../../providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../envs/config";
import type { MenuItem } from "./menuItems";
type HeaderItemProps = {
@@ -12,10 +14,19 @@ type HeaderItemProps = {
const HeaderItem = ({ menu }: HeaderItemProps) => {
const pathname = usePathname();
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
return (
<Stack direction="row" gap={2} key={menu.id} alignItems="center">
{pathname.includes(menu.url) && <Circle sx={{ fontSize: 10 }} />}
<Link href={menu.url} passHref>
<Circle
sx={{
fontSize: 10,
display: pathname.includes(menu.url) ? "block" : "none",
}}
/>
<Link href={`${basePath}${menu.url}`} passHref>
<Button
sx={{
padding: 0,
@@ -9,10 +9,19 @@ import NymLogo from "../icons/NymLogo";
import ConnectWallet from "../wallet/ConnectWallet";
import { DarkLightSwitchDesktop } from "./Switch";
import MENU_DATA from "./menuItems";
import { EnvironmentSwitcher } from "./EnvironmentSwitcher";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../envs/config";
export const MobileHeader = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const theme = useTheme();
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
const explorerName = environment
? `${environment} Explorer`
: "Mainnet Explorer";
// Mobile menu handlers
const toggleDrawer = (open: boolean) => {
@@ -62,6 +71,55 @@ export const MobileHeader = () => {
>
{/* Main Menu */}
<Box sx={{ width: "50%", height: "100%" }}>
<Box key={explorerName} sx={{ marginBottom: 3 }}>
<Link
onClick={() => toggleDrawer(false)}
href={basePath}
target="_self"
sx={{
display: "flex",
width: "100%",
padding: 3.75,
color:
theme.palette.mode === "dark"
? "base.white"
: "background.main",
justifyContent: "space-between",
alignItems: "center",
textDecoration: "none",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: 1.25,
}}
>
<Box
sx={{
display: "block",
width: "10px",
height: "10px",
borderRadius: "100%",
bgcolor:
theme.palette.mode === "dark"
? "base.white"
: "primary.main",
}}
/>
<Typography
color={
theme.palette.mode === "dark" ? "base.white" : "primary"
}
variant="h4"
>
{explorerName}
</Typography>
</Box>
</Link>
</Box>
{MENU_DATA.map((menu) => (
<Box key={menu.title} sx={{ marginBottom: 3 }}>
<Link
@@ -78,6 +136,7 @@ export const MobileHeader = () => {
: "background.main",
justifyContent: "space-between",
alignItems: "center",
textDecoration: "none",
}}
>
<Box
@@ -145,7 +204,7 @@ const MobileMenuHeader = ({
>
<Link
onClick={() => toggleDrawer(false)}
href={"/"}
href={"https://nym.com/"}
style={{
display: "flex",
alignItems: "center",
@@ -172,12 +231,13 @@ const MobileMenuHeader = ({
sx={{
display: "flex",
flexDirection: "column",
gap: 2.5,
gap: 4,
justifyContent: "center",
alignItems: "center",
width: "100%",
}}
>
{!drawerOpen && <EnvironmentSwitcher />}
{!drawerOpen && <ConnectWallet size="small" />}
</Box>
<Box height={40} />
+8 -2
View File
@@ -1,12 +1,18 @@
import { Box } from "@mui/material";
import { DesktopHeader } from "./DesktopHeader";
import { MobileHeader } from "./MobileHeader";
export const Header = async () => {
return (
<header>
<Box
component="header"
sx={{
backgroundColor: "background.default",
}}
>
<DesktopHeader />
<MobileHeader />
{/* Mobile header will go here */}
</header>
</Box>
);
};
@@ -7,8 +7,8 @@ export type MenuItem = {
const MENU_DATA: MenuItem[] = [
{
id: 1,
title: "Explorer",
url: "/table",
title: "Servers",
url: "/servers",
},
{
id: 2,
@@ -0,0 +1,23 @@
"use client";
import Grid from "@mui/material/Grid2";
import { ReactNode } from "react";
interface ConditionalCardWrapperProps {
children: ReactNode;
size?:
| number
| { xs?: number; sm?: number; md?: number; lg?: number; xl?: number };
visible?: boolean;
}
export const ConditionalCardWrapper = ({
children,
size,
visible = true,
}: ConditionalCardWrapperProps) => {
if (!visible) {
return null;
}
return <Grid size={size}>{children}</Grid>;
};
@@ -4,7 +4,7 @@ import {
type EpochResponseData,
useEpochContext,
} from "@/providers/EpochProvider";
import { Skeleton, Typography } from "@mui/material";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { differenceInMinutes, format } from "date-fns";
import { useCallback, useEffect, useState } from "react";
import ExplorerCard from "../cards/ExplorerCard";
@@ -34,10 +34,13 @@ export const CurrentEpochCard = () => {
const [endTime, setEndTime] = useState("");
const [progress, setProgress] = useState(0);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const updateState = useCallback((data: NonNullable<EpochResponseData>) => {
const { startTime, endTime } = getStartEndTime(
data.current_epoch_start,
data.current_epoch_end,
data.current_epoch_end
);
const progress = calulateProgress(data.current_epoch_end);
@@ -65,30 +68,28 @@ export const CurrentEpochCard = () => {
);
}
if (isError) {
if (isError || !data) {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light">
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load data
</Typography>
</ExplorerCard>
);
}
if (!data) {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light">
No data available
</Typography>
</ExplorerCard>
);
}
if (epochStatus === "pending") {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light" height={80}>
<Typography
variant="body3"
fontWeight="light"
height={80}
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Waiting for next epoch to start...
</Typography>
</ExplorerCard>
@@ -0,0 +1,19 @@
"use client";
import { useEpochContext } from "@/providers/EpochProvider";
import { CurrentEpochCard } from "./CurrentEpochCard";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
export const CurrentEpochCardWrapper = () => {
const { data, isError, isLoading, epochStatus } = useEpochContext();
// Determine if the card should be visible
// Show the card if we have data and it's not in a pending state, or if we're still loading
const isVisible =
!isError && (data || isLoading) && epochStatus !== "pending";
return (
<ConditionalCardWrapper size={12} visible={isVisible}>
<CurrentEpochCard />
</ConditionalCardWrapper>
);
};
@@ -6,8 +6,10 @@ import type { ExplorerData, IPacketsAndStakingData } from "../../app/api/types";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { LineChart } from "../lineChart";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const NetworkStakeCard = () => {
const { environment } = useEnvironment();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const {
@@ -15,8 +17,8 @@ export const NetworkStakeCard = () => {
isLoading: isStakingLoading,
isError: isStakingError,
} = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
queryKey: ["noise", environment],
queryFn: () => fetchNoise(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -33,21 +35,14 @@ export const NetworkStakeCard = () => {
);
}
if (isStakingError || !packetsAndStaking) {
return (
<ExplorerCard label="Current network stake">
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
// Don't display the card if there's an error or insufficient data
if (
isStakingError ||
!packetsAndStaking ||
!Array.isArray(packetsAndStaking) ||
packetsAndStaking.length < 10
) {
return null;
}
const packetsAndStakingData: ExplorerData["packetsAndStakingData"] =
@@ -67,8 +62,8 @@ export const NetworkStakeCard = () => {
.map((item: IPacketsAndStakingData) => ({
date_utc: item.date_utc,
numericData: item.total_stake / 1000000,
}))
.filter((item) => item.numericData >= 50_000_000);
}));
// .filter((item) => item.numericData >= 50_000_000);
const stakeLineGraphData = {
color: "#00CA33",
@@ -0,0 +1,27 @@
"use client";
import { fetchNoise } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { NetworkStakeCard } from "./NetworkStakeCard";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const NetworkStakeCardWrapper = () => {
const { environment } = useEnvironment();
const { data, isLoading, isError } = useQuery({
queryKey: ["noise"],
queryFn: () => fetchNoise(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
// Determine if the card should be visible
const isVisible =
!isLoading && !isError && data && Array.isArray(data) && data.length >= 10;
return (
<ConditionalCardWrapper size={{ xs: 12, sm: 6, lg: 3 }} visible={isVisible}>
<NetworkStakeCard />
</ConditionalCardWrapper>
);
};
@@ -16,15 +16,17 @@ import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { LineChart } from "../lineChart";
import { UpDownPriceIndicator } from "../price/UpDownPriceIndicator";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const NoiseCard = () => {
const { environment } = useEnvironment();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const [tooltipOpen, setTooltipOpen] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
queryKey: ["noise", environment],
queryFn: () => fetchNoise(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -41,31 +43,21 @@ export const NoiseCard = () => {
);
}
if (isError || !data) {
return (
<ExplorerCard label="Mixnet traffic">
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
// Don't display the card if there's an error or insufficient data
if (isError || !data || !Array.isArray(data) || data.length < 10) {
return null;
}
const todaysData = data[data.length - 2];
const yesterdaysData = data[data.length - 3];
const noiseLast24H =
todaysData.total_packets_sent + todaysData.total_packets_received;
(todaysData?.total_packets_sent || 0) +
(todaysData?.total_packets_received || 0);
const noisePrevious24H =
yesterdaysData.total_packets_sent + yesterdaysData.total_packets_received;
(yesterdaysData?.total_packets_sent || 0) +
(yesterdaysData?.total_packets_received || 0);
const formatNoiseVolume = (packets: number): string => {
if (packets < 0) {
@@ -107,11 +99,12 @@ export const NoiseCard = () => {
.slice(0, -1)
.map((item: IPacketsAndStakingData) => {
return {
date_utc: item.date_utc,
numericData: item.total_packets_sent + item.total_packets_received,
date_utc: item?.date_utc,
numericData:
(item?.total_packets_sent || 0) + (item?.total_packets_received || 0),
};
})
.filter((item) => item.numericData >= 2_500_000_000);
});
// .filter((item) => item.numericData >= 2_500_000_000);
const handleTooltipOpen = () => {
setTooltipOpen(true);
@@ -0,0 +1,28 @@
"use client";
import { fetchNoise } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { NoiseCard } from "./NoiseCard";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const NoiseCardWrapper = () => {
const { environment } = useEnvironment();
const { data, isLoading, isError } = useQuery({
queryKey: ["noise"],
queryFn: () => fetchNoise(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
// Determine if the card should be visible
const isVisible =
!isLoading && !isError && data && Array.isArray(data) && data.length >= 10;
return (
<ConditionalCardWrapper size={{ xs: 12, sm: 6, lg: 3 }} visible={isVisible}>
<NoiseCard />
</ConditionalCardWrapper>
);
};
@@ -1,18 +1,21 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const StakersNumberCard = () => {
const { environment } = useEnvironment();
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: () => fetchObservatoryNodes(),
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -22,7 +25,7 @@ export const StakersNumberCard = () => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
if (isLoading) {
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Number of delegations">
<Skeleton variant="text" height={90} />
@@ -30,11 +33,11 @@ export const StakersNumberCard = () => {
);
}
if (isError || !nymNodes) {
if (isNSApiNodesError || !nsApiNodes) {
return (
<ExplorerCard label="Number of delegations">
<Typography
variant="h3"
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
@@ -43,13 +46,13 @@ export const StakersNumberCard = () => {
);
}
const getActiveStakersNumber = (nodes: IObservatoryNode[]): number => {
const getActiveStakersNumber = (nodes: NS_NODE[]): number => {
return nodes.reduce(
(sum, node) => sum + node.rewarding_details.unique_delegations,
0,
(sum, node) => sum + (node.rewarding_details?.unique_delegations || 0),
0
);
};
const allStakers = getActiveStakersNumber(nymNodes);
const allStakers = getActiveStakersNumber(nsApiNodes);
return (
<ExplorerCard label="Number of delegations">
@@ -0,0 +1,29 @@
"use client";
import { fetchNSApiNodes } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { StakersNumberCard } from "./StakersNumberCard";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const StakersNumberCardWrapper = () => {
const { environment } = useEnvironment();
const { data, isLoading, isError } = useQuery({
queryKey: ["nsApiNodes"],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Determine if the card should be visible
const isVisible =
!isLoading && !isError && data && Array.isArray(data) && data.length > 0;
return (
<ConditionalCardWrapper size={12} visible={isVisible}>
<StakersNumberCard />
</ConditionalCardWrapper>
);
};
@@ -1,14 +1,18 @@
"use client";
import { fetchEpochRewards, fetchNoise, fetchNymPrice } from "@/app/api";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { ExplorerData, NymTokenomics } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import { TitlePrice } from "../price/TitlePrice";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const TokenomicsCard = () => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
const {
data: nymPrice,
isLoading,
@@ -27,8 +31,8 @@ export const TokenomicsCard = () => {
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
queryKey: ["epochRewards", environment],
queryFn: () => fetchEpochRewards(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -40,8 +44,8 @@ export const TokenomicsCard = () => {
isLoading: isStakingLoading,
isError: isStakingError,
} = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
queryKey: ["noise", environment],
queryFn: () => fetchNoise(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -69,7 +73,10 @@ export const TokenomicsCard = () => {
) {
return (
<ExplorerCard label="Tokenomics overview">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load tokenomics overview.
</Typography>
<Skeleton variant="text" height={80} />
@@ -98,7 +105,7 @@ export const TokenomicsCard = () => {
function calculateTVL(
epochRewards: ExplorerData["currentEpochRewardsData"],
nymPriceData: NymTokenomics,
packetsAndStaking: ExplorerData["packetsAndStakingData"],
packetsAndStaking: ExplorerData["packetsAndStakingData"]
): number {
const lastTotalStake =
packetsAndStaking[packetsAndStaking.length - 1]?.total_stake || 0;
@@ -109,7 +116,7 @@ export const TokenomicsCard = () => {
);
}
const TVL = formatBigNum(
calculateTVL(epochRewardsData, nymPrice, packetsAndStakingData),
calculateTVL(epochRewardsData, nymPrice, packetsAndStakingData)
);
const dataRows = [
@@ -0,0 +1,67 @@
"use client";
import { fetchEpochRewards, fetchNoise, fetchNymPrice } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { TokenomicsCard } from "./TokenomicsCard";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const TokenomicsCardWrapper = () => {
const { environment } = useEnvironment();
const {
data: nymPrice,
isLoading: isPriceLoading,
isError: isPriceError,
} = useQuery({
queryKey: ["nymPrice"],
queryFn: fetchNymPrice,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const {
data: epochRewards,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: () => fetchEpochRewards(environment),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const {
data: packetsAndStaking,
isLoading: isStakingLoading,
isError: isStakingError,
} = useQuery({
queryKey: ["noise"],
queryFn: () => fetchNoise(environment),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Determine if the card should be visible
const isLoading = isPriceLoading || isEpochLoading || isStakingLoading;
const hasError = isPriceError || isEpochError || isStakingError;
const hasData =
nymPrice &&
epochRewards &&
packetsAndStaking &&
Array.isArray(packetsAndStaking) &&
packetsAndStaking.length >= 2;
const isVisible = !hasError && (hasData || isLoading);
return (
<ConditionalCardWrapper size={{ xs: 12, sm: 6, lg: 3 }} visible={isVisible}>
<TokenomicsCard />
</ConditionalCardWrapper>
);
};
@@ -0,0 +1,368 @@
"use client";
import React, { useEffect } from "react";
import {
Box,
Button,
Collapse,
Slider,
Typography,
useTheme,
} from "@mui/material";
import Grid from "@mui/material/Grid2";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import PieChartIcon from "@mui/icons-material/PieChart";
import PercentIcon from "@mui/icons-material/Percent";
import NodeFilterButtonGroup from "../toggleButton/NodeFilterButtonGroup";
import { RECOMMENDED_NODES } from "@/app/constants";
type AdvancedFiltersProps = {
uptime: [number, number];
setUptime: (value: [number, number]) => void;
saturation: [number, number];
setSaturation: (value: [number, number]) => void;
profitMargin: [number, number];
setProfitMargin: (value: [number, number]) => void;
open?: boolean;
setOpen?: (open: boolean) => void;
maxSaturation?: number;
activeFilter: "all" | "mixnodes" | "gateways" | "recommended";
setActiveFilter: (
filter: "all" | "mixnodes" | "gateways" | "recommended"
) => void;
nodeCounts: {
all: number;
mixnodes: number;
gateways: number;
};
environment: "mainnet" | "sandbox";
};
export default function AdvancedFilters({
uptime,
setUptime,
saturation,
setSaturation,
profitMargin,
setProfitMargin,
open,
setOpen,
maxSaturation = 100,
activeFilter,
setActiveFilter,
nodeCounts,
environment,
}: AdvancedFiltersProps) {
const theme = useTheme();
const green = "#14e76f"; // from theme colours
const marksPercent: { value: number }[] = [{ value: 0 }, { value: 100 }];
const marksSaturation: { value: number }[] = [
{ value: 0 },
{ value: maxSaturation },
];
const panel = (
<Box
sx={{
mt: 3,
p: 2,
borderRadius: 3,
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography
variant="subtitle1"
sx={{
fontStyle: "italic",
color:
theme.palette.mode === "light"
? theme.palette.common.black
: theme.palette.common.white,
mb: 2,
}}
>
Advanced filtering mode is active
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Box
sx={{
p: 3,
borderRadius: 3,
background: theme.palette.background.default,
mb: { xs: 2, sm: 0 },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box display="flex" alignItems="center" mb={1}>
<AccessTimeIcon
sx={{
color:
theme.palette.mode === "dark"
? theme.palette.common.white
: theme.palette.common.black,
mr: 1,
}}
/>
<Typography
variant="h6"
sx={{ color: theme.palette.text.primary, fontSize: 17 }}
>
Uptime
</Typography>
<Box flexGrow={1} />
<Typography
variant="h6"
sx={{ color: theme.palette.primary.main, fontSize: 17 }}
>
{uptime[0]}% - {uptime[1]}%
</Typography>
</Box>
<Slider
value={uptime}
onChange={(_, v) => setUptime(v as [number, number])}
valueLabelDisplay="off"
min={0}
max={100}
marks={marksPercent}
sx={{
color: green,
height: 8,
"& .MuiSlider-thumb": {
width: 24,
height: 24,
backgroundColor: green,
},
}}
/>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Box
sx={{
p: 3,
borderRadius: 3,
background: theme.palette.background.default,
mb: { xs: 2, sm: 0 },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box display="flex" alignItems="center" mb={1}>
<PieChartIcon
sx={{
color:
theme.palette.mode === "dark"
? theme.palette.common.white
: theme.palette.common.black,
mr: 1,
}}
/>
<Typography
variant="h6"
sx={{ color: theme.palette.text.primary, fontSize: 17 }}
>
Saturation
</Typography>
<Box flexGrow={1} />
<Typography
variant="h6"
sx={{ color: theme.palette.primary.main, fontSize: 17 }}
>
{saturation[0]}% - {saturation[1]}%
</Typography>
</Box>
<Slider
value={saturation}
onChange={(_, v) => setSaturation(v as [number, number])}
valueLabelDisplay="off"
min={0}
max={maxSaturation}
marks={marksSaturation}
sx={{
color: green,
height: 8,
"& .MuiSlider-thumb": {
width: 24,
height: 24,
backgroundColor: green,
},
}}
/>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }} sx={{ mx: { sm: "auto" } }}>
<Box
sx={{
p: 3,
borderRadius: 3,
background: theme.palette.background.default,
mb: { xs: 2, sm: 0 },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box display="flex" alignItems="center" mb={1}>
<PercentIcon
sx={{
color:
theme.palette.mode === "dark"
? theme.palette.common.white
: theme.palette.common.black,
mr: 1,
}}
/>
<Typography
variant="h6"
sx={{ color: theme.palette.text.primary, fontSize: 17 }}
>
Profit Margin
</Typography>
<Box flexGrow={1} />
<Typography
variant="h6"
sx={{ color: theme.palette.primary.main, fontSize: 17 }}
>
{profitMargin[0]}% - {profitMargin[1]}%
</Typography>
</Box>
<Slider
value={profitMargin}
onChange={(_, v) => setProfitMargin(v as [number, number])}
valueLabelDisplay="off"
min={0}
max={100}
marks={marksPercent}
sx={{
color: green,
height: 8,
"& .MuiSlider-thumb": {
width: 24,
height: 24,
backgroundColor: green,
},
}}
/>
</Box>
</Grid>
</Grid>
</Box>
);
const filterOptions =
environment === "mainnet"
? [
{
label: `Recommended servers (${RECOMMENDED_NODES.length})`,
isSelected: activeFilter === "recommended",
value: "recommended" as const,
},
{
label: `All servers (${nodeCounts.all})`,
isSelected: activeFilter === "all",
value: "all" as const,
},
{
label: `Mixnodes (${nodeCounts.mixnodes})`,
isSelected: activeFilter === "mixnodes",
value: "mixnodes" as const,
},
{
label: `Gateways (${nodeCounts.gateways})`,
isSelected: activeFilter === "gateways",
value: "gateways" as const,
},
]
: [
{
label: `All servers (${nodeCounts.all})`,
isSelected: activeFilter === "all",
value: "all" as const,
},
{
label: `Mixnodes (${nodeCounts.mixnodes})`,
isSelected: activeFilter === "mixnodes",
value: "mixnodes" as const,
},
{
label: `Gateways (${nodeCounts.gateways})`,
isSelected: activeFilter === "gateways",
value: "gateways" as const,
},
];
useEffect(() => {
if (environment === "sandbox" && activeFilter === "recommended") {
setActiveFilter("all");
}
}, [environment, activeFilter, setActiveFilter]);
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "stretch", sm: "center" },
gap: 2,
}}
>
<Box sx={{ width: { xs: "100%", sm: "auto" } }}>
<NodeFilterButtonGroup
size="medium"
options={filterOptions}
onPage={activeFilter}
onFilterChange={setActiveFilter}
/>
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={
<FilterAltIcon
sx={{
color:
theme.palette.mode === "light"
? `${theme.palette.common.black} !important`
: `${theme.palette.common.white} !important`,
}}
/>
}
onClick={() => setOpen && setOpen(!open)}
sx={{
borderRadius: 3,
px: 4,
py: 1.5,
color:
theme.palette.mode === "light"
? `${theme.palette.common.black} !important`
: `${theme.palette.common.white} !important`,
borderColor:
theme.palette.mode === "light"
? theme.palette.common.black
: theme.palette.common.white,
background: "none",
fontWeight: 500,
fontSize: 16,
"&:hover, &:focus": {
background:
theme.palette.mode === "light"
? "rgba(0,0,0,0.04)"
: "rgba(255,255,255,0.05)",
borderColor:
theme.palette.mode === "light"
? theme.palette.grey[400]
: theme.palette.common.white,
},
}}
>
Advanced Filters
</Button>
</Box>
<Collapse in={open} timeout="auto" unmountOnExit>
{panel}
</Collapse>
</Box>
);
}
@@ -7,6 +7,7 @@ import {
Typography,
useMediaQuery,
useTheme,
Tooltip,
} from "@mui/material";
import { useQueryClient } from "@tanstack/react-query";
import { useLocalStorage } from "@uidotdev/usehooks";
@@ -29,6 +30,9 @@ import StakeModal from "../staking/StakeModal";
import { fee } from "../staking/schemas";
import ConnectWallet from "../wallet/ConnectWallet";
import type { MappedNymNode, MappedNymNodes } from "./NodeTableWithAction";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../envs/config";
const ColumnHeading = ({
children,
@@ -82,7 +86,9 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
identityKey: string;
}>();
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const { isWalletConnected } = useChain(chain);
const handleRefetch = useCallback(async () => {
await queryClient.invalidateQueries();
@@ -100,7 +106,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
{ nodeId },
fee,
"Delegation from Nym Explorer V2",
uNymFunds,
uNymFunds
);
setSelectedNodeForStaking(undefined);
setInfoModalProps({
@@ -129,7 +135,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
}
setIsLoading(false);
},
[nymClient, handleRefetch],
[nymClient, handleRefetch]
);
const handleOnSelectStake = useCallback(
@@ -158,7 +164,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
identityKey: node.identity_key,
});
},
[isWalletConnected],
[isWalletConnected]
);
const columns: MRT_ColumnDef<MappedNymNode>[] = useMemo(
@@ -189,54 +195,6 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
Cell: ({ row }) => <Favorite address={row.original.owner} />,
},
{
id: "name",
header: "",
Header: <ColumnHeading>Name</ColumnHeading>,
accessorKey: "name",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.name || "-"}</Typography>
</Stack>
),
},
{
id: "id",
header: "",
Header: <ColumnHeading>Node ID</ColumnHeading>,
accessorKey: "nodeId",
size: 90,
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.nodeId}</Typography>
</Stack>
),
},
{
id: "identity_key",
header: "",
Header: <ColumnHeading>Identity Key</ColumnHeading>,
accessorKey: "identity_key",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body5">{row.original.identity_key}</Typography>
</Stack>
),
},
{
id: "qos",
header: "Qlt of Service",
align: "center",
accessorKey: "qualityOfService",
size: 100,
Header: <ColumnHeading>Qlt of Service</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.qualityOfService.toFixed()}%
</Typography>
),
},
{
id: "location",
header: "Location",
@@ -255,6 +213,98 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
"-"
),
},
{
id: "name",
header: "",
size: 210,
Header: <ColumnHeading>Node</ColumnHeading>,
accessorKey: "name",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.name || "-"}</Typography>
<Tooltip
title={row.original.identity_key}
placement="bottom"
slotProps={{
tooltip: {
sx: {
maxWidth: "none",
whiteSpace: "nowrap",
bgcolor: isDarkMode ? "#374042" : "#E5E7EB",
color: isDarkMode ? "#FFFFFF" : "#000000",
"& .MuiTooltip-arrow": {
color: isDarkMode ? "#374042" : "#E5E7EB",
},
},
},
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
onClick={(e) => e.stopPropagation()}
sx={{ height: "24px" }}
>
<Typography
variant="body5"
sx={{
height: "24px",
display: "flex",
alignItems: "center",
}}
>
{row.original.identity_key.length > 17
? `${row.original.identity_key.slice(0, 10)}...${row.original.identity_key.slice(-8)}`
: row.original.identity_key}
</Typography>
<CopyToClipboard text={row.original.identity_key} />
</Stack>
</Tooltip>
</Stack>
),
},
{
id: "id",
header: "",
Header: <ColumnHeading>Node ID</ColumnHeading>,
accessorKey: "nodeId",
size: 90,
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.nodeId}</Typography>
</Stack>
),
},
{
id: "qos",
header: "Qlt of Service",
align: "center",
accessorKey: "qualityOfService",
size: 100,
Header: <ColumnHeading>Uptime</ColumnHeading>,
Cell: ({ row }) => {
const value = row.original.qualityOfService;
let color = "#000000";
if (value >= 80) {
color = "#22C55E"; // green
} else if (value >= 50) {
color = "#F59E0B"; // amber/orange-yellow
} else {
color = "#EF4444"; // red
}
return (
<Typography variant="body4" sx={{ color, fontWeight: 400 }}>
{value.toFixed()}%
</Typography>
);
},
},
{
id: "stakeSaturation",
header: "Stake saturation",
@@ -262,9 +312,58 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
size: 120,
Header: <ColumnHeading>Saturation</ColumnHeading>,
Cell: ({ row }) => {
const value = row.original.stakeSaturation;
let color = "#000000";
if (value > 100) {
color = "#EF4444";
} else if (value >= 75) {
color = "#22C55E";
} else if (value >= 25) {
color = "#F59E0B";
} else {
color = "#EF4444";
}
return (
<Typography variant="body4" sx={{ color, fontWeight: 400 }}>
{value}%
</Typography>
);
},
},
{
id: "selfBond",
header: "Self-bond",
accessorKey: "selfBond",
Header: <ColumnHeading>Self-bond</ColumnHeading>,
Cell: ({ row }) => {
const value = row.original.selfBond;
let color = isDarkMode ? "#FFFFFF" : "#000000";
if (value === 0) {
color = "#EF4444";
}
return (
<Typography
variant="body4"
sx={{ color, fontWeight: value === 0 ? 400 : 300 }}
>
{row.original.selfBond} NYM
</Typography>
);
},
},
{
id: "operatingCosts",
header: "Operating costs",
accessorKey: "operatingCosts",
Header: <ColumnHeading>Operating costs</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.stakeSaturation}%
{row.original.operatingCosts} NYM
</Typography>
),
},
@@ -303,7 +402,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
enableSorting: false,
},
],
[isWalletConnected, handleOnSelectStake, favorites],
[isWalletConnected, handleOnSelectStake, favorites, isDarkMode]
);
const table = useMaterialReactTable({
columns,
@@ -410,8 +509,14 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
},
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/nym-node/${row.original.nodeId}`);
onClick: (e) => {
if (e.ctrlKey || e.metaKey) {
const basePath = getBasePathByEnv(environment || "mainnet");
window.open(`${basePath}/nym-node/${row.original.nodeId}`, "_blank");
} else {
const basePath = getBasePathByEnv(environment || "mainnet");
router.push(`${basePath}/nym-node/${row.original.nodeId}`);
}
},
hover: true,
sx: {
@@ -3,15 +3,19 @@
import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import type { ExplorerData, IObservatoryNode } from "../../app/api/types";
import { fetchEpochRewards, fetchNSApiNodes } from "../../app/api";
import type { ExplorerData, NS_NODE } from "../../app/api/types";
import { countryName } from "../../utils/countryName";
import NodeTable from "./NodeTable";
import { useState, useEffect } from "react";
import AdvancedFilters from "./AdvancedFilters";
import { RECOMMENDED_NODES } from "@/app/constants";
import { useEnvironment } from "@/providers/EnvironmentProvider";
// Utility function to calculate node saturation point
function getNodeSaturationPoint(
totalStake: number,
stakeSaturationPoint: string,
stakeSaturationPoint: string
): number {
const saturation = Number.parseFloat(stakeSaturationPoint);
@@ -25,47 +29,139 @@ function getNodeSaturationPoint(
}
// Map nodes with rewards data
const mappedNymNodes = (
nodes: IObservatoryNode[],
epochRewardsData: ExplorerData["currentEpochRewardsData"],
const mappedNSApiNodes = (
nodes: NS_NODE[],
epochRewardsData: ExplorerData["currentEpochRewardsData"]
) =>
nodes.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
nodes
.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
+node.total_stake,
epochRewardsData.interval.stake_saturation_point
);
const cleanMoniker = DOMPurify.sanitize(
node.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanMoniker = DOMPurify.sanitize(node.description.moniker).replace(
/&amp;/g,
"&"
);
return {
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.description.auxiliary_details.location || null,
countryName:
countryName(node.description.auxiliary_details.location) || null,
profitMarginPercentage:
+node.rewarding_details.cost_params.profit_margin_percent * 100,
owner: node.bonding_address,
stakeSaturation: nodeSaturationPoint,
qualityOfService: +node.uptime * 100,
};
});
const selfBondFormatted = node.original_pledge
? Number(node.original_pledge) / 1_000_000
: 0;
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
const operatingCostsFormatted = node.rewarding_details
? Number(
node.rewarding_details.cost_params.interval_operating_cost.amount
) / 1_000_000
: 0;
return {
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.geoip?.country || null,
countryName: countryName(node.geoip?.country || null) || null,
selfBond: selfBondFormatted,
operatingCosts: operatingCostsFormatted,
profitMarginPercentage: node.rewarding_details
? +node.rewarding_details.cost_params.profit_margin_percent * 100
: 0,
owner: node.bonding_address,
stakeSaturation: nodeSaturationPoint,
qualityOfService: +node.uptime * 100,
mixnode: node.self_description?.declared_role.mixnode === true,
gateway:
node.self_description?.declared_role.entry === true ||
node.self_description?.declared_role.exit_ipr === true ||
node.self_description?.declared_role.exit_nr === true,
};
})
.sort((a, b) => {
// Handle null country names by putting them at the end
if (!a.countryName && !b.countryName) return 0;
if (!a.countryName) return 1;
if (!b.countryName) return -1;
// Sort alphabetically by country name
return a.countryName.localeCompare(b.countryName);
});
export type MappedNymNodes = ReturnType<typeof mappedNSApiNodes>;
export type MappedNymNode = MappedNymNodes[0];
const NodeTableWithAction = () => {
// All hooks at the top!
const [activeFilter, setActiveFilter] = useState<
"all" | "mixnodes" | "gateways" | "recommended"
>(() => {
const stored = sessionStorage.getItem("nodeTableActiveFilter");
return (
(stored as "all" | "mixnodes" | "gateways" | "recommended") ||
"recommended"
);
});
const [uptime, setUptime] = useState<[number, number]>(() => {
const stored = sessionStorage.getItem("nodeTableUptime");
return stored ? JSON.parse(stored) : [0, 100];
});
const [saturation, setSaturation] = useState<[number, number]>([0, 100]);
const [profitMargin, setProfitMargin] = useState<[number, number]>(() => {
const stored = sessionStorage.getItem("nodeTableProfitMargin");
return stored ? JSON.parse(stored) : [0, 100];
});
const [advancedOpen, setAdvancedOpen] = useState(() => {
const stored = sessionStorage.getItem("nodeTableAdvancedOpen");
return stored ? JSON.parse(stored) : false;
});
const { environment } = useEnvironment();
// Wrapper functions to handle filter changes and sessionStorage
const handleActiveFilterChange = (
newFilter: "all" | "mixnodes" | "gateways" | "recommended"
) => {
setActiveFilter(newFilter);
sessionStorage.setItem("nodeTableActiveFilter", newFilter);
};
const handleUptimeChange = (newUptime: [number, number]) => {
setUptime(newUptime);
sessionStorage.setItem("nodeTableUptime", JSON.stringify(newUptime));
};
const handleSaturationChange = (newSaturation: [number, number]) => {
setSaturation(newSaturation);
sessionStorage.setItem(
"nodeTableSaturation",
JSON.stringify(newSaturation)
);
};
const handleProfitMarginChange = (newProfitMargin: [number, number]) => {
setProfitMargin(newProfitMargin);
sessionStorage.setItem(
"nodeTableProfitMargin",
JSON.stringify(newProfitMargin)
);
};
const handleAdvancedOpenChange = (newAdvancedOpen: boolean) => {
setAdvancedOpen(newAdvancedOpen);
sessionStorage.setItem(
"nodeTableAdvancedOpen",
JSON.stringify(newAdvancedOpen)
);
};
// Use React Query to fetch epoch rewards
const {
data: epochRewardsData,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
queryKey: ["epochRewards", environment],
queryFn: () => fetchEpochRewards(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -74,20 +170,48 @@ const NodeTableWithAction = () => {
// Use React Query to fetch Nym nodes
const {
data: nymNodes = [],
isLoading: isNodesLoading,
isError: isNodesError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
// Map nodes with rewards data
const nsApiNodesData = epochRewardsData
? mappedNSApiNodes(nsApiNodes || [], epochRewardsData)
: [];
// Calculate max saturation from all nodes
const maxSaturation = Math.max(
100,
...nsApiNodesData.map((n) => n.stakeSaturation || 0)
);
// Initialize saturation from sessionStorage or set to maxSaturation when data is loaded
useEffect(() => {
const stored = sessionStorage.getItem("nodeTableSaturation");
if (stored) {
setSaturation(JSON.parse(stored));
} else if (nsApiNodesData.length > 0) {
setSaturation([0, maxSaturation]);
}
}, [maxSaturation, nsApiNodesData.length]);
// Calculate node counts for each type
const nodeCounts = {
all: nsApiNodesData.length,
mixnodes: nsApiNodesData.filter((node) => node.mixnode).length,
gateways: nsApiNodesData.filter((node) => node.gateway).length,
};
// Handle loading state
if (isEpochLoading || isNodesLoading) {
if (isEpochLoading || isNSApiNodesLoading) {
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
@@ -101,7 +225,7 @@ const NodeTableWithAction = () => {
}
// Handle error state
if (isEpochError || isNodesError) {
if (isEpochError || isNSApiNodesError) {
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
@@ -112,14 +236,68 @@ const NodeTableWithAction = () => {
}
// Map nodes with rewards data
if (!epochRewardsData) {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
// Step 1: Filter nodes by type
const typeFilteredNodes = nsApiNodesData.filter((node) => {
switch (activeFilter) {
case "mixnodes":
return node.mixnode;
case "gateways":
return node.gateway;
case "recommended":
return RECOMMENDED_NODES.includes(node.nodeId);
default:
return true;
}
});
return <NodeTable nodes={data} />;
// Step 2: If advanced filters are open, apply them only if sliders are not at default
const isDefault = {
uptime: uptime[0] === 0 && uptime[1] === 100,
saturation: saturation[0] === 0 && saturation[1] === maxSaturation,
profitMargin: profitMargin[0] === 0 && profitMargin[1] === 100,
};
const filteredNodes = advancedOpen
? typeFilteredNodes.filter((node) => {
const uptimeMatch =
isDefault.uptime ||
(node.qualityOfService >= uptime[0] &&
node.qualityOfService <= uptime[1]);
const saturationMatch =
isDefault.saturation ||
(node.stakeSaturation >= saturation[0] &&
node.stakeSaturation <= saturation[1]);
const profitMarginMatch =
isDefault.profitMargin ||
(node.profitMarginPercentage >= profitMargin[0] &&
node.profitMarginPercentage <= profitMargin[1]);
return uptimeMatch && saturationMatch && profitMarginMatch;
})
: typeFilteredNodes;
return (
<Stack spacing={3}>
<AdvancedFilters
open={advancedOpen}
setOpen={handleAdvancedOpenChange}
uptime={uptime}
setUptime={handleUptimeChange}
saturation={saturation}
setSaturation={handleSaturationChange}
profitMargin={profitMargin}
setProfitMargin={handleProfitMarginChange}
maxSaturation={maxSaturation}
activeFilter={activeFilter}
setActiveFilter={handleActiveFilterChange}
nodeCounts={nodeCounts}
environment={environment}
/>
<NodeTable nodes={filteredNodes} />
</Stack>
);
};
export default NodeTableWithAction;
@@ -1,35 +1,40 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Stack, Typography } from "@mui/material";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchObservatoryNodes } from "../../app/api";
import { fetchNSApiNodes } from "../../app/api";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import ExplorerListItem from "../list/ListItem";
import { useEnvironment } from "@/providers/EnvironmentProvider";
type Props = {
paramId: string;
};
export const BasicInfoCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const { environment } = useEnvironment();
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isLoading) {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Basic info">
<Skeleton variant="text" height={90} />
@@ -42,10 +47,13 @@ export const BasicInfoCard = ({ paramId }: Props) => {
);
}
if (isError || !nymNodes) {
if (!nsApiNodes || isNSApiNodesError) {
return (
<ExplorerCard label="Basic info">
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
@@ -55,16 +63,20 @@ export const BasicInfoCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId)
);
}
if (!nodeInfo) return null;
const selfBond = formatBigNum(
Number(nodeInfo.rewarding_details.operator) / 1_000_000,
);
const selfBond = nodeInfo.original_pledge
? formatBigNum(Number(nodeInfo.original_pledge) / 1_000_000)
: 0;
const selfBondFormatted = `${selfBond} NYM`;
return (
@@ -85,9 +97,13 @@ export const BasicInfoCard = ({ paramId }: Props) => {
variant="body4"
sx={{ wordWrap: "break-word", maxWidth: "90%" }}
>
{nodeInfo.bonding_address}
{nodeInfo.bonding_address
? nodeInfo.bonding_address
: "Node not bonded"}
</Typography>
<CopyToClipboard text={nodeInfo.bonding_address} />
{nodeInfo.bonding_address && (
<CopyToClipboard text={nodeInfo.bonding_address} />
)}
</Stack>
}
/>
@@ -113,12 +129,14 @@ export const BasicInfoCard = ({ paramId }: Props) => {
}
/>
<ExplorerListItem
row
divider
label="Nr. of stakers"
value={nodeInfo.rewarding_details.unique_delegations.toString()}
/>
{nodeInfo.rewarding_details && (
<ExplorerListItem
row
divider
label="Nr. of stakers"
value={nodeInfo.rewarding_details.unique_delegations.toString()}
/>
)}
<ExplorerListItem row label="Self bonded" value={selfBondFormatted} />
</Stack>
</ExplorerCard>
@@ -11,6 +11,8 @@ import {
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import type { NodeRewardDetails } from "../../app/api/types";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../envs/config";
const ColumnHeading = ({
children,
@@ -36,10 +38,12 @@ type Props = {
const DelegationsTable = ({ id }: Props) => {
const router = useRouter();
const theme = useTheme();
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
const { data: delegations = [], isError } = useQuery({
queryKey: ["nodeDelegations", id],
queryFn: () => fetchNodeDelegations(id),
queryKey: ["nodeDelegations", id, environment],
queryFn: () => fetchNodeDelegations(environment, id),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -55,7 +59,7 @@ const DelegationsTable = ({ id }: Props) => {
accessorKey: "height",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.height}</Typography>
<Typography variant="body4">{row.original.block_height}</Typography>
</Stack>
),
},
@@ -86,7 +90,7 @@ const DelegationsTable = ({ id }: Props) => {
),
},
],
[],
[]
);
const table = useMaterialReactTable({
columns,
@@ -139,7 +143,7 @@ const DelegationsTable = ({ id }: Props) => {
},
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/account/${row.original.owner}`);
router.push(`${basePath}/account/${row.original.owner}`);
},
hover: true,
sx: {
@@ -1,48 +1,40 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Typography } from "@mui/material";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import { fetchNSApiNodes } from "../../app/api";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import { useEnvironment } from "@/providers/EnvironmentProvider";
type Props = {
paramId: string;
};
export const NodeDataCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
const {
data: epochRewardsData,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
let nodeInfo: NS_NODE | undefined;
const { environment } = useEnvironment();
// Fetch node information
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isEpochLoading || isLoading) {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Nym node data" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
@@ -53,10 +45,13 @@ export const NodeDataCard = ({ paramId }: Props) => {
);
}
if (isEpochError || isError || !nymNodes || !epochRewardsData) {
if (isNSApiNodesError || !nsApiNodes) {
return (
<ExplorerCard label="Nym node data" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
@@ -66,17 +61,23 @@ export const NodeDataCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId)
);
}
if (!nodeInfo) return null;
const softwareUpdateTime = format(
new Date(nodeInfo.description.build_information.build_timestamp),
"dd/MM/yyyy",
);
const softwareUpdateTime = nodeInfo.self_description
? format(
new Date(nodeInfo.self_description.build_information.build_timestamp),
"dd/MM/yyyy"
)
: "N/A";
return (
<ExplorerCard label="Nym node data" sx={{ height: "100%" }}>
@@ -90,13 +91,21 @@ export const NodeDataCard = ({ paramId }: Props) => {
row
divider
label="Host"
value={nodeInfo.description.host_information.ip_address.toString()}
value={
nodeInfo.self_description
? nodeInfo.self_description.host_information.ip_address.toString()
: "N/A"
}
/>
<ExplorerListItem
row
divider
label="Version"
value={nodeInfo.description.build_information.build_version}
value={
nodeInfo.self_description
? nodeInfo.self_description.build_information.build_version
: "N/A"
}
/>
<ExplorerListItem
row
@@ -1,26 +1,30 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Typography } from "@mui/material";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import DelegationsTable from "./DelegationsTable";
import { useEnvironment } from "@/providers/EnvironmentProvider";
type Props = {
paramId: string;
};
const NodeDelegationsCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
let nodeInfo: NS_NODE | undefined;
const { environment } = useEnvironment();
const {
data: nymNodes,
isError,
isLoading,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -28,16 +32,20 @@ const NodeDelegationsCard = ({ paramId }: Props) => {
});
if (paramId.length > 10) {
nodeInfo = nymNodes?.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId
);
} else {
nodeInfo = nymNodes?.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId)
);
}
if (!nodeInfo) return null;
const id = nodeInfo.node_id;
if (isLoading) {
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
@@ -48,10 +56,13 @@ const NodeDelegationsCard = ({ paramId }: Props) => {
);
}
if (isError) {
if (isNSApiNodesError) {
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h3"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load delegations. Please try again later.
</Typography>
</ExplorerCard>
@@ -1,54 +1,65 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { useQuery } from "@tanstack/react-query";
import { getBasePathByEnv } from "../../../envs/config";
import { Box } from "@mui/material";
type Props = {
paramId: string;
};
export default function NodePageButtonGroup({ paramId }: Props) {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const { environment } = useEnvironment();
const basePath = getBasePathByEnv(environment || "mainnet");
const { data: nymNodes, isError } = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
const { data: nsApiNodes = [], isError: isNSApiNodesError } = useQuery({
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (!nymNodes || isError) return null;
if (!nsApiNodes || isNSApiNodesError) return null;
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId)
);
}
if (!nodeInfo) return null;
if (nodeInfo.bonding_address)
return (
<ExplorerButtonGroup
onPage="Nym Node"
options={[
{
label: "Nym Node",
isSelected: true,
link: `/nym-node/${nodeInfo.node_id}`,
},
{
label: "Account",
isSelected: false,
link: `/account/${nodeInfo.bonding_address}`,
},
]}
/>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<ExplorerButtonGroup
onPage="Node"
options={[
{
label: "Nym Node",
isSelected: true,
link: `${basePath}/nym-node/${nodeInfo.identity_key}`,
},
{
label: "Account",
isSelected: false,
link: `${basePath}/account/${nodeInfo.bonding_address}`,
},
]}
/>
</Box>
);
}
@@ -1,19 +1,23 @@
"use client";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Skeleton, Typography } from "@mui/material";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import type { IObservatoryNode, RewardingDetails } from "../../app/api/types";
import { fetchEpochRewards, fetchNSApiNodes } from "../../app/api";
import type { NS_NODE } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import { useEnvironment } from "@/providers/EnvironmentProvider";
type Props = {
paramId: string;
};
export const NodeParametersCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
let nodeInfo: NS_NODE | undefined;
const { environment } = useEnvironment();
// Fetch epoch rewards
const {
@@ -21,8 +25,8 @@ export const NodeParametersCard = ({ paramId }: Props) => {
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
queryKey: ["epochRewards", environment],
queryFn: () => fetchEpochRewards(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -31,19 +35,19 @@ export const NodeParametersCard = ({ paramId }: Props) => {
// Fetch node information
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isEpochLoading || isLoading) {
if (isEpochLoading || isNSApiNodesLoading) {
return (
<ExplorerCard label="Node parameters" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
@@ -54,10 +58,13 @@ export const NodeParametersCard = ({ paramId }: Props) => {
);
}
if (isEpochError || isError || !nymNodes || !epochRewardsData) {
if (isEpochError || isNSApiNodesError || !nsApiNodes || !epochRewardsData) {
return (
<ExplorerCard label="Node parameters" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
@@ -66,9 +73,13 @@ export const NodeParametersCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId)
);
}
if (!nodeInfo) return null;
@@ -76,23 +87,27 @@ export const NodeParametersCard = ({ paramId }: Props) => {
const totalStake = formatBigNum(Number(nodeInfo.total_stake) / 1_000_000);
const totalStakeFormatted = `${totalStake} NYM`;
// Extract reward details
const rewardDetails: RewardingDetails = nodeInfo.rewarding_details;
const profitMarginPercent =
Number(rewardDetails.cost_params.profit_margin_percent) * 100;
// Extract reward details
const profitMarginPercent = nodeInfo.rewarding_details
? Number(nodeInfo.rewarding_details.cost_params.profit_margin_percent) * 100
: 0;
const profitMarginPercentFormated = `${profitMarginPercent}%`;
const operatingCosts =
Number(rewardDetails.cost_params.interval_operating_cost.amount) /
1_000_000;
const operatingCosts = nodeInfo.rewarding_details
? Number(
nodeInfo.rewarding_details.cost_params.interval_operating_cost.amount
) / 1_000_000
: 0;
const operatingCostsFormated = `${operatingCosts.toString()} NYM`;
const getNodeSaturationPoint = (
totalStake: number,
stakeSaturationPoint: string,
nodeTotalStake: string,
stakeSaturationPoint: string
): string => {
const saturation = Number.parseFloat(stakeSaturationPoint);
const totalStake = Number.parseFloat(nodeTotalStake);
if (Number.isNaN(saturation) || saturation <= 0) {
throw new Error("Invalid stake saturation point provided");
@@ -105,7 +120,7 @@ export const NodeParametersCard = ({ paramId }: Props) => {
const nodeSaturationPoint = getNodeSaturationPoint(
nodeInfo.total_stake,
epochRewardsData.interval.stake_saturation_point,
epochRewardsData.interval.stake_saturation_point
);
return (
@@ -1,6 +1,6 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import type { NS_NODE } from "@/app/api/types";
import { useChain } from "@cosmos-kit/react";
import {
Box,
@@ -14,7 +14,7 @@ import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { useCallback, useState } from "react";
import { RandomAvatar } from "react-random-avatars";
import { fetchObservatoryNodes } from "../../app/api";
import { fetchNSApiNodes } from "../../app/api";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import ExplorerCard from "../cards/ExplorerCard";
@@ -25,16 +25,19 @@ import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import StakeModal from "../staking/StakeModal";
import { fee } from "../staking/schemas";
import ConnectWallet from "../wallet/ConnectWallet";
import { useEnvironment } from "@/providers/EnvironmentProvider";
type Props = {
paramId: string;
};
export const NodeProfileCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const theme = useTheme();
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { isWalletConnected } = useChain(chain);
const { nymClient } = useNymClient();
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
@@ -47,12 +50,12 @@ export const NodeProfileCard = ({ paramId }: Props) => {
// Fetch node info
const {
data: nymNodes,
isLoading: isLoadingNymNodes,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -62,9 +65,13 @@ export const NodeProfileCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes?.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId
);
} else {
nodeInfo = nymNodes?.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId)
);
}
const handleOnSelectStake = useCallback(() => {
@@ -96,7 +103,7 @@ export const NodeProfileCard = ({ paramId }: Props) => {
}
}, [isWalletConnected, nodeInfo]);
if (isLoadingNymNodes) {
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<Skeleton variant="rectangular" height={80} width={80} />
@@ -105,11 +112,11 @@ export const NodeProfileCard = ({ paramId }: Props) => {
</ExplorerCard>
);
}
if (isError || !nymNodes) {
if (isNSApiNodesError || !nsApiNodes) {
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<Typography
variant="h3"
variant="h5"
sx={{
color: theme.palette.mode === "dark" ? "base.white" : "pine.950",
}}
@@ -137,7 +144,7 @@ export const NodeProfileCard = ({ paramId }: Props) => {
{ nodeId },
fee,
"Delegation from Nym Explorer V2",
uNymFunds,
uNymFunds
);
setSelectedNodeForStaking(undefined);
setInfoModalProps({
@@ -164,12 +171,13 @@ export const NodeProfileCard = ({ paramId }: Props) => {
if (!nodeInfo) return null;
const cleanMoniker = DOMPurify.sanitize(
nodeInfo?.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanMoniker = DOMPurify.sanitize(nodeInfo.description.moniker).replace(
/&amp;/g,
"&"
);
const cleanDescription = DOMPurify.sanitize(
nodeInfo?.self_description.details,
nodeInfo.description.details
).replace(/&amp;/g, "&");
// get full country name
@@ -197,7 +205,7 @@ export const NodeProfileCard = ({ paramId }: Props) => {
>
{cleanMoniker || "Moniker"}
</Typography>
{nodeInfo.description.auxiliary_details.location && (
{nodeInfo.geoip?.country && (
<Box display={"flex"} gap={1}>
<Typography
variant="h6"
@@ -210,10 +218,8 @@ export const NodeProfileCard = ({ paramId }: Props) => {
<Box>
<CountryFlag
countryCode={nodeInfo.description.auxiliary_details.location}
countryName={countryName(
nodeInfo.description.auxiliary_details.location,
)}
countryCode={nodeInfo.geoip?.country || ""}
countryName={countryName(nodeInfo.geoip?.country || "")}
/>
</Box>
</Box>
@@ -1,21 +1,21 @@
"use client";
import { Chip, Skeleton, Stack, Typography } from "@mui/material";
import { Chip, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import {
fetchEpochRewards,
fetchGatewayStatus,
fetchObservatoryNodes,
fetchNSApiNodes,
} from "../../app/api";
import type {
IObservatoryNode,
LastProbeResult,
NodeDescription,
NS_NODE,
} from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import StarRating from "../starRating/StarRating";
import { useEnvironment } from "@/providers/EnvironmentProvider";
type Props = {
paramId: string;
};
@@ -32,7 +32,7 @@ const roleMapping: Record<DeclaredRoleKey, RoleString> = {
};
const getNodeRoles = (
declaredRoles: NodeDescriptionNotNull["declared_role"],
declaredRoles: NodeDescriptionNotNull["declared_role"]
): RoleString[] => {
return Object.entries(declaredRoles)
.filter(([, isActive]) => isActive)
@@ -81,7 +81,7 @@ function calculateConfigScoreStars(probeResult: LastProbeResult): number {
if (as_entry) {
const entryScore = [as_entry.can_connect, as_entry.can_route].filter(
Boolean,
Boolean
).length;
return entryScore === 2 ? 4 : entryScore === 1 ? 2 : 1;
@@ -148,28 +148,31 @@ function calculateWireguardPerformance(probeResult: LastProbeResult): number {
}
export const NodeRoleCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
// Fetch node info
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
const {
data: epochRewardsData,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
queryKey: ["epochRewards", environment],
queryFn: () => fetchEpochRewards(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -177,30 +180,31 @@ export const NodeRoleCard = ({ paramId }: Props) => {
});
if (paramId.length > 10) {
nodeInfo = nymNodes?.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find((node) => node.identity_key === paramId);
} else {
nodeInfo = nymNodes?.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find((node) => node.node_id === Number(paramId));
} // Extract node roles once `nodeInfo` is available
const nodeRoles = nodeInfo
? getNodeRoles(nodeInfo.description.declared_role)
const nodeRoles = nodeInfo?.self_description
? getNodeRoles(nodeInfo.self_description.declared_role)
: [];
// Define whether to fetch gateway status
const shouldFetchGatewayStatus = nodeRoles.some((role) =>
["Entry Node", "Exit IPR Node", "Exit NR Node"].includes(role),
["Entry Node", "Exit IPR Node", "Exit NR Node"].includes(role)
);
// Fetch gateway status only if `shouldFetchGatewayStatus` is true
const { data: gatewayStatus } = useQuery({
queryKey: ["gatewayStatus", nodeInfo?.identity_key],
queryFn: () => fetchGatewayStatus(nodeInfo?.identity_key || ""),
queryFn: () =>
fetchGatewayStatus(nodeInfo?.identity_key || "", environment),
enabled: !!nodeInfo?.identity_key && shouldFetchGatewayStatus, // ✅ Only fetch if needed
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
});
if (isLoading || isEpochLoading) {
if (isNSApiNodesLoading || isEpochLoading) {
return (
<ExplorerCard label="Node role & performance">
<Skeleton variant="text" height={70} />
@@ -210,15 +214,19 @@ export const NodeRoleCard = ({ paramId }: Props) => {
);
}
if (isError || !nymNodes || !epochRewardsData || isEpochError) {
if (isNSApiNodesError || !nsApiNodes || !epochRewardsData || isEpochError) {
return (
<ExplorerCard label="Node role & performance">
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
if (!nodeInfo) return null;
const NodeRoles = nodeRoles.map((role) => (
<Stack key={role} direction="row" gap={1}>
@@ -226,8 +234,6 @@ export const NodeRoleCard = ({ paramId }: Props) => {
</Stack>
));
if (!nodeInfo) return null;
const qualityOfServiceStars = nodeInfo?.uptime
? calculateQualityOfServiceStars(nodeInfo.uptime)
: gatewayStatus
@@ -247,9 +253,10 @@ export const NodeRoleCard = ({ paramId }: Props) => {
// Function to calculate active set probability
const getActiveSetProbability = (
totalStake: number,
stakeSaturationPoint: string,
nodeTotalStake: string,
stakeSaturationPoint: string
): string => {
const totalStake = Number.parseFloat(nodeTotalStake);
const saturation = Number.parseFloat(stakeSaturationPoint);
if (Number.isNaN(saturation) || saturation <= 0) {
@@ -268,7 +275,7 @@ export const NodeRoleCard = ({ paramId }: Props) => {
};
const activeSetProb = getActiveSetProbability(
nodeInfo.total_stake,
epochRewardsData.interval.stake_saturation_point,
epochRewardsData.interval.stake_saturation_point
);
return (
@@ -1,5 +1,6 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import type { NS_NODE } from "@/app/api/types";
import { NYM_ACCOUNT_ADDRESS } from "@/app/api/urls";
import { Search } from "@mui/icons-material";
import {
Autocomplete,
@@ -12,29 +13,36 @@ import {
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { fetchObservatoryNodes } from "../../app/api";
import { NYM_ACCOUNT_ADDRESS } from "@/app/api/urls";
import { fetchNSApiNodes } from "../../app/api";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../envs/config";
const NodeAndAddressSearch = () => {
const router = useRouter();
const { environment } = useEnvironment();
const [inputValue, setInputValue] = useState("");
const [errorText, setErrorText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [searchOptions, setSearchOptions] = useState<IObservatoryNode[]>([]);
const [searchOptions, setSearchOptions] = useState<NS_NODE[]>([]);
// Use React Query to fetch nodes
const { data: nymNodes = [], isLoading: isLoadingNodes } = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
const { data: nsApiNodes = [], isLoading: isNSApiNodesLoading } = useQuery({
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
const handleSearch = async () => {
setErrorText(""); // Clear any previous error messages
setIsLoading(true); // Start loading
if (!inputValue.trim()) {
setErrorText("Please enter a search term");
return;
}
setIsLoading(true);
setErrorText("");
try {
if (inputValue.startsWith("n1")) {
@@ -45,7 +53,8 @@ const NodeAndAddressSearch = () => {
try {
const data = await response.json();
if (data) {
router.push(`/account/${inputValue}`);
const basePath = getBasePathByEnv(environment || "mainnet");
router.push(`${basePath}/account/${inputValue}`);
return;
}
} catch {
@@ -66,13 +75,14 @@ const NodeAndAddressSearch = () => {
}
} else {
// Check if it's a node identity key
if (nymNodes) {
const matchingNode = nymNodes.find(
(node) => node.identity_key === inputValue
if (nsApiNodes) {
const matchingNode = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === inputValue
);
if (matchingNode) {
router.push(`/nym-node/${matchingNode.identity_key}`);
const basePath = getBasePathByEnv(environment || "mainnet");
router.push(`${basePath}/nym-node/${matchingNode.identity_key}`);
return;
}
}
@@ -104,10 +114,8 @@ const NodeAndAddressSearch = () => {
// Filter nodes by moniker if input is not empty
if (value.trim() !== "") {
const filteredNodes = nymNodes.filter((node) =>
node.self_description?.moniker
?.toLowerCase()
.includes(value.toLowerCase())
const filteredNodes = nsApiNodes.filter((node: NS_NODE) =>
node.description.moniker?.toLowerCase().includes(value.toLowerCase())
);
setSearchOptions(filteredNodes);
} else {
@@ -118,11 +126,12 @@ const NodeAndAddressSearch = () => {
// Handle node selection from dropdown
const handleNodeSelect = (
event: React.SyntheticEvent,
value: string | IObservatoryNode | null
value: string | NS_NODE | null
) => {
if (value && typeof value !== "string") {
setIsLoading(true); // Show loading spinner
router.push(`/nym-node/${value.node_id}`);
const basePath = getBasePathByEnv(environment || "mainnet");
router.push(`${basePath}/nym-node/${value.node_id}`);
}
};
@@ -132,9 +141,9 @@ const NodeAndAddressSearch = () => {
<Autocomplete
freeSolo
options={searchOptions}
getOptionLabel={(option: string | IObservatoryNode) => {
getOptionLabel={(option: string | NS_NODE) => {
if (typeof option === "string") return option;
return option.self_description?.moniker || "";
return option.description.moniker || "";
}}
isOptionEqualToValue={(option, value) => {
if (typeof option === "string" || typeof value === "string")
@@ -146,10 +155,10 @@ const NodeAndAddressSearch = () => {
return (
<li
{...props}
key={`${option.node_id}-${option.self_description?.moniker || ""}`}
key={`${option.node_id}-${option.description.moniker || ""}`}
style={{ fontSize: "0.875rem" }}
>
{option.self_description?.moniker || "Unnamed Node"}
{option.description.moniker || "Unnamed Node"}
</li>
);
}}
@@ -182,7 +191,7 @@ const NodeAndAddressSearch = () => {
/>
)}
onChange={handleNodeSelect}
loading={isLoadingNodes}
loading={isNSApiNodesLoading}
loadingText="Loading nodes..."
noOptionsText="No nodes found"
slotProps={{
@@ -6,11 +6,13 @@ import { fetchOriginalStake } from "../../app/api";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { useEnvironment } from "@/providers/EnvironmentProvider";
const OriginalStakeCard = () => {
const { address } = useNymClient();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
// Use React Query to fetch original stake
const {
@@ -18,14 +20,15 @@ const OriginalStakeCard = () => {
isLoading,
isError,
} = useQuery({
queryKey: ["originalStake", address],
queryFn: () => fetchOriginalStake(address || ""),
queryKey: ["originalStake", address, environment],
queryFn: () => fetchOriginalStake(address || "", environment),
enabled: !!address, // Only fetch if address exists
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
});
if (!address) {
return null; // Do not render if address is not available
}
+113 -24
View File
@@ -13,7 +13,7 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalStorage } from "@uidotdev/usehooks";
import {
type MRT_ColumnDef,
@@ -37,11 +37,16 @@ import StakeActions from "./StakeActions";
import StakeModal from "./StakeModal";
import type { MappedNymNode, MappedNymNodes } from "./StakeTableWithAction";
import { fee } from "./schemas";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { getBasePathByEnv } from "../../../envs/config";
import { fetchStakerRewards } from "@/app/api";
import { IRewardDetails } from "@/app/api/types";
type DelegationWithNodeDetails = {
node: MappedNymNode | undefined;
delegation: Delegation;
pendingEvent?: PendingEvent;
stakerReward?: IRewardDetails;
};
const ColumnHeading = ({
@@ -82,7 +87,7 @@ const ColumnHeading = ({
const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const { nymClient, address, nymQueryClient } = useNymClient();
const [delegations, setDelegations] = useState<DelegationWithNodeDetails[]>(
[],
[]
);
const [isDataLoading, setIsLoading] = useState(false);
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
@@ -93,8 +98,11 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
identityKey: string;
}>();
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const { isWalletConnected } = useChain(chain);
const { data: pendingEvents } = usePendingEvents(nymQueryClient, address);
const basePath = getBasePathByEnv(environment || "mainnet");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
@@ -102,12 +110,27 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const router = useRouter();
const queryClient = useQueryClient();
// Use React Query to fetch total rewards
const {
data: stakerRewards = [],
isLoading: isStakerRewardsLoading,
isError: isStakerRewardsError,
} = useQuery({
queryKey: ["stakerRewards", address, environment],
queryFn: () => fetchStakerRewards(address || "", environment),
enabled: !!address, // Only fetch if address exists
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
});
const handleRefetch = useCallback(async () => {
await queryClient.invalidateQueries();
}, [queryClient]);
useEffect(() => {
if (!nymClient || !address || !nymQueryClient) return;
if (!nymClient || !address || !nymQueryClient || isStakerRewardsError)
return;
// Fetch staking data
const fetchDelegations = async () => {
@@ -122,18 +145,23 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
delegations: Delegation[],
nodes: MappedNymNode[],
pendingEvents: PendingEvent[] | undefined,
stakerRewards: IRewardDetails[] | undefined
) => {
// Combine delegations with node details
const delegationsWithNodeDetails = delegations.map((delegation) => {
const node = nodes.find((node) => node.nodeId === delegation.node_id);
const pendingEvent = pendingEvents?.find(
(event) => event?.mixId === delegation.node_id,
(event) => event?.mixId === delegation.node_id
);
const stakerReward = stakerRewards?.find(
(reward) => reward.node_id === delegation.node_id
);
return {
node,
delegation,
pendingEvent,
stakerReward,
};
});
@@ -145,7 +173,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
!delegationsWithNodeDetails.find(
(item) =>
item.node?.nodeId === e.mixId ||
item.delegation.node_id === e.mixId,
item.delegation.node_id === e.mixId
)
) {
delegationsWithNodeDetails.push({
@@ -170,6 +198,18 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
node_id: e.mixId,
owner: "-",
},
stakerReward: {
amount_staked: {
amount: e.amount?.amount || "0",
denom: "unym",
},
node_id: e.mixId,
node_still_fully_bonded: true,
rewards: {
amount: "0",
denom: "unym",
},
},
});
}
}
@@ -186,13 +226,22 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
delegations,
nodes,
pendingEvents,
stakerRewards
);
setDelegations(delegationsWithNodeDetails);
};
fetchAndMapDelegations();
}, [address, nodes, nymClient, nymQueryClient, pendingEvents]);
}, [
address,
nodes,
nymClient,
nymQueryClient,
pendingEvents,
stakerRewards,
environment,
]);
const handleStakeOnNode = useCallback(
async ({ nodeId, amount }: { nodeId: number; amount: string }) => {
@@ -206,7 +255,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
{ nodeId },
fee,
"Delegation from Nym Explorer V2",
uNymFunds,
uNymFunds
);
setSelectedNodeForStaking(undefined);
@@ -235,7 +284,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
}
setIsLoading(false);
},
[nymClient, handleRefetch],
[nymClient, handleRefetch]
);
const handleOnSelectStake = useCallback(
@@ -266,7 +315,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
});
}
},
[isWalletConnected],
[isWalletConnected]
);
const handleUnstake = useCallback(
@@ -281,7 +330,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
nodeId,
},
fee,
`Explorer V2: Unstaking node ${nodeId}`,
`Explorer V2: Unstaking node ${nodeId}`
);
setIsLoading(false);
await handleRefetch();
@@ -306,7 +355,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
setIsLoading(false);
}
},
[address, nymClient, handleRefetch],
[address, nymClient, handleRefetch]
);
const handleActionSelect = useCallback(
@@ -322,7 +371,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
break;
}
},
[handleUnstake, handleOnSelectStake],
[handleUnstake, handleOnSelectStake]
);
const getTooltipTitle = useCallback(
@@ -333,13 +382,13 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
if (pending?.kind === "delegate") {
return `You have a delegation pending worth ${formatBigNum(
+pending.amount.amount / 1_000_000,
+pending.amount.amount / 1_000_000
)} NYM`;
}
return undefined;
},
[], // Add dependencies if necessary
[] // Add dependencies if necessary
);
const columns: MRT_ColumnDef<DelegationWithNodeDetails>[] = useMemo(
@@ -349,10 +398,20 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
header: "",
Header: <ColumnHeading>Name</ColumnHeading>,
accessorKey: "node.name",
size: 220,
Cell: ({ row }) =>
row.original.node?.name ? (
<Stack spacing={1}>
<Stack
spacing={1.5}
direction="row"
alignItems="center"
justifyContent="flex-start"
>
<Typography variant="body4">{row.original.node.name}</Typography>
{!row.original.stakerReward?.node_still_fully_bonded && (
<Chip size="small" label="Not bonded" />
)}
</Stack>
) : (
"-"
@@ -418,10 +477,10 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
sortingFn: (rowA, rowB) => {
const stakeA = Number.parseFloat(
rowA.original.delegation.amount.amount,
rowA.original.delegation.amount.amount
);
const stakeB = Number.parseFloat(
rowB.original.delegation.amount.amount,
rowB.original.delegation.amount.amount
);
return stakeA - stakeB;
},
@@ -452,6 +511,34 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
<Typography variant="body4">{0}%</Typography>
),
},
{
id: "rewards",
header: "Rewards",
accessorKey: "stakerReward.rewards.amount",
Header: <ColumnHeading>Rewards</ColumnHeading>,
size: 80,
sortingFn: (rowA, rowB) => {
const stakeA = Number.parseFloat(
rowA.original.stakerReward?.rewards.amount || "0"
);
const stakeB = Number.parseFloat(
rowB.original.stakerReward?.rewards.amount || "0"
);
return stakeA - stakeB;
},
Cell: ({ row }) =>
isStakerRewardsLoading ? (
<Typography variant="body4">Loading rewards</Typography>
) : (
<Typography variant="body4">
{formatBigNum(
Number(row.original.stakerReward?.rewards?.amount) / 1_000_000
) || "0"}{" "}
NYM
</Typography>
),
},
{
id: "Favorite",
header: "Favorite",
@@ -466,10 +553,10 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
),
sortingFn: (rowA, rowB) => {
const isFavoriteA = favorites.includes(
rowA.original.node?.owner || "-",
rowA.original.node?.owner || "-"
);
const isFavoriteB = favorites.includes(
rowB.original.node?.owner || "-",
rowB.original.node?.owner || "-"
);
// Sort favorites first
@@ -509,7 +596,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
handleActionSelect(
action,
row.original.delegation?.node_id,
row.original.node?.identity_key || undefined,
row.original.node?.identity_key || undefined
);
}}
/>
@@ -519,7 +606,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
},
],
[handleActionSelect, favorites, getTooltipTitle],
[handleActionSelect, favorites, getTooltipTitle, isStakerRewardsLoading]
);
const table = useMaterialReactTable({
@@ -548,7 +635,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
size="large"
onClick={(e) => e.stopPropagation()}
>
<Link href="/table" underline="none" color="inherit">
<Link href="/servers" underline="none" color="inherit">
Stake
</Link>
</Button>
@@ -651,7 +738,9 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/nym-node/${row.original.node?.nodeId || "not-found"}`);
router.push(
`${basePath}/nym-node/${row.original.node?.nodeId || "not-found"}`
);
},
hover: true,
sx: {
@@ -3,15 +3,15 @@
import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import type { ExplorerData, IObservatoryNode } from "../../app/api/types";
import { fetchEpochRewards, fetchNSApiNodes } from "../../app/api";
import type { ExplorerData, NS_NODE } from "../../app/api/types";
import { countryName } from "../../utils/countryName";
import StakeTable from "./StakeTable";
import { useEnvironment } from "@/providers/EnvironmentProvider";
// Utility function to calculate node saturation point
function getNodeSaturationPoint(
totalStake: number,
stakeSaturationPoint: string,
stakeSaturationPoint: string
): number {
const saturation = Number.parseFloat(stakeSaturationPoint);
@@ -24,46 +24,58 @@ function getNodeSaturationPoint(
}
// Map nodes with rewards data
const mappedNymNodes = (
nodes: IObservatoryNode[],
epochRewardsData: ExplorerData["currentEpochRewardsData"],
const mappedNSApiNodes = (
nodes: NS_NODE[],
epochRewardsData: ExplorerData["currentEpochRewardsData"]
) =>
nodes.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
nodes
.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
+node.total_stake,
epochRewardsData.interval.stake_saturation_point
);
const cleanMoniker = DOMPurify.sanitize(
node.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanMoniker = DOMPurify.sanitize(node.description.moniker).replace(
/&amp;/g,
"&"
);
return {
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.description.auxiliary_details.location || null,
countryName:
countryName(node.description.auxiliary_details.location) || null,
profitMarginPercentage:
+node.rewarding_details.cost_params.profit_margin_percent * 100,
owner: node.bonding_address,
stakeSaturation: +nodeSaturationPoint || 0,
};
});
return {
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.geoip?.country || null,
countryName: countryName(node.geoip?.country || null) || null,
profitMarginPercentage: node.rewarding_details
? +node.rewarding_details.cost_params.profit_margin_percent * 100
: 0,
owner: node.bonding_address,
stakeSaturation: nodeSaturationPoint,
};
})
.sort((a, b) => {
// Handle null country names by putting them at the end
if (!a.countryName && !b.countryName) return 0;
if (!a.countryName) return 1;
if (!b.countryName) return -1;
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
// Sort alphabetically by country name
return a.countryName.localeCompare(b.countryName);
});
export type MappedNymNodes = ReturnType<typeof mappedNSApiNodes>;
export type MappedNymNode = MappedNymNodes[0];
const StakeTableWithAction = () => {
const { environment } = useEnvironment();
// Use React Query to fetch epoch rewards
const {
data: epochRewardsData,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
queryKey: ["epochRewards", environment],
queryFn: () => fetchEpochRewards(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -72,12 +84,12 @@ const StakeTableWithAction = () => {
// Use React Query to fetch Nym nodes
const {
data: nymNodes = [],
isLoading: isNodesLoading,
isError: isNodesError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes", environment],
queryFn: () => fetchNSApiNodes(environment),
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -85,7 +97,7 @@ const StakeTableWithAction = () => {
});
// Handle loading state
if (isEpochLoading || isNodesLoading) {
if (isEpochLoading || isNSApiNodesLoading) {
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
@@ -99,7 +111,7 @@ const StakeTableWithAction = () => {
}
// Handle error state
if (isEpochError || isNodesError) {
if (isEpochError || isNSApiNodesError) {
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
@@ -115,9 +127,9 @@ const StakeTableWithAction = () => {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
const nsApiNodesData = mappedNSApiNodes(nsApiNodes || [], epochRewardsData);
return <StakeTable nodes={data} />;
return <StakeTable nodes={nsApiNodesData} />;
};
export default StakeTableWithAction;
@@ -7,23 +7,19 @@ import { Button, Stack } from "@mui/material";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { fetchTotalStakerRewards } from "../../app/api";
import type { NodeRewardDetails } from "../../app/api/types";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "../../config";
import { fetchDelegations, fetchTotalStakerRewards } from "../../app/api";
import {
COSMOS_KIT_USE_CHAIN,
NYM_MIXNET_CONTRACT,
SANDBOX_MIXNET_CONTRACT_ADDRESS,
} from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import Loading from "../loading";
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import RedeemRewardsModal from "../redeemRewards/RedeemRewardsModal";
import { useEnvironment } from "@/providers/EnvironmentProvider";
import { SANDBOX_VALIDATOR_BASE_URL, VALIDATOR_BASE_URL } from "@/app/api/urls";
// Fetch delegations
const fetchDelegations = async (
address: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
nymClient: any,
): Promise<Delegation[]> => {
const data = await nymClient.getDelegatorDelegations({ delegator: address });
return data.delegations;
};
const SubHeaderRowActions = () => {
const [openRedeemRewardsModal, setOpenRedeemRewardsModal] =
@@ -32,9 +28,18 @@ const SubHeaderRowActions = () => {
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
});
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const mixnetContractAddress =
environment === "mainnet"
? NYM_MIXNET_CONTRACT
: SANDBOX_MIXNET_CONTRACT_ADDRESS;
const rpcAddress =
environment === "mainnet" ? VALIDATOR_BASE_URL : SANDBOX_VALIDATOR_BASE_URL;
const { address, nymClient } = useNymClient();
const { getOfflineSigner } = useChain(COSMOS_KIT_USE_CHAIN);
const { getOfflineSigner } = useChain(chain);
const queryClient = useQueryClient();
@@ -44,30 +49,31 @@ const SubHeaderRowActions = () => {
isLoading: isDelegationsLoading,
isError: isDelegationsError,
} = useQuery({
queryKey: ["delegations", address],
queryKey: ["delegations", address, environment],
queryFn: () => fetchDelegations(address || "", nymClient),
enabled: !!address && !!nymClient, // Only fetch if address and nymClient are available
enabled: !!address && !!nymClient, // Only fetch if address and nymClient exist
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
});
// Fetch total rewards using React Query
const {
data: totalStakerRewards = 0,
isLoading: isRewardsLoading,
isError: isRewardsError,
refetch,
isLoading: isTotalStakerRewardsLoading,
isError: isTotalStakerRewardsError,
} = useQuery({
queryKey: ["totalStakerRewards", address],
queryFn: () => fetchTotalStakerRewards(address || ""),
enabled: !!address, // Only fetch if address is available
queryKey: ["totalStakerRewards", address, environment],
queryFn: () => fetchTotalStakerRewards(address || "", environment),
enabled: !!address, // Only fetch if address exists
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
});
const handleRefetch = useCallback(async () => {
refetch();
queryClient.invalidateQueries(); // This will refetch ALL active queries
}, [queryClient, refetch]);
await queryClient.invalidateQueries(); // This will refetch ALL active queries
}, [queryClient]);
const handleRedeemRewards = useCallback(async () => {
setIsLoading(true);
@@ -82,13 +88,13 @@ const SubHeaderRowActions = () => {
const gasPrice = GasPrice.fromString("0.025unym");
const client = await SigningCosmWasmClient.connectWithSigner(
"https://rpc.nymtech.net/",
rpcAddress,
signer,
{ gasPrice },
{ gasPrice }
);
const messages = delegations.map((delegation: NodeRewardDetails) => ({
contractAddress: NYM_MIXNET_CONTRACT,
const messages = delegations.map((delegation: Delegation) => ({
contractAddress: mixnetContractAddress,
funds: [],
msg: {
withdraw_delegator_reward: {
@@ -101,7 +107,7 @@ const SubHeaderRowActions = () => {
address,
messages,
"auto",
"Redeeming all rewards",
"Redeeming all rewards"
);
// Success state
setIsLoading(false);
@@ -112,8 +118,11 @@ const SubHeaderRowActions = () => {
tx: result?.transactionHash,
onClose: async () => {
await handleRefetch();
setInfoModalProps({ open: false });
try {
await handleRefetch();
} finally {
setInfoModalProps({ open: false });
}
},
});
} catch (error) {
@@ -128,7 +137,15 @@ const SubHeaderRowActions = () => {
} finally {
setIsLoading(false);
}
}, [address, nymClient, delegations, handleRefetch, getOfflineSigner]);
}, [
address,
nymClient,
delegations,
handleRefetch,
getOfflineSigner,
mixnetContractAddress,
rpcAddress,
]);
const handleRedeemRewardsButtonClick = () => {
setOpenRedeemRewardsModal(true);
@@ -138,11 +155,11 @@ const SubHeaderRowActions = () => {
return null;
}
if (isDelegationsLoading || isRewardsLoading) {
if (isDelegationsLoading || isTotalStakerRewardsLoading) {
return <Loading />;
}
if (isDelegationsError || isRewardsError) {
if (isDelegationsError || isTotalStakerRewardsError) {
return (
<Stack direction="row" spacing={3} justifyContent={"end"}>
<Button variant="contained" disabled>
@@ -152,12 +169,13 @@ const SubHeaderRowActions = () => {
);
}
return (
<Stack direction="row" spacing={3} justifyContent={"end"}>
<Button
variant="contained"
onClick={handleRedeemRewardsButtonClick}
disabled={totalStakerRewards === 0}
disabled={totalStakerRewards / 1_000_000 < 0.1}
>
Redeem NYM
</Button>
@@ -6,11 +6,13 @@ import { fetchTotalStakerRewards } from "../../app/api";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { useEnvironment } from "@/providers/EnvironmentProvider";
const TotalRewardsCard = () => {
const { address } = useNymClient();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
// Use React Query to fetch total rewards
const {
@@ -18,8 +20,8 @@ const TotalRewardsCard = () => {
isLoading,
isError,
} = useQuery({
queryKey: ["totalStakerRewards", address],
queryFn: () => fetchTotalStakerRewards(address || ""),
queryKey: ["totalStakerRewards", address, environment],
queryFn: () => fetchTotalStakerRewards(address || "", environment),
enabled: !!address, // Only fetch if address exists
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
@@ -6,11 +6,13 @@ import { fetchBalances } from "../../app/api";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { useEnvironment } from "@/providers/EnvironmentProvider";
const TotalStakeCard = () => {
const { address } = useNymClient();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { environment } = useEnvironment();
// Use React Query to fetch total stake
const {
@@ -18,8 +20,8 @@ const TotalStakeCard = () => {
isLoading,
isError,
} = useQuery({
queryKey: ["totalStake", address],
queryFn: () => fetchBalances(address || ""),
queryKey: ["totalStake", address, environment],
queryFn: () => fetchBalances(address || "", environment),
enabled: !!address, // Only fetch if address exists
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
@@ -51,6 +53,7 @@ const TotalStakeCard = () => {
);
}
return (
<ExplorerCard label="Total Stake">
<Typography
@@ -0,0 +1,90 @@
"use client";
import { Button, ButtonGroup, Stack } from "@mui/material";
export type Option = {
label: string;
isSelected: boolean;
value: "all" | "mixnodes" | "gateways" | "recommended";
};
type Options = Option[];
const NodeFilterButtonGroup = ({
size = "small",
options,
onPage,
onFilterChange,
}: {
size?: "small" | "medium" | "large";
options: Options;
onPage: string;
onFilterChange: (
filter: "all" | "mixnodes" | "gateways" | "recommended"
) => void;
}) => {
const handleClick = (
value: "all" | "mixnodes" | "gateways" | "recommended"
) => {
if (onPage === value) return;
onFilterChange(value);
};
const getMobileButtonStyles = (isSelected: boolean) => ({
color: isSelected ? "primary.contrastText" : "text.primary",
"&:hover": {
bgcolor: isSelected ? "primary.main" : "",
},
bgcolor: isSelected ? "primary.main" : "transparent",
width: "100%",
py: 1.5,
px: 2,
});
const getDesktopButtonStyles = (isSelected: boolean) => ({
color: isSelected ? "primary.contrastText" : "text.primary",
"&:hover": {
bgcolor: isSelected ? "primary.main" : "",
},
bgcolor: isSelected ? "primary.main" : "transparent",
});
return (
<>
{/* Mobile view - Stack */}
<Stack
spacing={1.5}
sx={{
display: { xs: "flex", sm: "none" },
width: "100%",
}}
>
{options.map((option) => (
<Button
key={option.label}
onClick={() => handleClick(option.value)}
sx={getMobileButtonStyles(option.isSelected)}
variant="outlined"
>
{option.label}
</Button>
))}
</Stack>
{/* Desktop view - ButtonGroup */}
<ButtonGroup size={size} sx={{ display: { xs: "none", sm: "flex" } }}>
{options.map((option) => (
<Button
key={option.label}
onClick={() => handleClick(option.value)}
sx={getDesktopButtonStyles(option.isSelected)}
variant="outlined"
>
{option.label}
</Button>
))}
</ButtonGroup>
</>
);
};
export default NodeFilterButtonGroup;
@@ -14,15 +14,16 @@ import Cross from "../icons/Cross";
import CrossDark from "../icons/CrossDark";
import { WalletAddress } from "./WalletAddress";
import { WalletBalance } from "./WalletBalance";
import { useEnvironment } from "@/providers/EnvironmentProvider";
interface ButtonPropsWithOnClick extends ButtonProps {
hideAddressAndBalance?: boolean;
onClick?: () => void;
}
const ConnectWallet = ({ ...buttonProps }: ButtonPropsWithOnClick) => {
const { connect, disconnect, address, isWalletConnected } =
useChain(COSMOS_KIT_USE_CHAIN);
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const { connect, disconnect, address, isWalletConnected } = useChain(chain);
const theme = useTheme();
const handleConnectWallet = async () => {
@@ -6,9 +6,11 @@ import React from "react";
import { Token } from "../../components/icons/Token";
import { TokenDark } from "../../components/icons/TokenDark";
import useGetWalletBalance from "../../hooks/useGetWalletBalance";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const WalletBalance = () => {
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const { isWalletConnected } = useChain(chain);
const { formattedBalance, isLoading, isError, refetch } =
useGetWalletBalance();
const theme = useTheme();
@@ -0,0 +1,407 @@
"use client";
import { scaleLinear } from "d3-scale";
import * as React from "react";
import Image from "next/image";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import { Tooltip } from "react-tooltip";
import { fetchWorldMapCountries } from "@/app/api";
import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { IconButton, Skeleton, Typography, Box } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useQuery } from "@tanstack/react-query";
import type { CountryDataResponse } from "../../app/api/types";
import MAP_TOPOJSON from "../../assets/world-110m.json";
import ExplorerCard from "../cards/ExplorerCard";
import { useEnvironment } from "@/providers/EnvironmentProvider";
const mapPlaceholderDark = "/explorer/map-placeholder-dark.png";
const mapPlaceholderLight = "/explorer/map-placeholder-light.png";
export const WorldMap = (): JSX.Element => {
const { environment } = useEnvironment();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const [position, setPosition] = React.useState<{
coordinates: [number, number];
zoom: number;
}>({ coordinates: [0, 0], zoom: 1 });
const {
data: {
countries = [],
totalCountries = 0,
uniqueLocations = 0,
totalServers = 0,
} = {
countries: [],
totalCountries: 0,
uniqueLocations: 0,
totalServers: 0,
},
isLoading: isLoadingCountries,
isError: isCountriesError,
} = useQuery({
queryKey: ["nymNodesCountries", environment],
queryFn: () => fetchWorldMapCountries(environment),
staleTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const [tooltipContent, setTooltipContent] = React.useState<string>("");
React.useEffect(() => {
const handleMouseLeave = () => setTooltipContent("");
return () => {
handleMouseLeave();
};
}, []);
const colorScale = React.useMemo(() => {
if (countries) {
const heighestNumberOfNodes = Math.max(
...Object.values(countries).map((country) => country.nodes)
);
return scaleLinear<string, string>()
.domain([
0,
1,
heighestNumberOfNodes / 4,
heighestNumberOfNodes / 2,
heighestNumberOfNodes,
])
.range(
isDarkMode
? [
theme.palette.pine[950],
"#0F5A2E", // Dark green
"#147A3D", // Medium green
"#1A994C", // Light green
theme.palette.accent.main,
]
: [
theme.palette.pine[300],
"#0F5A2E", // Dark green
"#147A3D", // Medium green
"#1A994C", // Light green
theme.palette.accent.main,
]
)
.unknown(isDarkMode ? theme.palette.pine[950] : theme.palette.pine[25]);
}
return () =>
isDarkMode ? theme.palette.pine[950] : theme.palette.pine[25];
}, [countries, theme.palette.pine, theme.palette.accent, isDarkMode]);
if (isLoadingCountries) {
return (
<ExplorerCard label="Nym server locations" sx={{ width: "100%" }}>
<div
style={{
position: "relative",
width: "100%",
aspectRatio: "16/7",
}}
>
<Image
src={isDarkMode ? mapPlaceholderDark : mapPlaceholderLight}
alt="World Map Placeholder"
fill
style={{ objectFit: "contain" }}
priority
/>
</div>
</ExplorerCard>
);
}
if (isCountriesError) {
return (
<ExplorerCard label="Nym server locations" sx={{ width: "100%" }}>
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={500} />
</ExplorerCard>
);
}
return (
<>
<ExplorerCard
label="Nym server locations"
sx={{
width: "100%",
position: "relative",
p: { xs: 2, sm: 3 },
"& > .MuiCardContent-root": {
height: {
xs: "200px",
sm: "auto",
},
aspectRatio: {
xs: "unset",
sm: "16/7",
},
},
}}
>
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
margin: "0 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ComposableMap
data-tip=""
style={{
backgroundColor: isDarkMode ? "#000000" : theme.palette.pine[25],
width: "100%",
height: "100%",
}}
viewBox="0 0 800 400"
projection="geoMercator"
projectionConfig={{
scale: 130,
}}
>
<ZoomableGroup
center={position.coordinates}
zoom={position.zoom}
minZoom={1}
maxZoom={8}
translateExtent={[
[-800, -400],
[800, 400],
]}
onMoveEnd={({
coordinates,
zoom,
}: {
coordinates: [number, number];
zoom: number;
}) => {
setPosition({ coordinates, zoom });
}}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Geographies geography={MAP_TOPOJSON}>
{({ geographies }: { geographies: GeoJSON.Feature[] }) =>
geographies.map((geo) => {
const d = Array.isArray(countries)
? { nodes: 0 }
: (countries as CountryDataResponse)[
geo.properties?.ISO_A3 as string
] || { nodes: 0 };
return (
<Geography
key={`${geo.properties?.ISO_A3 || ""}-${geo.id}-${
geo.properties?.NAME_LONG || ""
}`}
geography={geo}
fill={colorScale(d?.nodes || 0)}
stroke={
theme.palette.mode === "dark"
? theme.palette.pine[800]
: theme.palette.pine[200]
}
strokeWidth={0.2}
data-tooltip-id="map-tooltip"
onMouseEnter={() => {
const { NAME_LONG } = geo.properties as {
NAME_LONG: string;
};
setTooltipContent(`${NAME_LONG} | ${d?.nodes || 0}`);
}}
onMouseLeave={() => {
setTooltipContent("");
}}
style={{
hover: countries
? {
fill: theme.palette.accent.main,
outline: "white",
cursor: "pointer",
}
: undefined,
default: {
outline: "none",
},
pressed: {
outline: "none",
},
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
<div
style={{
position: "absolute",
top: 10,
left: 10,
zIndex: 1000,
display: "flex",
flexDirection: "column",
gap: "4px",
backgroundColor: isDarkMode
? "rgba(0,0,0,0.5)"
: "rgba(255,255,255,0.5)",
padding: "4px",
borderRadius: "4px",
}}
>
<IconButton
size="small"
onClick={() =>
setPosition((prev) => ({
...prev,
zoom: Math.min(prev.zoom + 0.5, 8),
}))
}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<AddIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() =>
setPosition((prev) => ({
...prev,
zoom: Math.max(prev.zoom - 0.5, 1),
}))
}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<RemoveIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => setPosition({ coordinates: [0, 0], zoom: 1 })}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<RestartAltIcon fontSize="small" />
</IconButton>
</div>
</Box>
<Tooltip
id="map-tooltip"
content={tooltipContent}
float={true}
style={{
fontSize: "12px",
padding: "4px 8px",
backgroundColor:
theme.palette.mode === "dark"
? theme.palette.pine[800]
: theme.palette.pine[200],
color:
theme.palette.mode === "dark"
? theme.palette.base.white
: theme.palette.pine[950],
borderRadius: "4px",
zIndex: 9999,
}}
/>
</ExplorerCard>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(3, 1fr)",
},
gap: 2,
mt: 2,
}}
>
<ExplorerCard label="Nym servers around the world">
<Typography
variant="h4"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
{totalServers}
</Typography>
</ExplorerCard>
<ExplorerCard label="Countries with Nym servers">
<Typography
variant="h4"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
{totalCountries}
</Typography>
</ExplorerCard>
<ExplorerCard label="Nym Server Locations">
<Typography
variant="h4"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
{uniqueLocations}
</Typography>
</ExplorerCard>
</Box>
</>
);
};
+5 -1
View File
@@ -1,6 +1,10 @@
export const COSMOS_KIT_USE_CHAIN =
process.env.NEXT_PUBLIC_COSMOS_KIT_USE_CHAIN || "nyx";
process.env.NEXT_PUBLIC_COSMOS_KIT_USE_CHAIN || "sandbox";
export const NYM_MIXNET_CONTRACT =
process.env.NYM_MIXNET_CONTRACT ||
"n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr";
export const SANDBOX_MIXNET_CONTRACT_ADDRESS =
"n1xr3rq8yvd7qplsw5yx90ftsr2zdhg4e9z60h5duusgxpv72hud3sjkxkav";
@@ -11,6 +11,7 @@
"iconLight": "explorerCard",
"iconDark": "explorerCardDark",
"image": "/explorer/images/Network.webp",
"link": "https://nym.com/blog/welcome-to-explorer",
"overview": {
"content": [
{
+1
View File
@@ -11,6 +11,7 @@
"iconLight": "stakeCard",
"iconDark": "stakeCardDark",
"image": "/explorer/images/stake-article.webp",
"link": "https://nym.com/blog/stake-Nym-tokens",
"overview": {
"content": [
{
@@ -1,10 +1,8 @@
import type { PendingEpochEventKind } from "@nymproject/contract-clients/Mixnet.types";
import { useQuery } from "@tanstack/react-query";
import { useEnvironment } from "../providers/EnvironmentProvider";
export const getEventsByAddress = (
kind: PendingEpochEventKind,
address: string,
) => {
export const getEventsByAddress = (kind: PendingEpochEventKind, address: string) => {
if ("delegate" in kind && kind.delegate.owner === address) {
return {
kind: "delegate" as const,
@@ -27,8 +25,10 @@ export type PendingEvent = ReturnType<typeof getEventsByAddress>;
// Custom Hook for fetching pending events
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const usePendingEvents = (nymQueryClient: any, address: string | undefined) => {
const { environment } = useEnvironment();
return useQuery({
queryKey: ["pendingEvents", address], // Query key to uniquely identify this query
queryKey: ["pendingEvents", address, environment], // Query key to uniquely identify this query
queryFn: async () => {
if (!nymQueryClient || !address) {
throw new Error("Missing required dependencies");
@@ -47,6 +47,9 @@ const usePendingEvents = (nymQueryClient: any, address: string | undefined) => {
return pendingEvents;
},
enabled: !!nymQueryClient && !!address, // Prevents execution if dependencies are missing
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
});
};
@@ -2,6 +2,7 @@ import { useChain } from "@cosmos-kit/react";
import { useQuery } from "@tanstack/react-query";
import { COSMOS_KIT_USE_CHAIN } from "../config";
import { unymToNym } from "../utils/currency";
import { useEnvironment } from "@/providers/EnvironmentProvider";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchNYMBalance = async (address: string, getCosmWasmClient: any) => {
@@ -17,7 +18,10 @@ const fetchNYMBalance = async (address: string, getCosmWasmClient: any) => {
};
const useGetWalletBalance = () => {
const { getCosmWasmClient, address } = useChain(COSMOS_KIT_USE_CHAIN);
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const { getCosmWasmClient, address } = useChain(chain);
const {
data = { NYMBalance: "0", formattedBalance: "-" },
@@ -25,7 +29,7 @@ const useGetWalletBalance = () => {
isError,
refetch,
} = useQuery({
queryKey: ["nymBalance", address],
queryKey: ["nymBalance", address, environment],
queryFn: () => fetchNYMBalance(address || "", getCosmWasmClient),
enabled: !!address, // Only fetch if address exists
});
+22 -5
View File
@@ -7,14 +7,25 @@ import type {
MixnetQueryClient,
} from "@nymproject/contract-clients/Mixnet.client";
import { useEffect, useState } from "react";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "../config";
import {
COSMOS_KIT_USE_CHAIN,
NYM_MIXNET_CONTRACT,
SANDBOX_MIXNET_CONTRACT_ADDRESS,
} from "../config";
import { useEnvironment } from "@/providers/EnvironmentProvider";
export const useNymClient = () => {
const [nymClient, setNymClient] = useState<MixnetClient>();
const [nymQueryClient, setNymQueryClient] = useState<MixnetQueryClient>();
const { environment } = useEnvironment();
const chain = environment === "mainnet" ? COSMOS_KIT_USE_CHAIN : "sandbox";
const mixnetContractAddress =
environment === "mainnet"
? NYM_MIXNET_CONTRACT
: SANDBOX_MIXNET_CONTRACT_ADDRESS;
const { address, getCosmWasmClient, getSigningCosmWasmClient } =
useChain(COSMOS_KIT_USE_CHAIN);
useChain(chain);
useEffect(() => {
if (address) {
@@ -26,13 +37,13 @@ export const useNymClient = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cosmWasmSigningClient as any,
address,
NYM_MIXNET_CONTRACT,
mixnetContractAddress
);
const queryClient = new contracts.Mixnet.MixnetQueryClient(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cosmWasmClient as any,
NYM_MIXNET_CONTRACT,
mixnetContractAddress
);
setNymClient(client);
@@ -41,7 +52,13 @@ export const useNymClient = () => {
init();
}
}, [address, getCosmWasmClient, getSigningCosmWasmClient]);
}, [
address,
getCosmWasmClient,
getSigningCosmWasmClient,
mixnetContractAddress,
environment,
]);
return { nymClient, nymQueryClient, address };
};
@@ -0,0 +1,49 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { usePathname } from "next/navigation";
export type Environment = "mainnet" | "sandbox";
interface EnvironmentContextType {
environment: Environment;
setEnvironment: (env: Environment) => void;
}
const EnvironmentContext = createContext<EnvironmentContextType | undefined>(
undefined
);
export const EnvironmentProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [environment, setEnvironment] = useState<Environment>("mainnet");
const pathname = usePathname();
// Initialize environment from URL path
useEffect(() => {
if (pathname.startsWith("/sandbox-explorer")) {
setEnvironment("sandbox");
} else if (pathname.startsWith("/explorer")) {
setEnvironment("mainnet");
} else {
// Default to mainnet for other paths
setEnvironment("mainnet");
}
}, [pathname]);
return (
<EnvironmentContext.Provider value={{ environment, setEnvironment }}>
{children}
</EnvironmentContext.Provider>
);
};
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (context === undefined) {
throw new Error(
"useEnvironment must be used within an EnvironmentProvider"
);
}
return context;
};
+6 -5
View File
@@ -4,7 +4,7 @@ import { fetchCurrentEpoch } from "@/app/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { differenceInMilliseconds } from "date-fns";
import { createContext, useContext, useEffect, useState } from "react";
import { useEnvironment } from "./EnvironmentProvider";
type EpochStatus = "active" | "pending";
export type EpochResponseData =
@@ -45,14 +45,15 @@ const useEpochContext = () => {
};
const EpochProvider = ({ children }: { children: React.ReactNode }) => {
const { environment } = useEnvironment();
const [epochStatus, setEpochStatus] = useState<EpochStatus>("pending");
const QueryClient = useQueryClient();
const { data, isError, isLoading } = useQuery({
refetchOnWindowFocus: true,
queryKey: ["currentEpoch"],
queryFn: fetchCurrentEpoch,
queryKey: ["currentEpoch", environment],
queryFn: () => fetchCurrentEpoch(environment),
refetchInterval: ({ state }) => {
// refetchInterval can be set dynamically based on the current state
@@ -62,7 +63,7 @@ const EpochProvider = ({ children }: { children: React.ReactNode }) => {
}
const isEpochTimeValid = checkIsEpochTimeValid(
state.data.current_epoch_end.toString(),
state.data.current_epoch_end.toString()
);
// if epoch time is not valid (i.e current_time > epoch_start_time) refetch in 30 secs
@@ -73,7 +74,7 @@ const EpochProvider = ({ children }: { children: React.ReactNode }) => {
// if epoch time is valid, refetch based on the epoch end time
const newRefetchInterval = calculateRefetchInterval(
state.data.current_epoch_end.toString(),
state.data.current_epoch_end.toString()
);
setEpochStatus("active");
+10 -7
View File
@@ -1,17 +1,20 @@
import CosmosKitProvider from "./CosmosKitProvider";
import { EnvironmentProvider } from "./EnvironmentProvider";
import { EpochProvider } from "./EpochProvider";
import { QueryProvider } from "./QueryProvider";
import ThemeProvider from "./ThemeProvider";
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<ThemeProvider>
<QueryProvider>
<EpochProvider>
<CosmosKitProvider>{children}</CosmosKitProvider>
</EpochProvider>
</QueryProvider>
</ThemeProvider>
<EnvironmentProvider>
<ThemeProvider>
<QueryProvider>
<EpochProvider>
<CosmosKitProvider>{children}</CosmosKitProvider>
</EpochProvider>
</QueryProvider>
</ThemeProvider>
</EnvironmentProvider>
);
};
+5
View File
@@ -0,0 +1,5 @@
declare module "*.json" {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content: any;
export default content;
}
+1
View File
@@ -0,0 +1 @@
declare module "react-simple-maps";
+1
View File
@@ -0,0 +1 @@
declare module 'react-tooltip';
+14 -11
View File
@@ -9,6 +9,7 @@ import CosmosKitProvider from './context/cosmos-kit';
import '@interchain-ui/react/styles';
import { App } from './App';
import { WalletProvider } from './context/wallet';
import { EnvironmentProvider } from './providers/EnvironmentProvider';
import './styles.css';
const elem = document.getElementById('app');
@@ -17,17 +18,19 @@ if (elem) {
const root = createRoot(elem);
root.render(
<ErrorBoundary FallbackComponent={ErrorBoundaryContent}>
<MainContextProvider>
<CosmosKitProvider>
<WalletProvider>
<NetworkExplorerThemeProvider>
<Router>
<App />
</Router>
</NetworkExplorerThemeProvider>
</WalletProvider>
</CosmosKitProvider>
</MainContextProvider>
<EnvironmentProvider>
<MainContextProvider>
<CosmosKitProvider>
<WalletProvider>
<NetworkExplorerThemeProvider>
<Router>
<App />
</Router>
</NetworkExplorerThemeProvider>
</WalletProvider>
</CosmosKitProvider>
</MainContextProvider>
</EnvironmentProvider>
</ErrorBoundary>,
);
}
+1623 -1465
View File
File diff suppressed because it is too large Load Diff