Feature/explorer v2 connect wallet (#5301)

* set up libs for connecting wallet

* wallet - get address and balance

* format wallet balance

* start staking modal work

* Yana/node page (#5276)

* Add nym-node page api WIP

* nym-node page api WIP

* Add rewards card

* Add account balances

* fix build

* Add USD price to tokenomics card

* fix build

* fix build

* fix build

* Refactor ProgressBar

* Add profile card

* fix build

* replace hardcoded id

* Add build version and node roles

* fix build

* rename id param to address

* add node table to explorer page

* get node details from unstable endpoint + layout updates

* allow node select from table

* stop propogation on favorite/unfavorite

* update self bond data

* card refactors

* revert node engine requirement

* use node v20

---------

Co-authored-by: Yana <yanok87@users.noreply.github.com>
Co-authored-by: fmtabbara <fmtabbara@hotmail.co.uk>

* set up libs for connecting wallet

* wallet - get address and balance

* update typings + refactor favorite component

* fix event propogation

* fix multiple url declarations

* move pages to pages directory

* build staking page and base components

* refactor wallet components and add useNymClient hook

* enhance DesktopHeader to highlight active menu item with icon

* remove StakeModal and getNymNodes components

* refactor NodeTable and NodeTableWithAction components for improved staking functionality

* refactor API configuration and theme styles for improved maintainability

* refactor Loading component to implement LoadingModal with enhanced styling and functionality

* add InfoModal component for displaying informational messages with customizable actions

* fix type

* fix linting

* update CI workflow to use Node.js 20

* add explorer package to dependencies in package.json

* fix mui grid2 build errors

* fix full page reload

* fix unstaking

* fix delegation operation

* fix up workspace packages

* refactor staking and wallet components for improved functionality and readability

* fix undelegation (again)

* update ci to include new explorer

* update wallet ci node version

* remove logs

---------

Co-authored-by: Yana Matrosova <42305364+yanok87@users.noreply.github.com>
Co-authored-by: Yana <yanok87@users.noreply.github.com>
This commit is contained in:
Fouad
2024-12-30 22:19:13 +00:00
committed by GitHub
parent fb3a7aed6b
commit c45492cdc8
74 changed files with 4355 additions and 3228 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ on:
- "nym-connect/desktop/package.json"
- "nym-wallet/src/**"
- "nym-wallet/package.json"
- "explorer/**"
- "explorer-nextjs/src/**"
- ".github/workflows/ci-lint-typescript.yml"
jobs:
@@ -37,12 +37,12 @@ jobs:
- name: Install wasm-opt
uses: ./.github/actions/install-wasm-opt
with:
version: '116'
version: "116"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.20'
go-version: "1.20"
- name: Install
run: yarn
+77 -77
View File
@@ -4,8 +4,8 @@ on:
workflow_dispatch:
push:
paths:
- 'explorer/**'
- '.github/workflows/ci-nym-network-explorer.yml'
- "explorer/**"
- ".github/workflows/ci-nym-network-explorer.yml"
defaults:
run:
@@ -15,78 +15,78 @@ jobs:
build:
runs-on: custom-linux
steps:
- uses: actions/checkout@v4
- name: Install rsync
run: sudo apt-get install rsync
continue-on-error: true
- uses: rlespinasse/github-slug-action@v3.x
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Setup yarn
run: npm install -g yarn
continue-on-error: true
- name: Build shared packages
run: cd .. && yarn && yarn build
- name: Set environment from the example
run: cp .env.prod .env
# - run: yarn test
# continue-on-error: true
- run: yarn && yarn build
continue-on-error: true
- run: yarn storybook:build
name: Build storybook
- name: Deploy branch to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "explorer/dist/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/network-explorer-${{ env.GITHUB_REF_SLUG }}
EXCLUDE: "/dist/, /node_modules/"
- name: Deploy storybook to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "explorer/storybook-static/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/ne-sb-${{ env.GITHUB_REF_SLUG }}
EXCLUDE: "/dist/, /node_modules/"
- name: Matrix - Node Install
run: npm install
working-directory: .github/workflows/support-files
- name: Matrix - Send Notification
env:
NYM_NOTIFICATION_KIND: network-explorer
NYM_PROJECT_NAME: "Network Explorer"
NYM_CI_WWW_BASE: "${{ secrets.NYM_CI_WWW_BASE }}"
NYM_CI_WWW_LOCATION: "network-explorer-${{ env.GITHUB_REF_SLUG }}"
NYM_CI_WWW_LOCATION_STORYBOOK: "ne-sb-${{ env.GITHUB_REF_SLUG }}"
GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}"
GIT_BRANCH: "${GITHUB_REF##*/}"
IS_SUCCESS: "${{ job.status == 'success' }}"
MATRIX_SERVER: "${{ secrets.MATRIX_SERVER }}"
MATRIX_ROOM: "${{ secrets.MATRIX_ROOM }}"
MATRIX_USER_ID: "${{ secrets.MATRIX_USER_ID }}"
MATRIX_TOKEN: "${{ secrets.MATRIX_TOKEN }}"
MATRIX_DEVICE_ID: "${{ secrets.MATRIX_DEVICE_ID }}"
uses: docker://keybaseio/client:stable-node
with:
args: .github/workflows/support-files/notifications/entry_point.sh
- name: Deploy
if: github.event_name == 'workflow_dispatch'
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CD_PROD_NE_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "explorer/dist/"
REMOTE_HOST: ${{ secrets.CD_PROD_NE_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CD_PROD_NE_REMOTE_USER }}
TARGET: ${{ secrets.CD_PROD_NE_REMOTE_TARGET }}
EXCLUDE: "/dist/, /node_modules/"
- uses: actions/checkout@v4
- name: Install rsync
run: sudo apt-get install rsync
continue-on-error: true
- uses: rlespinasse/github-slug-action@v3.x
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup yarn
run: npm install -g yarn
continue-on-error: true
- name: Build shared packages
run: cd .. && yarn && yarn build
- name: Set environment from the example
run: cp .env.prod .env
# - run: yarn test
# continue-on-error: true
- run: yarn && yarn build
continue-on-error: true
- run: yarn storybook:build
name: Build storybook
- name: Deploy branch to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "explorer/dist/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/network-explorer-${{ env.GITHUB_REF_SLUG }}
EXCLUDE: "/dist/, /node_modules/"
- name: Deploy storybook to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "explorer/storybook-static/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/ne-sb-${{ env.GITHUB_REF_SLUG }}
EXCLUDE: "/dist/, /node_modules/"
- name: Matrix - Node Install
run: npm install
working-directory: .github/workflows/support-files
- name: Matrix - Send Notification
env:
NYM_NOTIFICATION_KIND: network-explorer
NYM_PROJECT_NAME: "Network Explorer"
NYM_CI_WWW_BASE: "${{ secrets.NYM_CI_WWW_BASE }}"
NYM_CI_WWW_LOCATION: "network-explorer-${{ env.GITHUB_REF_SLUG }}"
NYM_CI_WWW_LOCATION_STORYBOOK: "ne-sb-${{ env.GITHUB_REF_SLUG }}"
GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}"
GIT_BRANCH: "${GITHUB_REF##*/}"
IS_SUCCESS: "${{ job.status == 'success' }}"
MATRIX_SERVER: "${{ secrets.MATRIX_SERVER }}"
MATRIX_ROOM: "${{ secrets.MATRIX_ROOM }}"
MATRIX_USER_ID: "${{ secrets.MATRIX_USER_ID }}"
MATRIX_TOKEN: "${{ secrets.MATRIX_TOKEN }}"
MATRIX_DEVICE_ID: "${{ secrets.MATRIX_DEVICE_ID }}"
uses: docker://keybaseio/client:stable-node
with:
args: .github/workflows/support-files/notifications/entry_point.sh
- name: Deploy
if: github.event_name == 'workflow_dispatch'
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CD_PROD_NE_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "explorer/dist/"
REMOTE_HOST: ${{ secrets.CD_PROD_NE_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CD_PROD_NE_REMOTE_USER }}
TARGET: ${{ secrets.CD_PROD_NE_REMOTE_TARGET }}
EXCLUDE: "/dist/, /node_modules/"
@@ -20,7 +20,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Setup yarn
run: npm install -g yarn
+9 -4
View File
@@ -4,7 +4,7 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build:prod": "yarn build",
"build:prod": "yarn --cwd .. build && next build",
"start": "next start",
"lint": "biome check --fix"
},
@@ -12,19 +12,23 @@
"node": ">=20.0.0"
},
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.4",
"@cosmjs/proto-signing": "^0.32.4",
"@chain-registry/types": "^0.50.36",
"@cosmos-kit/keplr-extension": "^2.14.0",
"@cosmos-kit/react": "^2.20.1",
"@emotion/cache": "^11.13.5",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@interchain-ui/react": "^1.26.1",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^6.1.10",
"@mui/material-nextjs": "^6.1.9",
"@mui/x-date-pickers": "^7.23.2",
"@nivo/line": "^0.88.0",
"@nymproject/contract-clients": "^1.4.1",
"@nymproject/react": "1.0.0",
"@tanstack/react-table": "^8.20.6",
"@uidotdev/usehooks": "^2.4.1",
"chain-registry": "^1.69.64",
"cldr-compact-number": "^0.4.0",
"date-fns": "^4.1.0",
"material-react-table": "^3.0.3",
@@ -33,7 +37,8 @@
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-random-avatars": "^1.3.1",
"react-world-flags": "^1.6.0"
"react-world-flags": "^1.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
+135
View File
@@ -0,0 +1,135 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
>
<g clip-path="url(#clip0_8428_65000)">
<rect
y="3"
width="3"
height="3"
transform="rotate(-90 0 3)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
y="15"
width="3"
height="3"
transform="rotate(-90 0 15)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="12"
y="3"
width="3"
height="3"
transform="rotate(-90 12 3)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="9"
y="12"
width="3"
height="3"
transform="rotate(-90 9 12)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="12"
y="15"
width="3"
height="3"
transform="rotate(-90 12 15)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="3"
y="6"
width="3"
height="3"
transform="rotate(-90 3 6)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="3"
y="12"
width="3"
height="3"
transform="rotate(-90 3 12)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="9"
y="6"
width="3"
height="3"
transform="rotate(-90 9 6)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
<rect
x="6"
y="9"
width="3"
height="3"
transform="rotate(-90 6 9)"
fill="#242B2D"
style="
fill: #242b2d;
fill: color(display-p3 0.1412 0.1686 0.1765);
fill-opacity: 1;
"
/>
</g>
<defs>
<clipPath id="clip0_8428_65000">
<rect
width="15"
height="15"
fill="white"
style="fill: white; fill-opacity: 1"
/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="25"
viewBox="0 0 24 25"
fill="none"
>
<circle cx="12" cy="12.5" r="10" fill="url(#paint0_angular_2549_7570)" />
<defs>
<radialGradient
id="paint0_angular_2549_7570"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(12 12.5) rotate(90) scale(12)"
>
<stop stopColor="#22D27E" />
<stop offset="1" stopColor="#9002FF" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 515 B

