Explorer V2 (#5548)

* remove pnpm lock file (should only be using yarn)

* Add lefthook configuration for pre-commit checks

* Add explorer-v2 to package.json dependencies

* add explorer v2

* update explorer v2 package name

* + basepath
+ redirect to basepath
+ blog icons refactor
+ icons refactor

* Add Getting Started instructions to README

* fix noise graph bug and line graph UI

* Delete unused translations, clean up console logs

* / test image url

* update yarn.lock

---------

Co-authored-by: RadekSabacky <radek@nymtech.net>
Co-authored-by: windy-ux <75579979+windy-ux@users.noreply.github.com>
Co-authored-by: Yana <iana.matrosova@gmail.com>
Co-authored-by: Mark Sinclair <mmsinclair@users.noreply.github.com>
This commit is contained in:
Fouad
2025-03-13 11:31:59 +00:00
committed by GitHub
parent 79ce611d21
commit dc88650d6d
219 changed files with 62763 additions and 5362 deletions
@@ -0,0 +1,396 @@
"use client";
import { useChain } from "@cosmos-kit/react";
import {
Box,
Button,
Stack,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useQueryClient } from "@tanstack/react-query";
import { useLocalStorage } from "@uidotdev/usehooks";
import {
type MRT_ColumnDef,
MaterialReactTable,
useMaterialReactTable,
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite } from "../favorite/Favorite";
import Loading from "../loading";
// import Loading from "../loading";
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import StakeModal from "../staking/StakeModal";
import { fee } from "../staking/schemas";
import ConnectWallet from "../wallet/ConnectWallet";
import type { MappedNymNode, MappedNymNodes } from "./NodeTableWithAction";
const ColumnHeading = ({
children,
}: {
children: string | React.ReactNode;
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Box
sx={{
width: isMobile ? "80px" : "unset",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "baseline",
p: 0,
}}
>
<Typography
sx={{
py: 2,
textAlign: "center",
whiteSpace: isMobile ? "normal" : "unset", // Ensure text can wrap
wordWrap: isMobile ? "break-word" : "unset", // Break long words
overflowWrap: isMobile ? "break-word" : "unset", // Ensure text breaks inside the cell
textTransform: "uppercase",
}}
variant={isMobile ? "caption" : "h5"}
>
{children}
</Typography>
</Box>
);
};
const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const router = useRouter();
const { nymClient } = useNymClient();
const queryClient = useQueryClient();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
});
const [isLoading, setIsLoading] = useState(false);
const [selectedNodeForStaking, setSelectedNodeForStaking] = useState<{
nodeId: number;
identityKey: string;
}>();
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const handleRefetch = useCallback(async () => {
await queryClient.invalidateQueries();
}, [queryClient]);
const handleStakeOnNode = useCallback(
async ({ nodeId, amount }: { nodeId: number; amount: string }) => {
const amountToDelegate = (Number(amount) * 1_000_000).toString();
const uNymFunds = [{ amount: amountToDelegate, denom: "unym" }];
setIsLoading(true);
setSelectedNodeForStaking(undefined);
try {
const tx = await nymClient?.delegate(
{ nodeId },
fee,
"Delegation from Nym Explorer V2",
uNymFunds,
);
setSelectedNodeForStaking(undefined);
setInfoModalProps({
open: true,
title: "Success",
message: "This operation can take up to one hour to process",
tx: tx?.transactionHash,
onClose: async () => {
await handleRefetch();
setInfoModalProps({ open: false });
},
});
await handleRefetch();
} catch (e) {
const errorMessage =
e instanceof Error ? e.message : "An error occurred while staking";
setInfoModalProps({
open: true,
title: "Error",
message: errorMessage,
onClose: () => {
setInfoModalProps({ open: false });
},
});
}
setIsLoading(false);
},
[nymClient, handleRefetch],
);
const handleOnSelectStake = useCallback(
(node: MappedNymNode) => {
if (!isWalletConnected) {
setInfoModalProps({
open: true,
title: "Connect Wallet",
message: "Connect your wallet to stake",
Action: (
<ConnectWallet
fullWidth
onClick={() =>
setInfoModalProps({
open: false,
})
}
/>
),
onClose: () => setInfoModalProps({ open: false }),
});
return;
}
setSelectedNodeForStaking({
nodeId: node.nodeId,
identityKey: node.identity_key,
});
},
[isWalletConnected],
);
const columns: MRT_ColumnDef<MappedNymNode>[] = useMemo(
() => [
{
id: "Favorite",
enableColumnFilter: false,
header: "Favorite",
accessorKey: "Favorite",
size: 50,
Header: (
<Stack direction="row" alignItems="center">
<ColumnHeading>Fav</ColumnHeading>
</Stack>
),
sortingFn: (a, b) => {
const aIsFavorite = favorites.includes(a.original.owner);
const bIsFavorite = favorites.includes(b.original.owner);
if (aIsFavorite && !bIsFavorite) {
return -1;
}
if (!aIsFavorite && bIsFavorite) {
return 1;
}
return 0;
},
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",
accessorKey: "countryName",
size: 140,
Header: <ColumnHeading>Location</ColumnHeading>,
Cell: ({ row }) =>
row.original.countryCode && row.original.countryName ? (
<Box width="100%">
<CountryFlag
countryCode={row.original.countryCode || ""}
countryName={row.original.countryName || ""}
/>
</Box>
) : (
"-"
),
},
{
id: "stakeSaturation",
header: "Stake saturation",
accessorKey: "stakeSaturation",
size: 120,
Header: <ColumnHeading>Saturation</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.stakeSaturation}%
</Typography>
),
},
{
id: "profitMarginPercentage",
header: "Profit margin",
accessorKey: "profitMarginPercentage",
Header: <ColumnHeading>Profit margin</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.profitMarginPercentage}%
</Typography>
),
},
{
id: "Action",
header: "Action",
accessorKey: "Action",
size: 120,
Header: <ColumnHeading>Action</ColumnHeading>,
hidden: !isWalletConnected,
enableColumnFilter: false,
Cell: ({ row }) => (
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.stopPropagation();
handleOnSelectStake(row.original);
}}
>
Stake
</Button>
),
enableSorting: false,
},
],
[isWalletConnected, handleOnSelectStake, favorites],
);
const table = useMaterialReactTable({
columns,
data: nodes,
enableRowSelection: false,
enableColumnOrdering: false,
enableColumnActions: false,
enableFullScreenToggle: false,
enableHiding: false,
enableDensityToggle: false,
enableFilterMatchHighlighting: true,
paginationDisplayMode: "pages",
muiPaginationProps: {
showRowsPerPage: false,
SelectProps: {
sx: {
fontFamily: "labGrotesqueMono",
fontSize: "14px",
},
},
color: "primary",
shape: "circular",
},
initialState: {
columnPinning: isMobile ? {} : { right: ["Action"] }, // No pinning on mobile
},
muiColumnActionsButtonProps: {
sx: {
color: "red",
},
size: "small",
},
muiTablePaperProps: {
elevation: 0,
},
muiTableHeadRowProps: {
sx: {
bgcolor: "background.paper",
},
},
muiTableHeadCellProps: {
sx: {
alignItems: "center",
paddingRight: 0,
},
},
muiTableBodyCellProps: {
sx: {
border: "none",
whiteSpace: "unset", // Allow text wrapping in body cells
wordBreak: "break-word", // Ensure long text breaks correctly
paddingRight: 0,
},
},
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/nym-node/${row.original.nodeId}`);
},
hover: true,
sx: {
":nth-child(odd)": {
bgcolor: "#F3F7FB !important",
},
":nth-child(even)": {
bgcolor: "white !important",
},
cursor: "pointer",
},
}),
});
return (
<>
{isLoading && <Loading />}
<StakeModal
nodeId={selectedNodeForStaking?.nodeId}
identityKey={selectedNodeForStaking?.identityKey}
onStake={handleStakeOnNode}
onClose={() => setSelectedNodeForStaking(undefined)}
/>
<InfoModal {...infoModalProps} />
<MaterialReactTable table={table} />
</>
);
};
export default NodeTable;
@@ -0,0 +1,117 @@
"use client";
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 { countryName } from "../../utils/countryName";
import NodeTable from "./NodeTable";
// Utility function to calculate node saturation point
function getNodeSaturationPoint(
totalStake: number,
stakeSaturationPoint: string,
): number {
const saturation = Number.parseFloat(stakeSaturationPoint);
if (Number.isNaN(saturation) || saturation <= 0) {
throw new Error("Invalid stake saturation point provided");
}
const ratio = (totalStake / saturation) * 100;
return Number(ratio.toFixed());
}
// Map nodes with rewards data
const mappedNymNodes = (
nodes: IObservatoryNode[],
epochRewardsData: ExplorerData["currentEpochRewardsData"],
) =>
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, "&");
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,
};
});
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
export type MappedNymNode = MappedNymNodes[0];
const NodeTableWithAction = () => {
// Use React Query to fetch epoch rewards
const {
data: epochRewardsData,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
});
// Use React Query to fetch Nym nodes
const {
data: nymNodes = [],
isLoading: isNodesLoading,
isError: isNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
});
// Handle loading state
if (isEpochLoading || isNodesLoading) {
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
</CardContent>
</Card>
);
}
// Handle error state
if (isEpochError || isNodesError) {
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Error loading data. Please try again later.
</Typography>
</Stack>
);
}
// Map nodes with rewards data
if (!epochRewardsData) {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
return <NodeTable nodes={data} />;
};
export default NodeTableWithAction;