+23
View File
@@ -0,0 +1,23 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="25"
viewBox="0 0 24 25"
fill="none"
>
<g clipPath="url(#clip0_2549_7563)">
<path
d="M20.4841 4.01607C15.8041 -0.67593 8.19607 -0.67593 3.51607 4.01607C-1.17593 8.70807 -1.17593 16.3041 3.51607 20.9841C8.20807 25.6761 15.8041 25.6761 20.4841 20.9841C25.1761 16.3041 25.1761 8.69607 20.4841 4.01607ZM19.4521 19.9521C15.3361 24.0681 8.65207 24.0681 4.53607 19.9521C0.42007 15.8361 0.42007 9.15207 4.53607 5.03607C8.65207 0.92007 15.3361 0.92007 19.4521 5.03607C23.5801 9.16407 23.5801 15.8361 19.4521 19.9521Z"
fill="black"
/>
<path
d="M18.48 19.4965V5.50447C17.868 4.92847 17.184 4.42447 16.452 4.02847V17.4085L7.62002 3.98047C6.85202 4.38847 6.14402 4.89247 5.52002 5.49247V19.4965C6.13202 20.0725 6.81602 20.5765 7.54802 20.9725V7.59247L16.38 21.0205C17.148 20.6125 17.856 20.0965 18.48 19.4965Z"
fill="black"
/>
</g>
<defs>
<clipPath id="clip0_2549_7563">
<rect width="24" height="24" fill="white" transform="translate(0 0.5)" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -6,7 +6,8 @@ import { AccountInfoCard } from "@/components/accountPageComponents/AccountInfoC
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Grid2, Typography } from "@mui/material";
import { Box, Typography } from "@mui/material";
import Grid2 from "@mui/material/Grid2";
export default async function Account({
params,
@@ -0,0 +1,121 @@
import type NodeData from "@/app/api/types";
import { NYM_NODES } from "@/app/api/urls";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import { BasicInfoCard } from "@/components/nymNodePageComponents/BasicInfoCard";
import { NodeMetricsCard } from "@/components/nymNodePageComponents/NodeMetricsCard";
import { NodeProfileCard } from "@/components/nymNodePageComponents/NodeProfileCard";
import { NodeRewardsCard } from "@/components/nymNodePageComponents/NodeRewardsCard";
import { QualityIndicatorsCard } from "@/components/nymNodePageComponents/QualityIndicatorsCard";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box } from "@mui/material";
import Grid2 from "@mui/material/Grid2";
export default async function NymNode({
params,
}: {
params: Promise<{ id: string }>;
}) {
try {
const id = Number((await params).id);
const response = await fetch(NYM_NODES, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymNodes: NodeData[] = await response.json();
if (!nymNodes) {
return null;
}
const nymNode = nymNodes.find((node) => node.node_id === id);
if (!nymNode) {
return null;
}
return (
<ContentLayout>
<Grid2 container columnSpacing={5} rowSpacing={5}>
<Grid2 size={12}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<SectionHeading title="Nym Node Details" />
<ExplorerButtonGroup
options={[
{ label: "Nym Node", isSelected: true, link: "/nym-node/1" },
{
label: "Account",
isSelected: false,
link: `/account/${nymNode.bond_information.owner}`,
},
]}
/>
</Box>
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<NodeProfileCard
bondInfo={nymNode.bond_information}
nodeDescription={nymNode.description}
/>
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<BasicInfoCard
bondInfo={nymNode.bond_information}
nodeDescription={nymNode.description}
rewardDetails={nymNode.rewarding_details}
/>
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<QualityIndicatorsCard nodeDescription={nymNode.description} />
</Grid2>
<Grid2
size={{
xs: 12,
md: 6,
}}
>
<NodeRewardsCard rewardDetails={nymNode.rewarding_details} />
</Grid2>
<Grid2
size={{
xs: 12,
md: 6,
}}
>
<NodeMetricsCard
nodeDescription={nymNode.description}
nodeId={nymNode.bond_information.node_id}
/>
</Grid2>
</Grid2>
</ContentLayout>
);
} catch (error) {
let errorMessage = "An error occurred";
if (error instanceof Error) {
errorMessage = error.message;
}
throw new Error(errorMessage);
}
}
@@ -0,0 +1,16 @@
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import OverviewCards from "@/components/staking/OverviewCards";
import StakeTableWithAction from "@/components/staking/StakeTableWithAction";
import SubHeaderRow from "@/components/staking/SubHeaderRow";
export default async function StakingPage() {
return (
<ContentLayout>
<SectionHeading title="Staking" />
<SubHeaderRow />
<OverviewCards />
<StakeTableWithAction />
</ContentLayout>
);
}
-90
View File
@@ -84,96 +84,6 @@ export interface ExplorerCache {
};
}
export interface IBondInfo {
bond_information: {
bonding_height: number;
is_unbonding: boolean;
node: {
custom_http_port: number;
host: string;
identity_key: string;
};
node_id: number;
original_pledge: {
amount: string;
denom: string;
};
owner: string;
};
rewarding_details: {
cost_params: {
profit_margin_percent: string;
interval_operating_cost: {
denom: string;
amount: string;
};
};
delegates: string;
last_rewarded_epoch: number;
operator: string;
total_unit_reward: string;
unique_delegations: number;
unit_delegation: string;
};
}
export interface INodeDescription {
contract_node_type: string;
description: {
authenticator: object;
address: string;
auxiliary_details: {
location: string;
accepted_operator_terms_and_conditions: boolean;
announce_ports: {
verloc_port: number | null;
mix_port: number | 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: null | object;
};
node_id: number;
}
const getExplorerData = async () => {
// FETCH NYMNODES
const fetchNymNodes = await fetch(NYM_NODES_DESCRIBED, {
+1 -1
View File
@@ -54,7 +54,7 @@ export type NodeDescription = {
ws_port: number;
wss_port: number | null;
};
};
} | null;
export type BondInformation = {
node_id: number;
+2
View File
@@ -30,3 +30,5 @@ export const NYM_ACCOUNT_ADDRESS =
"https://explorer.nymtech.net/api/v1/tmp/unstable/account/";
export const NYM_PRICES_API =
"https://canary-nym-vpn-chain-payment-watcher.nymte.ch/v1/price/average";
export const VALIDATOR_BASE_URL =
process.env.NEXT_PUBLIC_VALIDATOR_URL || "https://rpc.nymtech.net";
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import { Link } from "@/components/muiLink";
import { Button, Stack, Typography } from "@mui/material";
const ErrorPage = ({ error }: { error: Error }) => {
return (
<ContentLayout>
<Stack spacing={2} justifyContent="flex-start">
<Typography variant="body1">
An error occurred: {error.message}
</Typography>
<Typography variant="body2">
Please try again later or contact support
</Typography>
<Link href="/" underline="none">
<Button variant="contained" sx={{ maxWidth: 100 }} size="small">
Home
</Button>
</Link>
</Stack>
</ContentLayout>
);
};
export default ErrorPage;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+6 -5
View File
@@ -1,12 +1,13 @@
import { Header } from "@/components/header";
import { Wrapper } from "@/components/wrapper";
import ThemeProvider from "@/providers/ThemeProvider";
import Providers from "@/providers";
import type { Metadata } from "next";
import "./globals.css";
import "@interchain-ui/react/styles";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Nym Explorer V2",
};
export default function RootLayout({
@@ -17,10 +18,10 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<ThemeProvider>
<Providers>
<Header />
<Wrapper>{children}</Wrapper>
</ThemeProvider>
</Providers>
</body>
</html>
);
-122
View File
@@ -1,122 +0,0 @@
import type { IAccountBalancesTableProps } from "@/components/cards/AccountBalancesTable";
import type { ContentCardProps } from "@/components/cards/MonoCard";
const explorerCard: ContentCardProps = {
overTitle: "SINGLE",
profileImage: {},
title: "SINGLE",
profileCountry: {
countryCode: "NO",
countryName: "Norway",
},
upDownLine: {
percentage: 10,
numberWentUp: true,
},
titlePrice: {
price: 1.15,
upDownLine: {
percentage: 10,
numberWentUp: true,
},
},
dataRows: {
rows: [
{ key: "Market cap", value: "$ 1000000" },
{ key: "24H VOL", value: "$ 1000000" },
],
},
graph: {
data: [
{
date_utc: "2024-11-20",
numericData: 10,
},
{
date_utc: "2024-11-21",
numericData: 12,
},
{
date_utc: "2024-11-22",
numericData: 9,
},
{
date_utc: "2024-11-23",
numericData: 11,
},
],
color: "#00CA33",
label: "Label",
},
nymAddress: {
address: "n1w7tfthyfkhh3au3mqpy294p4dk65dzal2h04su",
title: "Nym address",
},
identityKey: {
address: "n1w7tfthyfkhh3au3mqpy294p4dk65dzal2h04su",
title: "Nym address",
},
qrCode: {
url: "https://nymtech.net",
},
ratings: {
ratings: [
{ title: "Rating-1", numberOfStars: 4 },
{ title: "Rating-2", numberOfStars: 2 },
{ title: "Rating-3", numberOfStars: 3 },
],
},
progressBar: {
start: "2024-12-08T12:26:19Z",
showEpoch: true,
},
comments: true,
paragraph: "Additional line",
stakeButton: {
label: "Stake on node",
identityKey: "n1w7tfthyfkhh3au3mqpy294p4dk65dzal2h04su",
},
};
const accountStatsCard: IAccountBalancesTableProps = {
// overTitle: "Total value",
// priceTitle: 1990.0174,
rows: [
{ type: "Spendable", allocation: 15.53, amount: 12800, value: 1200 },
{
type: "Delegated",
allocation: 15.53,
amount: 12800,
value: 1200,
history: [
{ type: "Liquid", amount: 6900 },
{ type: "Locked", amount: 6900 },
],
},
{
type: "Claimable",
allocation: 15.53,
amount: 12800,
value: 1200,
history: [
{ type: "Unlocked", amount: 6900 },
{ type: "Staking rewards", amount: 6900 },
{ type: "Operator comission", amount: 6900 },
],
},
{
type: "Self bonded",
allocation: 15.53,
amount: 12800,
value: 1200,
},
{
type: "Locked",
allocation: 15.53,
amount: 12800,
value: 1200,
},
],
};
export { explorerCard, accountStatsCard };
-31
View File
@@ -1,31 +0,0 @@
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Wrapper } from "@/components/wrapper";
import { Box, Typography } from "@mui/material";
export default function NodePage() {
return (
<div>
<main>
<Box sx={{ p: 5 }}>
<Wrapper>
<Typography fontWeight="light">Node page</Typography>
<ExplorerButtonGroup
options={[
{
label: "Node",
link: "/node",
isSelected: true,
},
{
label: "Account",
link: "/account",
isSelected: false,
},
]}
/>
</Wrapper>
</Box>
</main>
</div>
);
}
@@ -1,116 +0,0 @@
import type NodeData from "@/app/api/types";
import { NYM_NODES } from "@/app/api/urls";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import { BasicInfoCard } from "@/components/nymNodePageComponents/BasicInfoCard";
import { NodeMetricsCard } from "@/components/nymNodePageComponents/NodeMetricsCard";
import { NodeProfileCard } from "@/components/nymNodePageComponents/NodeProfileCard";
import { NodeRewardsCard } from "@/components/nymNodePageComponents/NodeRewardsCard";
import { QualityIndicatorsCard } from "@/components/nymNodePageComponents/QualityIndicatorsCard";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Grid2 } from "@mui/material";
export default async function NymNode({
params,
}: {
params: Promise<{ id: string; account?: string }>;
}) {
const id = Number((await params).id);
const response = await fetch(NYM_NODES, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymNodes: NodeData[] = await response.json();
if (!nymNodes) {
return null;
}
const nymNode = nymNodes.find((node) => node.node_id === id);
if (!nymNode) {
return null;
}
return (
<ContentLayout>
<Grid2 container columnSpacing={5} rowSpacing={5}>
<Grid2 size={12}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<SectionHeading title="Nym Node Details" />
<ExplorerButtonGroup
options={[
{
label: "Nym Node",
isSelected: true,
link: `/nym-node/${id}`,
},
{
label: "Account",
isSelected: false,
link: `/account/${nymNode.bond_information.owner}`,
},
]}
/>
</Box>
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<NodeProfileCard
bondInfo={nymNode.bond_information}
nodeDescription={nymNode.description}
/>
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<BasicInfoCard
bondInfo={nymNode.bond_information}
nodeDescription={nymNode.description}
rewardDetails={nymNode.rewarding_details}
/>
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<QualityIndicatorsCard nodeDescription={nymNode.description} />
</Grid2>
<Grid2
size={{
xs: 12,
md: 6,
}}
>
<NodeRewardsCard rewardDetails={nymNode.rewarding_details} />
</Grid2>
<Grid2
size={{
xs: 12,
md: 6,
}}
>
<NodeMetricsCard
nodeDescription={nymNode.description}
nodeId={nymNode.bond_information.node_id}
/>
</Grid2>
</Grid2>
</ContentLayout>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { Wrapper } from "@/components/wrapper";
import { Box, Typography } from "@mui/material";
export default function OnboardingPage() {
return (
<div>
<main>
<Box sx={{ p: 5 }}>
<Wrapper>
<Typography fontWeight="light">Stake page</Typography>
</Wrapper>
</Box>
</main>
</div>
);
}
@@ -18,9 +18,9 @@ export const AccountInfoCard = (props: IAccountInfoCardProps) => {
return (
<ExplorerCard
label="Address"
label=""
title={balanceFormated}
sx={{ height: "100%" }}
sx={{ height: "100%", pt: 0 }}
>
<Stack gap={5}>
<Box display={"flex"} justifyContent={"flex-start"}>
@@ -1,344 +0,0 @@
"use client";
import CopyToClipboard from "@/components/copyToClipboard/CopyToClipboard";
import { Box, Button, Card, CardContent, Typography } from "@mui/material";
import Image from "next/image";
import type React from "react";
import type { FC } from "react";
import profileImagePlaceholder from "../../../public/profileImagePlaceholder.png";
import CardProfileCountry from "../countryFlag/CountryFlag";
import type { ICountryFlag } from "../countryFlag/CountryFlag";
// import { Remark42Comments } from "../comments";
import { NymTokenSVG } from "../icons/NymTokenSVG";
import { type ILineChartData, LineChart } from "../lineChart";
import {
type IUpDownPriceIndicatorProps,
UpDownPriceIndicator,
} from "../price/UpDownPriceIndicator";
import type { IDynamicProgressBarProps } from "../progressBars/EpochProgressBar";
import { CardQRCode, type ICardQRCodeProps } from "../qrCode/QrCode";
import { StarRating } from "../starRating";
interface ICardTitlePriceProps {
price: number;
upDownLine: IUpDownPriceIndicatorProps;
}
const CardTitlePrice = (props: ICardTitlePriceProps): React.ReactNode => {
const { price, upDownLine } = props;
return (
<Box display={"flex"} flexDirection={"column"} alignItems={"flex-end"}>
<Box display={"flex"} justifyContent={"space-between"} width={"100%"}>
<Box display={"flex"} gap={1} alignItems={"center"}>
<NymTokenSVG />
<Typography variant="h3" sx={{ color: "pine.400" }}>
NYM
</Typography>
</Box>
<Typography variant="h3" sx={{ color: "pine.400" }}>
${price}
</Typography>
</Box>
<UpDownPriceIndicator {...upDownLine} />
</Box>
);
};
export interface ICardDataRowsProps {
rows: Array<{ key: string; value: string }>;
}
export const CardDataRows = (props: ICardDataRowsProps): React.ReactNode => {
const { rows } = props;
return (
<Box>
{rows.map((row, i) => {
return (
<Box
key={row.key}
display={"flex"}
justifyContent={"space-between"}
borderBottom={i < rows.length - 1 ? "1px solid #C3D7D7" : "none"}
sx={{ pt: 1, pb: i < rows.length - 1 ? 1 : 0 }}
>
<Typography variant="h6" sx={{ color: "pine.600" }}>
{row.key}
</Typography>
<Typography variant="h6" sx={{ color: "pine.600" }}>
{row.value}
</Typography>
</Box>
);
})}
</Box>
);
};
interface ICardProileImage {
url?: string;
}
const CardProfileImage = (props: ICardProileImage) => {
const { url } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"}>
{url ? (
<Image src={url} alt="linkedIn" width={80} height={80} />
) : (
<Image
src={profileImagePlaceholder}
alt="profile picture"
width={80}
height={80}
/>
)}
</Box>
);
};
interface ICardCopyAddressProps {
title: string;
address: string;
}
const CardCopyAddress = (props: ICardCopyAddressProps) => {
const { title, address } = props;
return (
<Box
paddingTop={1}
paddingBottom={2}
display={"flex"}
flexDirection={"column"}
gap={2}
borderBottom={"1px solid #C3D7D7"}
>
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
{title}
</Typography>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography
variant="body4"
maxWidth={"90%"}
sx={{ wordWrap: "break-word", color: "pine.950" }}
>
{address}
</Typography>
<CopyToClipboard text={`Copy identity key ${address} to clipboard`} />
</Box>
</Box>
);
};
interface ICardRatingsProps {
ratings: Array<{ title: string; numberOfStars: number }>;
}
const CardRatings = (props: ICardRatingsProps) => {
const { ratings } = props;
return (
<Box>
{ratings.map((rating, i) => {
const RatingTitle = () => {
switch (rating.numberOfStars) {
case 1:
return (
<Typography variant="body4" sx={{ color: "pine.950" }}>
Bad
</Typography>
);
case 2:
return (
<Typography variant="body4" sx={{ color: "pine.950" }}>
Bad
</Typography>
);
case 3:
return (
<Typography variant="body4" sx={{ color: "pine.950" }}>
ok
</Typography>
);
case 4:
return (
<Typography variant="body4" sx={{ color: "pine.950" }}>
Good
</Typography>
);
default:
return (
<Typography variant="body4" sx={{ color: "pine.950" }}>
Excellent
</Typography>
);
}
};
return (
<Box
key={rating.title}
paddingTop={2}
paddingBottom={2}
display={"flex"}
justifyContent={"space-between"}
borderBottom={i < ratings.length - 1 ? "1px solid #C3D7D7" : "none"}
>
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
{rating.title}
</Typography>
<Box display={"flex"} gap={1} alignItems={"center"}>
<StarRating value={rating.numberOfStars} />
<RatingTitle />
</Box>
</Box>
);
})}
</Box>
);
};
export type ContentCardProps = {
overTitle?: string;
profileImage?: ICardProileImage;
title?: string | number;
profileCountry?: ICountryFlag;
upDownLine?: IUpDownPriceIndicatorProps;
titlePrice?: ICardTitlePriceProps;
dataRows?: ICardDataRowsProps;
graph?: { data: Array<ILineChartData>; color: string; label: string };
progressBar?: IDynamicProgressBarProps;
paragraph?: string;
nymAddress?: ICardCopyAddressProps;
identityKey?: ICardCopyAddressProps;
qrCode?: ICardQRCodeProps;
ratings?: ICardRatingsProps;
comments?: boolean;
stakeButton?: {
label: string;
identityKey: string;
};
};
export const MonoCard: FC<ContentCardProps> = ({
title,
titlePrice,
overTitle,
upDownLine,
dataRows,
graph,
paragraph,
profileImage,
profileCountry,
nymAddress,
identityKey,
qrCode,
ratings,
// comments,
stakeButton,
}) => (
<Card sx={{ height: "100%", borderRadius: "unset", padding: 1 }}>
<CardContent
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<Box>
{overTitle && (
<Typography
variant="h5"
sx={{ color: "pine.600", letterSpacing: 0.7 }}
>
{overTitle}
</Typography>
)}
{profileImage && (
<Box mt={3}>
<CardProfileImage {...profileImage} />
</Box>
)}
{title && (
<Typography
variant="h3"
mt={3}
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{title}
</Typography>
)}
{upDownLine && (
<Box>
<UpDownPriceIndicator {...upDownLine} />
</Box>
)}
{profileCountry && (
<Box mt={3}>
<CardProfileCountry {...profileCountry} />
</Box>
)}
{titlePrice && (
<Box mt={3}>
<CardTitlePrice {...titlePrice} />
</Box>
)}
</Box>
{qrCode && (
<Box mt={3}>
<CardQRCode {...qrCode} />
</Box>
)}
{nymAddress && (
<Box mt={3}>
<CardCopyAddress {...nymAddress} />
</Box>
)}
{identityKey && (
<Box mt={3}>
<CardCopyAddress {...identityKey} />
</Box>
)}
{dataRows && (
<Box mt={3}>
<CardDataRows {...dataRows} />
</Box>
)}
{ratings && (
<Box mt={3}>
<CardRatings {...ratings} />
</Box>
)}
{graph && (
<Box mt={3}>
<LineChart
data={graph.data}
color={graph.color}
label={graph.label}
/>
</Box>
)}
{paragraph && (
<Typography variant="subtitle1" sx={{ color: "pine.950" }} mt={3}>
{paragraph}
</Typography>
)}
{/* {comments && (
<Box mt={3}>
<Remark42Comments />
</Box>
)} */}
{stakeButton && (
<Box mt={3}>
<Button
onClick={() =>
console.log(
"stakeButton.identityKey :>> ",
stakeButton.identityKey,
)
}
variant="contained"
>
{stakeButton.label}
</Button>
</Box>
)}
</CardContent>
</Card>
);
@@ -0,0 +1,40 @@
import { CURRENT_EPOCH } from "@/app/api/urls";
import { AccessTime } from "@mui/icons-material";
import { Stack, Typography } from "@mui/material";
import { addSeconds } from "date-fns";
import { format } from "date-fns";
export const fetcNextEpoch = async () => {
const res = await fetch(CURRENT_EPOCH, {
next: { revalidate: 60 },
});
const data = await res.json();
const dateTime = addSeconds(
new Date(data.current_epoch_start),
data.epoch_length.secs,
);
return { data, dateTime };
};
const NextEpochTime = async () => {
try {
const epoch = await fetcNextEpoch();
const formattedDate = format(epoch.dateTime, "HH:mm:ss");
return (
<Stack direction="row" spacing={1}>
<AccessTime />
<Typography variant="h5" fontWeight="light">
Next epoch: {formattedDate}
</Typography>
</Stack>
);
} catch (error) {
console.log(error);
return null;
}
};
export default NextEpochTime;
@@ -3,13 +3,27 @@ import {
Favorite as FavoriteIcon,
} from "@mui/icons-material";
import { IconButton } from "@mui/material";
import { useLocalStorage } from "@uidotdev/usehooks";
const Favorite = ({ address }: { address: string }) => {
const [favorites, saveFavorites] = useLocalStorage<string[]>(
"nym-node-favorites",
[],
);
const onFavorite = (address: string) => {
saveFavorites([...favorites, address]);
};
if (favorites.includes(address)) {
return <UnFavorite address={address} />;
}
const Favorite = ({ onFavorite }: { onFavorite: () => void }) => {
return (
<IconButton
onClick={(e) => {
e.stopPropagation();
onFavorite();
onFavorite(address);
}}
>
<FavoriteBorderIcon sx={{ color: "accent.main" }} />
@@ -17,12 +31,21 @@ const Favorite = ({ onFavorite }: { onFavorite: () => void }) => {
);
};
const UnFavorite = ({ onUnfavorite }: { onUnfavorite: () => void }) => {
const UnFavorite = ({ address }: { address: string }) => {
const [favorites, saveFavorites] = useLocalStorage<string[]>(
"nym-node-favorites",
[],
);
const handleUnfavorite = (address: string) => {
saveFavorites(favorites.filter((favorite) => favorite !== address));
};
return (
<IconButton
onClick={(e) => {
e.stopPropagation();
onUnfavorite();
handleUnfavorite(address);
}}
>
<FavoriteIcon sx={{ color: "accent.main" }} />
@@ -1,13 +1,17 @@
"use client";
import NymLogo from "@/components/icons/NymLogo";
import { Link } from "@/components/muiLink";
import { Wrapper } from "@/components/wrapper";
import { subtitles } from "@/theme/typography";
import { Box, Button, Divider } from "@mui/material";
import type React from "react";
import { Box, Divider } from "@mui/material";
import ConnectWallet from "../wallet/ConnectWallet";
import HeaderItem from "./HeaderItem";
const DUMMY_MENU_DATA = [
export type MenuItem = {
id: number;
title: string;
url: string;
};
const DUMMY_MENU_DATA: MenuItem[] = [
{
id: 1,
title: "Explorer",
@@ -65,39 +69,10 @@ export const DesktopHeader = () => {
}}
>
{DUMMY_MENU_DATA.map((menu) => (
<Button
key={menu.title}
href={menu.url}
sx={{
borderRadius: 0,
padding: 0,
minWidth: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "10px",
height: "100%",
...subtitles.subtitle1,
"& .MuiButton-endIcon": {
marginLeft: 0,
marginRight: 0,
},
"& .MuiButton-startIcon": {
marginLeft: 0,
marginRight: 0,
},
"&:hover": {
textDecoration: "none",
},
}}
>
{menu.title}
</Button>
<HeaderItem key={menu.id} menu={menu} />
))}
</Box>
<Button variant="contained" size="small">
Connect Wallet
</Button>
<ConnectWallet size="small" />
</Wrapper>
<Divider variant="fullWidth" sx={{ width: "100%" }} />
</Box>
@@ -0,0 +1,30 @@
"use client";
import { Circle } from "@mui/icons-material";
import { Button, Stack } from "@mui/material";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { MenuItem } from "./DesktopHeader";
type HeaderItemProps = {
menu: MenuItem;
};
const HeaderItem = ({ menu }: HeaderItemProps) => {
const pathname = usePathname();
return (
<Stack direction="row" gap={2} key={menu.id} alignItems="center">
{pathname.includes(menu.url) && <Circle sx={{ fontSize: 10 }} />}
<Link href={menu.url} passHref>
<Button
sx={{
padding: 0,
}}
>
{menu.title}
</Button>
</Link>
</Stack>
);
};
export default HeaderItem;
@@ -0,0 +1,7 @@
import Image from "next/image";
const Cross = () => (
<Image src="/icons/cross.svg" alt="cross" width={12.5} height={12.5} />
);
export default Cross;
@@ -0,0 +1,6 @@
import Image from "next/image";
import * as React from "react";
export const Elips = () => (
<Image src="/icons/elips.svg" alt="Elips" width={20} height={20} />
);
@@ -0,0 +1,6 @@
import Image from "next/image";
import * as React from "react";
export const Token = () => {
return <Image src="/icons/token.svg" alt="Token" width={20} height={20} />;
};
@@ -74,5 +74,4 @@ export const NoiseCard = async () => {
)}
</ExplorerCard>
);
// return <MonoCard {...noiseCard} />;
};
@@ -1,18 +1,16 @@
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Stack, Typography } from "@mui/material";
import { Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
export const RewardsCard = async () => {
return (
<ExplorerCard label="Operator rewards this month">
<Stack>
<Typography
variant="h3"
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{`${formatBigNum(10_000_111)} NYM`}
</Typography>
</Stack>
<Typography
variant="h3"
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{`${formatBigNum(10_000_111)} NYM`}
</Typography>
</ExplorerCard>
);
};
@@ -1,21 +1,37 @@
"use client";
import {
Box,
CircularProgress,
Modal,
Stack,
type SxProps,
Typography,
} from "@mui/material";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import * as React from "react";
const modalStyle: SxProps = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 500,
bgcolor: "background.paper",
boxShadow: 24,
borderRadius: "16px",
p: 4,
};
export default function Loading() {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
marginY: 10,
width: 1,
}}
>
<CircularProgress />
const LoadingModal: FCWithChildren<{
text?: string;
sx?: SxProps;
backdropProps?: object;
}> = ({ sx, backdropProps, text = "Please wait..." }) => (
<Modal open slotProps={{ backdrop: backdropProps }}>
<Box sx={{ ...modalStyle, ...sx }} textAlign="center">
<Stack spacing={4} direction="row" alignItems="center">
<CircularProgress size={25} />
<Typography variant="h4">{text}</Typography>
</Stack>
</Box>
);
}
</Modal>
);
export default LoadingModal;
@@ -0,0 +1,52 @@
import { Button, Link, Stack, Typography } from "@mui/material";
import SimpleModal from "./SimpleModal";
type InfoModalPropsClosed = {
open: false;
};
type InfoModalPropsOpen = {
open: true;
title: string;
message: string;
Action?: React.ReactNode;
tx?: string;
onClose: () => void;
};
export type InfoModalProps = InfoModalPropsClosed | InfoModalPropsOpen;
const InfoModal = (props: InfoModalProps) => {
if (!props.open) {
return null;
}
const { open, onClose, title, message, tx, Action } = props;
return (
<SimpleModal
onClose={onClose}
open={open}
title=""
Actions={
Action || (
<Button variant="contained" onClick={onClose}>
Close
</Button>
)
}
>
<Stack spacing={2} alignItems="center">
<Typography variant="h3">{title}</Typography>
<Typography variant="body3">{message}</Typography>
{tx && (
<Link href="/">
<Typography variant="h5">Block explorer link</Typography>
</Link>
)}
</Stack>
</SimpleModal>
);
};
export default InfoModal;
@@ -0,0 +1,53 @@
import Cross from "@/components/icons/Cross";
import {
Dialog,
DialogActions,
DialogContent,
IconButton,
Stack,
Typography,
} from "@mui/material";
type SimpleModalPropsClosed = {
open: false;
};
type SimpleModalPropsOpen = {
open: true;
title: string;
children?: React.ReactNode;
Actions?: React.ReactNode;
onClose: () => void;
};
export type SimpleModalProps = SimpleModalPropsClosed | SimpleModalPropsOpen;
const SimpleModal = (props: SimpleModalProps) => {
if (!props.open) {
return null;
}
const { title, children, Actions, onClose } = props;
return (
<Dialog open maxWidth="sm" fullWidth onClose={onClose}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ p: 4 }}
>
<Typography variant="body1" sx={{ textTransform: "uppercase" }}>
{title}
</Typography>
<IconButton aria-label="close" size="large" onClick={onClose}>
<Cross />
</IconButton>
</Stack>
<DialogContent sx={{ p: 4, pt: 0 }}>{children}</DialogContent>
<DialogActions sx={{ px: 4, pb: 4 }}>{Actions}</DialogActions>
</Dialog>
);
};
export default SimpleModal;
@@ -1,16 +1,23 @@
"use client";
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { useNymClient } from "@/hooks/useNymClient";
import { useChain } from "@cosmos-kit/react";
import { Box, Button, Stack, Tooltip, Typography } from "@mui/material";
import { useLocalStorage } from "@uidotdev/usehooks";
import {
type MRT_ColumnDef,
MaterialReactTable,
useMaterialReactTable,
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite, UnFavorite } from "../favorite/Favorite";
import { Favorite } from "../favorite/Favorite";
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 = ({
@@ -27,24 +34,90 @@ const ColumnHeading = ({
const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const router = useRouter();
const { nymClient } = useNymClient();
const [favorites, saveFavorites] = useLocalStorage<string[]>(
"nym-node-favorites",
[],
);
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
});
const [isLoading, setIsLoading] = useState(false);
const [selectedNodeForStaking, setSelectedNodeForStaking] = useState<{
nodeId: number;
identityKey: string;
}>();
const handleFavorite = useCallback(
(address: string) => {
saveFavorites([...favorites, address]);
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const handleStakeOnNode = 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,
);
console.log({ tx });
setSelectedNodeForStaking(undefined);
setInfoModalProps({
open: true,
title: "Success",
message: "This operation can take up to one hour to process",
tx: tx?.transactionHash,
onClose: () => setInfoModalProps({ open: false }),
});
} 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);
};
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.bondInformation.node.identity_key,
});
},
[favorites, saveFavorites],
);
const handleUnfavorite = useCallback(
(address: string) => {
saveFavorites(favorites.filter((favorite) => favorite !== address));
},
[favorites, saveFavorites],
[isWalletConnected],
);
const columns: MRT_ColumnDef<MappedNymNode>[] = useMemo(
@@ -117,8 +190,16 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
header: "Action",
accessorKey: "Action",
Header: <ColumnHeading>Action</ColumnHeading>,
Cell: () => (
<Button size="small" variant="outlined">
hidden: !isWalletConnected,
Cell: ({ row }) => (
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.stopPropagation();
handleOnSelectStake(row.original);
}}
>
Stake
</Button>
),
@@ -130,23 +211,12 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
accessorKey: "Favorite",
Header: <ColumnHeading>Favorite</ColumnHeading>,
sortingFn: "Favorite",
Cell: ({ row }) =>
favorites.includes(row.original.bondInformation.node.identity_key) ? (
<UnFavorite
onUnfavorite={() =>
handleUnfavorite(row.original.bondInformation.node.identity_key)
}
/>
) : (
<Favorite
onFavorite={() =>
handleFavorite(row.original.bondInformation.node.identity_key)
}
/>
),
Cell: ({ row }) => (
<Favorite address={row.original.bondInformation.owner} />
),
},
],
[favorites, handleFavorite, handleUnfavorite],
[isWalletConnected, handleOnSelectStake],
);
const table = useMaterialReactTable({
columns,
@@ -214,7 +284,19 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
}),
});
return <MaterialReactTable table={table} />;
return (
<>
{isLoading && <Loading />}
<StakeModal
nodeId={selectedNodeForStaking?.nodeId}
identityKey={selectedNodeForStaking?.identityKey}
onStake={handleStakeOnNode}
onClose={() => setSelectedNodeForStaking(undefined)}
/>
<InfoModal {...infoModalProps} />
<MaterialReactTable table={table} />
</>
);
};
export default NodeTable;
@@ -1,7 +1,6 @@
import getNymNodes from "@/actions/getNymNodes";
import type NymNode from "@/app/api/types";
import { ClientOnly } from "../clientOnly/ClientOnly";
import NodeTable from "./NodeTable";
import getNymNodes from "./actions";
const mappedNymNodes = (nodes: NymNode[]) =>
nodes.map((node) => {
@@ -22,11 +21,7 @@ const NodeTableWithAction = async () => {
try {
const nodes = await getNymNodes();
const data = mappedNymNodes(nodes);
return (
<ClientOnly>
<NodeTable nodes={data} />
</ClientOnly>
);
return <NodeTable nodes={data} />;
} catch (error) {
console.error(error);
return [];
@@ -19,10 +19,12 @@ interface IBasicInfoCardProps {
export const BasicInfoCard = (props: IBasicInfoCardProps) => {
const { bondInfo, nodeDescription, rewardDetails } = props;
const timeBonded = format(
new Date(nodeDescription.build_information.build_timestamp),
"dd/MM/yyyy",
);
const timeBonded = nodeDescription
? format(
new Date(nodeDescription.build_information.build_timestamp),
"dd/MM/yyyy",
)
: "-";
const selfBond = formatBigNum(Number(rewardDetails.operator) / 1_000_000);
const selfBondFormated = `${selfBond} NYM`;
@@ -9,6 +9,7 @@ interface INodeMetricsCardProps {
export const NodeMetricsCard = (props: INodeMetricsCardProps) => {
const { nodeDescription, nodeId } = props;
return (
<ExplorerCard label="Nym node metrics" sx={{ height: "100%" }}>
<ExplorerListItem
@@ -17,18 +18,22 @@ export const NodeMetricsCard = (props: INodeMetricsCardProps) => {
label="Node ID."
value={nodeId.toString()}
/>
<ExplorerListItem
row
divider
label="Host"
value={nodeDescription.host_information.ip_address.toString()}
/>
<ExplorerListItem
row
divider
label="Version"
value={nodeDescription.build_information.build_version}
/>
{nodeDescription && (
<>
<ExplorerListItem
row
divider
label="Host"
value={nodeDescription.host_information.ip_address.toString()}
/>
<ExplorerListItem
row
divider
label="Version"
value={nodeDescription.build_information.build_version}
/>
</>
)}
<ExplorerListItem row label="Active set Prob." value="High" />
</ExplorerCard>
);
@@ -26,9 +26,11 @@ export const NodeProfileCard = (props: INodeProfileCardProps) => {
>
{"Moniker"}
</Typography>
<CountryFlag
countryCode={nodeDescription.auxiliary_details.location || ""}
/>
{nodeDescription && (
<CountryFlag
countryCode={nodeDescription.auxiliary_details.location}
/>
)}
<Typography variant="body4" sx={{ color: "pine.950" }}>
Team of professional validators with best digital solutions. Please
visit our Telegram🔹https://t.me/CryptoSailorsAnn🔹
@@ -8,7 +8,8 @@ interface IQualityIndicatorsCardProps {
nodeDescription: NodeDescription;
}
type DelcaredRoleKey = keyof NodeDescription["declared_role"];
type NodeDescriptionNotNull = NonNullable<NodeDescription>;
type DelcaredRoleKey = keyof NodeDescriptionNotNull["declared_role"];
type RoleString = "Entry Node" | "Exit IPR Node" | "Exit NR Node" | "Mix Node";
const roleMapping: Record<DelcaredRoleKey, RoleString> = {
@@ -19,7 +20,7 @@ const roleMapping: Record<DelcaredRoleKey, RoleString> = {
};
function getNodeRoles(
declaredRoles: NodeDescription["declared_role"],
declaredRoles: NodeDescriptionNotNull["declared_role"],
): RoleString[] {
const activeRoles = Object.entries(declaredRoles)
.filter(([, isActive]) => isActive)
@@ -31,6 +32,10 @@ function getNodeRoles(
export const QualityIndicatorsCard = (props: IQualityIndicatorsCardProps) => {
const { nodeDescription } = props;
if (!nodeDescription) {
return null;
}
const nodeRoles = getNodeRoles(nodeDescription.declared_role);
const NodeRoles = nodeRoles.map((role) => (
<Stack key={role} direction="row" gap={1}>
@@ -1,8 +1,8 @@
"use client";
import type { IBondInfo } from "@/app/api";
import { NYM_NODE_BONDED } from "@/app/api/urls";
import { Search } from "@mui/icons-material";
import { Button, CircularProgress, Stack, Typography } from "@mui/material";
import type { NymNodeDetails } from "@nymproject/types";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Input from "../input/Input";
@@ -46,8 +46,8 @@ const NodeAndAddressSearch = () => {
if (response.ok) {
const nodes = await response.json();
const matchingNode = nodes.data.find(
(node: IBondInfo) =>
node.bond_information.node.identity_key === inputValue,
(node: NymNodeDetails) =>
node.bond_information.identity_key === inputValue,
);
if (matchingNode) {
@@ -0,0 +1,41 @@
"use client";
import { useNymClient } from "@/hooks/useNymClient";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Typography } from "@mui/material";
import { useEffect, useState } from "react";
import ExplorerCard from "../cards/ExplorerCard";
const OriginalStakeCard = () => {
const [origialStake, setOriginalStake] = useState(0);
const { nymClient, address } = useNymClient();
useEffect(() => {
const getDelegations = async () => {
if (!nymClient || !address) return;
const delegations = await nymClient?.getDelegatorDelegations({
delegator: address,
});
const totaluNYMStake = delegations.delegations.reduce((acc, curr) => {
return acc + Number(curr.amount.amount);
}, 0);
setOriginalStake(totaluNYMStake);
};
getDelegations();
}, [address, nymClient]);
return (
<ExplorerCard label="Original Stake">
<Typography
variant="h3"
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{`${formatBigNum(origialStake / 1_000_000)} NYM`}
</Typography>
</ExplorerCard>
);
};
export default OriginalStakeCard;
@@ -0,0 +1,46 @@
"use client";
import { useNymClient } from "@/hooks/useNymClient";
import { Grid2 } from "@mui/material";
import OriginalStakeCard from "./OriginalStakeCard";
import TotalRewardsCard from "./TotalRewardsCard";
import TotalStakeCard from "./TotalStakeCard";
const OverviewCards = () => {
const { address } = useNymClient();
if (!address) {
return null;
}
return (
<Grid2 container spacing={3}>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<TotalStakeCard />
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<OriginalStakeCard />
</Grid2>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<TotalRewardsCard />
</Grid2>
</Grid2>
);
};
export default OverviewCards;
@@ -0,0 +1,80 @@
import { MoreVert } from "@mui/icons-material";
import { IconButton, Menu, MenuItem } from "@mui/material";
import { useState } from "react";
type StakeAction = "unstake";
const StakeActions = ({
nodeId,
onActionSelect,
}: {
nodeId?: number;
onActionSelect: (action: StakeAction) => void;
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleShowMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleActionSelect = (action: StakeAction) => {
onActionSelect(action);
handleClose();
};
return (
<>
<IconButton onClick={handleShowMenu} disabled={!nodeId}>
<MoreVert />
</IconButton>
<Menu
elevation={0}
anchorEl={anchorEl}
open={open}
onClose={() => {
handleClose();
}}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
hideBackdrop
>
<StakeAction
actionName="Unstake"
onSelect={() => handleActionSelect("unstake")}
/>
</Menu>
</>
);
};
const StakeAction = ({
actionName,
disabled,
onSelect,
}: {
actionName: string;
disabled?: boolean;
onSelect: () => void;
}) => {
return (
<MenuItem
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
{actionName}
</MenuItem>
);
};
export default StakeActions;
@@ -0,0 +1,113 @@
import SimpleModal from "@/components/modal/SimpleModal";
import useGetWalletBalance from "@/hooks/useGetWalletBalance";
import { Button, Stack, Typography } from "@mui/material";
import { CurrencyFormField } from "@nymproject/react/currency/CurrencyFormField.js";
import { IdentityKeyFormField } from "@nymproject/react/mixnodes/IdentityKeyFormField.js";
import type { DecCoin } from "@nymproject/types";
import { useEffect, useState } from "react";
import ExplorerListItem from "../list/ListItem";
import stakingSchema, { MIN_AMOUNT_TO_DELEGATE } from "./schemas";
const StakeModal = ({
nodeId,
identityKey,
onStake,
onClose,
}: {
nodeId?: number;
identityKey?: string;
onStake: ({
nodeId,
amount,
}: {
nodeId: number;
amount: string;
}) => Promise<void>;
onClose: () => void;
}) => {
const { balance } = useGetWalletBalance();
const [amount, setAmount] = useState<DecCoin>({
amount: MIN_AMOUNT_TO_DELEGATE,
denom: "nym",
});
const [isValidated, setValidated] = useState<boolean>(false);
const [errorAmount, setErrorAmount] = useState<string | undefined>();
useEffect(() => {
const asyncValidate = async () => {
await stakingSchema
.parseAsync({ amount: amount.amount, balance, nodeId })
.then(() => {
setValidated(true);
setErrorAmount(undefined);
return true;
})
.catch((e) => {
console.error(e.errors);
setValidated(false);
setErrorAmount(e.errors[0]?.message);
return false;
});
};
asyncValidate();
}, [amount, balance, nodeId]);
if (!nodeId) {
return null;
}
const handleOnClose = () => {
setAmount({ amount: MIN_AMOUNT_TO_DELEGATE, denom: "nym" });
onClose();
};
return (
<SimpleModal
title="Stake"
open={!!identityKey}
onClose={handleOnClose}
Actions={
<Button
variant="contained"
color="secondary"
onClick={() => onStake({ nodeId, amount: amount.amount })}
fullWidth
disabled={!isValidated}
>
Next
</Button>
}
>
<Stack spacing={3}>
<Stack spacing={0.5}>
<Typography variant="body5">Address</Typography>
<IdentityKeyFormField
placeholder="Identity Key"
required
fullWidth
initialValue={identityKey}
readOnly
showTickOnValid={false}
/>
</Stack>
<Stack spacing={0.5}>
<Typography variant="body5">Amount</Typography>
<CurrencyFormField
placeholder="Amount"
showCoinMark={false}
required
fullWidth
autoFocus
initialValue={amount.amount}
onChanged={setAmount}
denom={"nym"}
validationError={errorAmount}
/>
</Stack>
<ExplorerListItem row label="Account balance" value={balance} divider />
</Stack>
</SimpleModal>
);
};
export default StakeModal;
@@ -0,0 +1,318 @@
"use client";
import { useNymClient } from "@/hooks/useNymClient";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import {
type MRT_ColumnDef,
MaterialReactTable,
useMaterialReactTable,
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import CountryFlag from "../countryFlag/CountryFlag";
import Loading from "../loading";
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import ConnectWallet from "../wallet/ConnectWallet";
import StakeActions from "./StakeActions";
import type { MappedNymNode, MappedNymNodes } from "./StakeTableWithAction";
import { fee } from "./schemas";
type DelegationWithNodeDetails = {
node: MappedNymNode | undefined;
delegation: Delegation;
};
const ColumnHeading = ({
children,
}: {
children: string | React.ReactNode;
}) => {
return (
<Typography sx={{ py: 2, textAlign: "center" }} variant="h5">
{children}
</Typography>
);
};
const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const { nymClient, address } = useNymClient();
const [delegations, setDelegations] = useState<DelegationWithNodeDetails[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
});
const router = useRouter();
useEffect(() => {
if (!nymClient || !address) return;
// Fetch staking data
const fetchDelegations = async () => {
const data = await nymClient?.getDelegatorDelegations({
delegator: address,
});
return data.delegations;
};
// Combine delegations with node details
const combineDelegationsWithNode = (delegations: Delegation[]) => {
const delegationsWithNodeDetails = delegations.map((delegation) => {
const node = nodes.find((node) => node.nodeId === delegation.node_id);
return {
node,
delegation,
};
});
return delegationsWithNodeDetails;
};
// Fetch and map delegations
const fetchAndMapDelegations = async () => {
const delegations = await fetchDelegations();
const delegationsWithNodeDetails =
combineDelegationsWithNode(delegations);
setDelegations(delegationsWithNodeDetails);
};
fetchAndMapDelegations();
}, [address, nodes, nymClient]);
const handleUnstake = useCallback(
async (nodeId?: number) => {
try {
if (!nodeId || !address) {
return;
}
console.log("Unstaking node", nodeId);
setIsLoading(true);
await nymClient?.undelegate(
{
nodeId,
},
fee,
`Explorer V2: Unstaking node ${nodeId}`,
);
setIsLoading(false);
setInfoModalProps({
open: true,
title: "Success",
message: "This operation can take up to one hour to process",
onClose: () => setInfoModalProps({ open: false }),
});
} catch (e) {
setInfoModalProps({
open: true,
title: "Error",
message:
e instanceof Error
? e.message
: "An error occurred while unstaking",
onClose: () => {
setInfoModalProps({ open: false });
},
});
setIsLoading(false);
}
},
[address, nymClient],
);
const handleActionSelect = useCallback(
(action: string, nodeId: number | undefined) => {
switch (action) {
case "unstake":
handleUnstake(nodeId);
break;
default:
break;
}
},
[handleUnstake],
);
const columns: MRT_ColumnDef<DelegationWithNodeDetails>[] = useMemo(
() => [
{
id: "node",
header: "",
Header: <ColumnHeading>Node</ColumnHeading>,
accessorKey: "bondInformation.node.identity_key",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">
{row.original.node?.bondInformation.node_id || "-"}
</Typography>
<Typography variant="body5">
{row.original.node?.bondInformation.node.identity_key || "-"}
</Typography>
</Stack>
),
},
{
id: "location",
header: "Location",
accessorKey: "location.country_name",
Header: <ColumnHeading>Location</ColumnHeading>,
Cell: ({ row }) =>
row.original.node?.location.two_letter_iso_country_code ? (
<Tooltip title={row.original.node.location.country_name}>
<Box>
<CountryFlag
countryCode={
row.original.node.location.two_letter_iso_country_code
}
countryName={
row.original.node.location.two_letter_iso_country_code
}
/>
</Box>
</Tooltip>
) : (
"-"
),
},
{
id: "stake",
header: "Staked amount",
accessorKey: "delegation.amount.amount",
Header: <ColumnHeading>Stake</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{formatBigNum(+row.original.delegation.amount.amount / 1_000_000)}{" "}
NYM
</Typography>
),
},
{
id: "stakeSaturation",
header: "Stake saturation",
accessorKey: "stakeSaturation",
Header: <ColumnHeading>Stake saturation</ColumnHeading>,
Cell: () => <Typography variant="body4">Unavailable</Typography>,
},
{
id: "profitMarginPercentage",
header: "Profit margin",
accessorKey: "profitMarginPercentage",
Header: <ColumnHeading>Profit margin</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.node?.profitMarginPercentage || 0 * 100}%
</Typography>
),
},
{
id: "action",
header: "Action",
Header: <ColumnHeading>Action</ColumnHeading>,
Cell: ({ row }) => (
<StakeActions
nodeId={row.original.node?.bondInformation.node_id}
onActionSelect={(action) => {
handleActionSelect(
action,
row.original.node?.bondInformation.node_id,
);
}}
/>
),
},
],
[handleActionSelect],
);
const table = useMaterialReactTable({
columns,
data: delegations,
enableRowSelection: false,
enableColumnOrdering: false,
enableColumnActions: false,
enableFullScreenToggle: false,
enableHiding: false,
paginationDisplayMode: "pages",
muiPaginationProps: {
showRowsPerPage: false,
SelectProps: {
sx: {
fontFamily: "labGrotesqueMono",
fontSize: "14px",
},
},
color: "primary",
shape: "circular",
},
sortingFns: {
Favorite: () => {
// TODO implement sorting by favorite
return 0;
},
},
initialState: {
columnPinning: { right: ["Action", "Favorite"] },
},
muiColumnActionsButtonProps: {
sx: {
color: "red",
},
size: "small",
},
muiTablePaperProps: {
elevation: 0,
},
muiTableHeadRowProps: {
sx: {
bgcolor: "background.paper",
},
},
muiTableBodyCellProps: {
sx: {
border: "none",
},
},
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/nym-node/${row.original.node?.nodeId || "not-found"}`);
},
hover: true,
sx: {
":nth-child(odd)": {
bgcolor: "#F3F7FB !important",
},
":nth-child(even)": {
bgcolor: "white !important",
},
cursor: "pointer",
},
}),
});
if (!nymClient || !address) {
return (
<Stack spacing={2} alignItems="center">
<Typography variant="body4">
Please connect your wallet to view your stake
</Typography>
<ConnectWallet hideAddressAndBalance />
</Stack>
);
}
return (
<div>
{isLoading && <Loading />}
<InfoModal {...infoModalProps} />
<MaterialReactTable table={table} />
</div>
);
};
export default StakeTable;
@@ -0,0 +1,31 @@
import getNymNodes from "@/actions/getNymNodes";
import type NymNode from "@/app/api/types";
import StakeTable from "./StakeTable";
const mappedNymNodes = (nodes: NymNode[]) =>
nodes.map((node) => {
return {
nodeId: node.node_id,
bondInformation: node.bond_information,
location: node.location,
profitMarginPercentage:
+node.rewarding_details.cost_params.profit_margin_percent * 100,
description: node.description,
};
});
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
export type MappedNymNode = MappedNymNodes[0];
const StakeTableWithAction = async () => {
try {
const nodes = await getNymNodes();
const data = mappedNymNodes(nodes);
return <StakeTable nodes={data} />;
} catch (error) {
console.error(error);
return null;
}
};
export default StakeTableWithAction;
@@ -0,0 +1,18 @@
import NextEpochTime from "@/components/epochtime/EpochTime";
import Grid2 from "@mui/material/Grid2";
import SubHeaderRowActions from "./SubHeaderRowActions";
const SubHeaderRow = () => {
return (
<Grid2 container spacing={3} alignItems={"center"}>
<Grid2 size={{ xs: 12, sm: "grow" }}>
<NextEpochTime />
</Grid2>
<Grid2 size={{ xs: 12, sm: "auto" }}>
<SubHeaderRowActions />
</Grid2>
</Grid2>
);
};
export default SubHeaderRow;
@@ -0,0 +1,21 @@
"use client";
import { useNymClient } from "@/hooks/useNymClient";
import { Button, Stack } from "@mui/material";
const SubHeaderRowActions = () => {
const { address } = useNymClient();
if (!address) {
return null;
}
return (
<Stack direction="row" spacing={3} justifyContent={"end"}>
<Button variant="outlined">Redeem all rewards</Button>
<Button variant="contained">Stake NYM</Button>
</Stack>
);
};
export default SubHeaderRowActions;
@@ -0,0 +1,17 @@
import { Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
const TotalRewardsCard = () => {
return (
<ExplorerCard label="Total Rewards">
<Typography
variant="h3"
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
- NYM
</Typography>
</ExplorerCard>
);
};
export default TotalRewardsCard;
@@ -0,0 +1,19 @@
"use client";
import { Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
const TotalStakeCard = () => {
return (
<ExplorerCard label="Total Stake">
<Typography
variant="h3"
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
- NYM
</Typography>
</ExplorerCard>
);
};
export default TotalStakeCard;
@@ -0,0 +1,48 @@
import { validateAmount } from "@/utils/currency";
import { z } from "zod";
const MIN_AMOUNT_TO_DELEGATE = "10";
const fee = { gas: "1000000", amount: [{ amount: "1000000", denom: "unym" }] };
const stakingSchema = z
.object({
nodeId: z.number(),
balance: z.string().refine(
async (val) => {
const num = Number.parseFloat(val);
return num > 0;
},
{
message: "Balance must be greater than 0",
},
),
amount: z.string().refine(
async (val) => {
let isValid = false;
isValid = await validateAmount(val, MIN_AMOUNT_TO_DELEGATE.toString());
isValid =
isValid &&
Number.parseFloat(val) >= Number.parseFloat(MIN_AMOUNT_TO_DELEGATE);
return isValid;
},
{
message: "Amount must be greater than 10 NYM",
},
),
})
.extend({})
.refine(
(data) => {
const balance = Number.parseFloat(data.balance);
const amount = Number.parseFloat(data.amount);
console.log(balance);
return balance - amount >= 0;
},
{
message: "Not enough funds",
},
);
export default stakingSchema;
export { MIN_AMOUNT_TO_DELEGATE, fee };
@@ -1,29 +0,0 @@
import { StarOutlineRounded, StarRounded } from "@mui/icons-material/";
import { Rating } from "@mui/material";
export const StarRating = ({
value,
defaultValue,
max = 5,
size = "medium",
}: {
value: number;
defaultValue?: number;
max?: number;
size?: "small" | "medium" | "large";
}) => {
return (
<Rating
size={size}
sx={{ color: "accent.main" }}
value={value}
defaultValue={defaultValue}
max={max}
readOnly
icon={<StarRounded fontSize={size} />}
emptyIcon={
<StarOutlineRounded fontSize={size} sx={{ color: "accent.main" }} />
}
/>
);
};
@@ -1,71 +0,0 @@
import { Box, Button } from "@mui/material";
import type React from "react";
import { useState } from "react";
interface TwoSidedSwitchProps {
leftLabel: string; // Label for the left side
rightLabel: string; // Label for the right side
onSwitch?: (side: "left" | "right") => void; // Callback when switched
}
const TwoSidedSwitch: React.FC<TwoSidedSwitchProps> = ({
leftLabel,
rightLabel,
onSwitch,
}) => {
const [selectedSide, setSelectedSide] = useState<"left" | "right">("left");
const handleSwitch = (side: "left" | "right") => {
setSelectedSide(side);
if (onSwitch) onSwitch(side);
};
return (
<Box
sx={{
display: "flex",
borderRadius: "20px",
overflow: "hidden",
width: "200px",
height: "40px",
}}
>
<Button
onClick={() => handleSwitch("left")}
sx={{
flex: 1,
backgroundColor: selectedSide === "left" ? "black" : "transparent",
color: selectedSide === "left" ? "white" : "black",
border: "1px dashed black",
borderRight: "none",
borderBottomRightRadius: 0,
borderTopRightRadius: 0,
// "&:hover": {
// backgroundColor: selectedSide === "left" ? "black" : "lightgray",
// },
}}
>
{leftLabel}
</Button>
<Button
onClick={() => handleSwitch("right")}
sx={{
flex: 1,
backgroundColor: selectedSide === "right" ? "black" : "transparent",
color: selectedSide === "right" ? "white" : "black",
border: "1px dashed black",
borderLeft: "none",
borderBottomLeftRadius: 0,
borderTopLeftRadius: 0,
// "&:hover": {
// backgroundColor: selectedSide === "right" ? "black" : "lightgray",
// },
}}
>
{rightLabel}
</Button>
</Box>
);
};
export default TwoSidedSwitch;
@@ -0,0 +1,57 @@
"use client";
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { useChain } from "@cosmos-kit/react";
import { Button, type ButtonProps, IconButton, Stack } from "@mui/material";
import Cross from "../icons/Cross";
import { WalletAddress } from "./WalletAddress";
import { WalletBalance } from "./WalletBalance";
interface ButtonPropsWithOnClick extends ButtonProps {
hideAddressAndBalance?: boolean;
onClick?: () => void;
}
const ConnectWallet = ({ ...buttonProps }: ButtonPropsWithOnClick) => {
const { connect, disconnect, address, isWalletConnected } =
useChain(COSMOS_KIT_USE_CHAIN);
const handleConnectWallet = async () => {
buttonProps.onClick?.();
await connect();
};
const handleDisconnectWallet = async () => {
await disconnect();
};
if (isWalletConnected && !buttonProps.hideAddressAndBalance) {
return (
<Stack direction="row" spacing={3}>
<WalletBalance />
<WalletAddress address={address} />
<IconButton
size="small"
onClick={async () => {
await handleDisconnectWallet();
}}
>
<Cross />
</IconButton>
</Stack>
);
}
return (
<Button
fullWidth={buttonProps.fullWidth}
variant="contained"
size={buttonProps.size}
onClick={handleConnectWallet}
>
Connect Wallet
</Button>
);
};
export default ConnectWallet;
@@ -0,0 +1,23 @@
import { Elips } from "@/components/icons/Elips";
import { Stack, Typography } from "@mui/material";
import React from "react";
export const trimAddress = (address = "", trimBy = 6) =>
`${address.slice(0, trimBy)}...${address.slice(-trimBy)}`;
export const WalletAddress = ({ address }: { address?: string }) => {
if (!address) {
return null;
}
const displayAddress = trimAddress(address, 7);
return (
<Stack direction="row" alignItems="center" gap={0.5}>
<Elips />
<Typography variant="h5" fontWeight={400}>
{displayAddress}
</Typography>
</Stack>
);
};
@@ -0,0 +1,17 @@
import { Token } from "@/components/icons/Token";
import useGetWalletBalance from "@/hooks/useGetWalletBalance";
import { Stack, Typography } from "@mui/material";
import React from "react";
export const WalletBalance = () => {
const { formattedBalance } = useGetWalletBalance();
return (
<Stack direction="row" alignItems="center" gap={1}>
<Token />
<Typography variant="h5" fontWeight={400}>
{formattedBalance} NYM
</Typography>
</Stack>
);
};
+6
View File
@@ -0,0 +1,6 @@
export const COSMOS_KIT_USE_CHAIN =
process.env.NEXT_PUBLIC_COSMOS_KIT_USE_CHAIN || "sandbox";
export const NYM_MIXNET_CONTRACT =
process.env.NYM_MIXNET_CONTRACT ||
"n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr";
@@ -0,0 +1,46 @@
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { unymToNym } from "@/utils/currency";
import { useChain } from "@cosmos-kit/react";
import { useCallback, useEffect, useState } from "react";
const useGetWalletBalance = () => {
const [balance, setBalance] = useState<string>("0");
const [formattedBalance, setFormattedBalance] = useState<string>("-");
const { getCosmWasmClient, address } = useChain(COSMOS_KIT_USE_CHAIN);
const getNYMBalance = useCallback(
async (address: string) => {
const account = await getCosmWasmClient();
const uNYMBalance = await account.getBalance(address, "unym");
const NYMBalance = unymToNym(uNYMBalance.amount);
if (!NYMBalance) {
return undefined;
}
const formattedBalance = Intl.NumberFormat().format(+NYMBalance);
return {
NYMBalance,
formattedBalance,
};
},
[getCosmWasmClient],
);
useEffect(() => {
if (!address) {
return;
}
getNYMBalance(address)
.then((balance) => {
setFormattedBalance(balance?.formattedBalance || "-");
setBalance(balance?.NYMBalance || "0");
})
.catch((e) => {
console.error("Failed to get balance", e);
});
}, [address, getNYMBalance]);
return { balance, formattedBalance };
};
export default useGetWalletBalance;
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "@/config";
import { useChain } from "@cosmos-kit/react";
import { contracts } from "@nymproject/contract-clients";
import type {
MixnetClient,
MixnetQueryClient,
} from "@nymproject/contract-clients/Mixnet.client";
import { useEffect, useState } from "react";
export const useNymClient = () => {
const [nymClient, setNymClient] = useState<MixnetClient>();
const [nymQueryClient, setNymQueryClient] = useState<MixnetQueryClient>();
const { address, getCosmWasmClient, getSigningCosmWasmClient } =
useChain(COSMOS_KIT_USE_CHAIN);
useEffect(() => {
if (address) {
const init = async () => {
const cosmWasmSigningClient = await getSigningCosmWasmClient();
const cosmWasmClient = await getCosmWasmClient();
const client = new contracts.Mixnet.MixnetClient(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cosmWasmSigningClient as any,
address,
NYM_MIXNET_CONTRACT,
);
const queryClient = new contracts.Mixnet.MixnetQueryClient(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cosmWasmClient as any,
NYM_MIXNET_CONTRACT,
);
setNymClient(client);
setNymQueryClient(queryClient);
};
init();
}
}, [address, getCosmWasmClient, getSigningCosmWasmClient]);
return { nymClient, nymQueryClient, address };
};
@@ -0,0 +1,66 @@
"use client";
import type { AssetList, Chain } from "@chain-registry/types";
import { wallets as keplr } from "@cosmos-kit/keplr-extension";
import { ChainProvider } from "@cosmos-kit/react";
import { assets, chains } from "chain-registry";
import React from "react";
const nymSandbox: Chain = {
chain_type: "cosmos",
chain_name: "sandbox",
chain_id: "sandbox",
bech32_prefix: "n",
network_type: "devnet",
pretty_name: "Nym Sandbox",
status: "live",
slip44: 118,
apis: {
rpc: [
{
address: "https://rpc.sandbox.nymtech.net",
},
],
},
};
const nymSandboxAssets = {
chain_name: "sandbox",
assets: [
{
name: "Nym",
base: "unym",
symbol: "NYM",
display: "NYM",
denom_units: [],
},
],
};
const CosmosKitProvider = ({ children }: { children: React.ReactNode }) => {
// Only use the nyx chains
const chainsWithNyx = React.useMemo(() => {
const nyx = chains.find((chain) => chain.chain_id === "nyx");
return nyx ? [nymSandbox, nyx] : [nymSandbox];
}, []);
// Only use the nyx assets
const assetsWithNyx = React.useMemo(() => {
const nyx = assets.find((asset) => asset.chain_name === "nyx");
return nyx ? [nymSandboxAssets, nyx] : [nymSandboxAssets];
}, []) as AssetList[];
return (
<ChainProvider
chains={chainsWithNyx}
assetLists={assetsWithNyx}
wallets={[...keplr]}
>
{children}
</ChainProvider>
);
};
export default CosmosKitProvider;
+6 -1
View File
@@ -1,7 +1,12 @@
import CosmosKitProvider from "./CosmosKitProvider";
import ThemeProvider from "./ThemeProvider";
const Providers = ({ children }: { children: React.ReactNode }) => {
return <ThemeProvider>{children}</ThemeProvider>;
return (
<ThemeProvider>
<CosmosKitProvider>{children}</CosmosKitProvider>
</ThemeProvider>
);
};
export default Providers;
+1 -5
View File
@@ -168,11 +168,7 @@ const getTheme = (mode: "light" | "dark"): ThemeOptions => {
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: "32px",
},
},
styleOverrides: {},
},
MuiButton: {
styleOverrides: {
+55
View File
@@ -0,0 +1,55 @@
import type { DecCoin } from "@nymproject/types";
import Big from "big.js";
export const isValidRawCoin = (rawAmount: string): boolean => {
const amountFloat = Number.parseFloat(rawAmount);
// if value is a decimal it cannot have more than 6 decimal places
if (amountFloat % 1 > 0) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/naming-convention
const [_, numsAfterDecimal] = rawAmount.split(".");
if (+numsAfterDecimal.length > 6) {
return false;
}
}
// it cannot be larger than the total supply
if (amountFloat > 1_000_000_000) {
return false;
}
// it can't be lower than one micro coin
if (amountFloat < 0.000001) {
return false;
}
return true;
};
export const unymToNym = (unym: string | number | Big, dp = 4) => {
try {
const nym = Big(unym).div(1_000_000).toFixed(dp);
return nym;
} catch (e: unknown) {
console.warn(`${unym} not a valid decimal number: ${e}`);
}
};
export const validateAmount = async (
majorAmountAsString: DecCoin["amount"],
minimumAmountAsString: DecCoin["amount"],
): Promise<boolean> => {
// tests basic coin value requirements, like no more than 6 decimal places, value lower than total supply, etc
if (!Number(majorAmountAsString)) {
return false;
}
if (!isValidRawCoin(majorAmountAsString)) {
return false;
}
const majorValueFloat = Number.parseInt(majorAmountAsString, Number(10));
return majorValueFloat >= Number.parseInt(minimumAmountAsString, Number(10));
};
+2 -1
View File
@@ -20,7 +20,8 @@
],
"paths": {
"@/public/*": ["./public/*"],
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@assets/*": ["../assets/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+3 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useChain } from '@cosmos-kit/react';
import { contracts } from '@nymproject/contract-clients';
import { MixnetClient, MixnetQueryClient } from '@nymproject/contract-clients/Mixnet.client';
import type { MixnetClient, MixnetQueryClient } from '@nymproject/contract-clients/Mixnet.client';
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from '@src/api/constants';
export const useNymClient = (address?: string) => {
@@ -15,8 +15,9 @@ export const useNymClient = (address?: string) => {
const init = async () => {
const cosmWasmSigningClient = await getSigningCosmWasmClient();
const cosmWasmClient = await getCosmWasmClient();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = new contracts.Mixnet.MixnetClient(cosmWasmSigningClient as any, address, NYM_MIXNET_CONTRACT);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queryClient = new contracts.Mixnet.MixnetQueryClient(cosmWasmClient as any, NYM_MIXNET_CONTRACT);
setNymClient(client);
+2 -2
View File
@@ -43,7 +43,7 @@
"lodash": "^4.17.21",
"notistack": "^2.0.3",
"npm-run-all": "^4.1.5",
"qrcode.react": "^1.0.1",
"qrcode.react": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.3",
@@ -125,4 +125,4 @@
"webpack-merge": "^5.8.0"
},
"private": false
}
}
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { Box, Stack, SxProps } from '@mui/material';
import QRCode from 'qrcode.react';
import { QRCodeSVG } from 'qrcode.react';
import { ClientAddress } from '@nymproject/react/client-address/ClientAddress';
import { ModalListItem } from '../Modals/ModalListItem';
import { SimpleModal } from '../Modals/SimpleModal';
@@ -57,7 +57,7 @@ export const ReceiveModal = ({
p: 3,
}}
>
{clientDetails && <QRCode data-testid="qr-code" value={clientDetails?.client_address} />}
{clientDetails && <QRCodeSVG data-testid="qr-code" value={clientDetails?.client_address} />}
</Box>
</Stack>
</Stack>
+2391 -2166
View File
File diff suppressed because it is too large Load Diff