Files
nym/explorer-nextjs/src/components/staking/StakeTable.tsx
T
Yana Matrosova 9aed938c79 Yana/pending staking events (#5400)
* Add pending events to stake table

* Add additional tanstack refetch on user stake/unstake/redeem rewards

* refactor repeated fetch functions

* clean up fetching functions

* refactor & clean up

* Add epoch waiting message on epoch change delay

* fine tune epoch change

* clean up

* refactor imports

* Add transaction hash to successful redeem rewards InfoModal

* fix epoch time check and mobile header

* Fix Loading modal width on mobile

* fix epoch logic

* clean up

* clean up logs

* add waiting for epoch to start to landing page

* clean up

* Add refetch of all queries on epoch change

* Finalise state change on epoch change

* clean up

* fix build

* Fix NodesTable mobile view

* Fix stake table mobile view

* fix typo

* Fix blog articles height

* Add loading skeletons to landing page cards

* clean up

* Add skeletons to cards

* Add skeletons, and loading/error refetch on wallet balnce

* clean up

* Add active stakers card

* clean up

* change NGM to mixnet

* Add TVL to Tokenomics card

* Add last total stake to Stake Card

* clean up

* Fix stake sorting function in Stake Table

* Add wrap of identity key and address to Basic Info Card

* Add counter to epoch time on staking page

* clean up

* update epoch labels

* Add circular loading on Toggle Button

* Update Toggle button loading functionality

* Add skeletons to account cards

* Add search functionality on Enter

* clean up

* DOMpurify node name and description

* Add column with id and identity key, wrap names to 2 lines

* Set width of column headers to 110px

* fix pending events for delegations

* Fix Stake button proppagation

* Add full country name to tooltips

* Take out connect wallet from mobile menu toggle

* finetune epoch change intervals

* Add error text to Magic Search

* fix build

* Add react-markdown for Blog articles

* fix graph's width and Table column headings

* fix Magic Search loading

* Fix grid on account page

* fix account card address width

* Fix permanent loading spinner on ToggleButton

* clean up URL's, fix copy address on the Basic Card

* replace mintscan with ping, open tx link on new page

* Take out toggle button if no node bonded by address

* Set fixed column width on tables

* Add not-found page to account, when no node bonded

* Add full country name to tables and node profile card

* clean up

* Table fixes

* Fix sorting in Delegations table Node page

* clean up

* Fix line chart view

* refactor epoch progress bar

* remove unused imports

* remove tanstack delclaration module

* create epoch data provider

* remove logic from togglebutton component

* use epoch provider in components

* invalidateQueries should be awaited

* tidy up QualityIndicatorsCard component formatting

* fix infinite loop in epoch provider

---------

Co-authored-by: Yana <yanok87@users.noreply.github.com>
Co-authored-by: fmtabbara <fmtabbara@hotmail.co.uk>
2025-02-12 13:15:45 +00:00

659 lines
18 KiB
TypeScript

"use client";
import { useChain } from "@cosmos-kit/react";
import {
Box,
Button,
Chip,
Stack,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
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 usePendingEvents, {
type PendingEvent,
} from "../../../src/hooks/useGetPendingEvents";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite } from "../favorite/Favorite";
import Loading from "../loading";
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import { Link } from "../muiLink";
import ConnectWallet from "../wallet/ConnectWallet";
import StakeActions from "./StakeActions";
import StakeModal from "./StakeModal";
import type { MappedNymNode, MappedNymNodes } from "./StakeTableWithAction";
import { fee } from "./schemas";
type DelegationWithNodeDetails = {
node: MappedNymNode | undefined;
delegation: Delegation;
pendingEvent?: PendingEvent;
};
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 StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const { nymClient, address, nymQueryClient } = useNymClient();
const [delegations, setDelegations] = useState<DelegationWithNodeDetails[]>(
[],
);
const [isDataLoading, setIsLoading] = useState(false);
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
});
const [selectedNodeForStaking, setSelectedNodeForStaking] = useState<{
nodeId: number;
identityKey: string;
}>();
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { data: pendingEvents } = usePendingEvents(nymQueryClient, address);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const router = useRouter();
const queryClient = useQueryClient();
// Custom Hook for fetching pending events
const handleRefetch = useCallback(async () => {
await queryClient.invalidateQueries();
}, [queryClient]);
useEffect(() => {
if (!nymClient || !address || !nymQueryClient) return;
// Fetch staking data
const fetchDelegations = async () => {
const data = await nymClient?.getDelegatorDelegations({
delegator: address,
});
return data.delegations;
};
// Combine delegations with node details and pending events
const combineDelegationsWithNodeAndPendingEvents = (
delegations: Delegation[],
nodes: MappedNymNode[],
pendingEvents: PendingEvent[] | undefined,
) => {
// Combine delegations with node details
const delegationsWithNodeDetails = delegations.map((delegation) => {
const node = nodes.find((node) => node.nodeId === delegation.node_id);
const pendingEvent = pendingEvents?.find(
(event) => event?.mixId === delegation.node_id,
);
console.log("node,delegation,pendingEvent,:>> ", {
node,
delegation,
pendingEvent,
});
return {
node,
delegation,
pendingEvent,
};
});
// Add pending events that are not in the delegations list
if (pendingEvents) {
for (const e of pendingEvents) {
if (
e &&
!delegationsWithNodeDetails.find(
(item) =>
item.node?.nodeId === e.mixId ||
item.delegation.node_id === e.mixId,
)
) {
delegationsWithNodeDetails.push({
node: {
name: "-",
nodeId: e.mixId,
identity_key: "-",
countryCode: null,
countryName: null,
profitMarginPercentage: 0,
owner: "-",
stakeSaturation: 0,
},
pendingEvent: e,
delegation: {
amount: {
amount: e.amount?.amount || "0",
denom: "unym",
},
cumulative_reward_ratio: "0",
height: 0,
node_id: e.mixId,
owner: "-",
},
});
}
}
}
return delegationsWithNodeDetails;
};
// Fetch and map delegations
const fetchAndMapDelegations = async () => {
const delegations = await fetchDelegations();
const delegationsWithNodeDetails =
combineDelegationsWithNodeAndPendingEvents(
delegations,
nodes,
pendingEvents,
);
setDelegations(delegationsWithNodeDetails);
};
fetchAndMapDelegations();
}, [address, nodes, nymClient, nymQueryClient, pendingEvents]);
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 });
},
});
} 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(
(nodeId: number, nodeIdentityKey: string | undefined) => {
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;
}
if (nodeIdentityKey) {
setSelectedNodeForStaking({
nodeId: nodeId,
identityKey: nodeIdentityKey,
});
}
},
[isWalletConnected],
);
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);
await handleRefetch();
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, handleRefetch],
);
const handleActionSelect = useCallback(
(action: string, nodeId: number, nodeIdentityKey: string | undefined) => {
switch (action) {
case "stake":
handleOnSelectStake(nodeId, nodeIdentityKey);
break;
case "unstake":
handleUnstake(nodeId);
break;
default:
break;
}
},
[handleUnstake, handleOnSelectStake],
);
const getTooltipTitle = useCallback(
(pending: PendingEvent) => {
if (pending?.kind === "undelegate") {
return "You have an undelegation pending";
}
if (pending?.kind === "delegate") {
return `You have a delegation pending worth ${formatBigNum(
+pending.amount.amount / 1_000_000,
)} NYM`;
}
return undefined;
},
[], // Add dependencies if necessary
);
// get full country name
const countryName = useCallback((countryCode: string) => {
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
return <span>{regionNames.of(countryCode)}</span>;
}, []);
const columns: MRT_ColumnDef<DelegationWithNodeDetails>[] = useMemo(
() => [
{
id: "name",
header: "",
Header: <ColumnHeading>Name</ColumnHeading>,
accessorKey: "node.name",
Cell: ({ row }) =>
row.original.node?.name ? (
<Stack spacing={1}>
<Typography variant="body4">{row.original.node.name}</Typography>
</Stack>
) : (
"-"
),
},
{
id: "id",
header: "",
Header: <ColumnHeading>Node ID</ColumnHeading>,
accessorKey: "delegation.node_id",
size: 90,
Cell: ({ row }) =>
row.original.delegation?.node_id ? (
<Typography variant="body4">
{row.original.delegation.node_id || "-"}
</Typography>
) : (
"-"
),
},
{
id: "identity_key",
header: "",
Header: <ColumnHeading>Identity Key</ColumnHeading>,
accessorKey: "delegation.node.identity_key",
Cell: ({ row }) =>
row.original.node?.identity_key ? (
<Typography variant="body4">
<Stack spacing={1}>
{row.original.node?.identity_key || "-"}
</Stack>
</Typography>
) : (
"-"
),
},
{
id: "location",
header: "Location",
accessorKey: "node.countryCode",
size: 160,
Header: <ColumnHeading>Location</ColumnHeading>,
Cell: ({ row }) =>
row.original.node?.countryCode && row.original.node?.countryName ? (
<Box>
<CountryFlag
countryCode={row.original.node.countryCode}
countryName={countryName(row.original.node?.countryName) || ""}
/>
</Box>
) : (
"-"
),
},
{
id: "stake",
header: "Staked amount",
accessorKey: "delegation.amount.amount",
Header: <ColumnHeading>Stake</ColumnHeading>,
size: 80,
sortingFn: (rowA, rowB) => {
const stakeA = Number.parseFloat(
rowA.original.delegation.amount.amount,
);
const stakeB = Number.parseFloat(
rowB.original.delegation.amount.amount,
);
return stakeA - stakeB;
},
Cell: ({ row }) => (
<Typography variant="body4">
{formatBigNum(+row.original.delegation.amount.amount / 1_000_000)}{" "}
NYM
</Typography>
),
},
{
id: "stakeSaturation",
header: "Stake saturation",
accessorKey: "node.stakeSaturation",
size: 200,
Header: <ColumnHeading>Stake saturation</ColumnHeading>,
sortingFn: (rowA, rowB) => {
const saturationA = rowA.original.node?.stakeSaturation || 0;
const saturationB = rowB.original.node?.stakeSaturation || 0;
console.log("sorting :>> ", saturationA, saturationB);
return saturationA - saturationB;
},
Cell: ({ row }) =>
row.original.node?.stakeSaturation ? (
<Typography variant="body4">
{row.original.node.stakeSaturation}%
</Typography>
) : (
<Typography variant="body4">{0}%</Typography>
),
},
{
id: "Favorite",
header: "Favorite",
accessorKey: "Favorite",
enableColumnFilter: false,
size: 80,
Header: (
<Stack direction="row" alignItems="center">
<ColumnHeading>Favorite</ColumnHeading>
</Stack>
),
sortingFn: (rowA, rowB) => {
const isFavoriteA = favorites.includes(
rowA.original.node?.owner || "-",
);
const isFavoriteB = favorites.includes(
rowB.original.node?.owner || "-",
);
// Sort favorites first
if (isFavoriteA && !isFavoriteB) return -1;
if (!isFavoriteA && isFavoriteB) return 1;
// If both are favorites or neither, keep the original order
return 0;
},
Cell: ({ row }) => (
<Favorite address={row.original.node?.owner || ""} />
),
},
{
id: "action",
header: "Action",
Header: <ColumnHeading>Action</ColumnHeading>,
size: 80,
enableColumnFilter: false,
Cell: ({ row }) => {
return (
<Box>
{row.original.pendingEvent ? (
<Tooltip
placement="left"
title={getTooltipTitle(row.original.pendingEvent)}
onClick={(e) => e.stopPropagation()}
>
<Chip size="small" label="Pending events" />
</Tooltip>
) : (
<StakeActions
nodeId={row.original.delegation?.node_id}
nodeIdentityKey={row.original.node?.identity_key}
onActionSelect={(action) => {
handleActionSelect(
action,
row.original.delegation?.node_id,
row.original.node?.identity_key || undefined,
);
}}
/>
)}
</Box>
);
},
},
],
[handleActionSelect, favorites, getTooltipTitle, countryName],
);
const table = useMaterialReactTable({
columns,
data: delegations,
enableRowSelection: false,
enableColumnOrdering: false,
enableColumnActions: false,
enableFullScreenToggle: false,
enableHiding: false,
paginationDisplayMode: "pages",
renderEmptyRowsFallback: () => (
<Stack
gap={3}
sx={{ p: 5 }}
justifyContent={isMobile ? "flex-start" : "center"}
alignItems={isMobile ? "flex-start" : "center"}
>
<Typography variant="body3" width={isMobile ? 300 : "unset"}>
You haven&apos;t staked on any nodes yet. Stake on a node to start
earning rewards.
</Typography>
<Button
variant="contained"
size="large"
onClick={(e) => e.stopPropagation()}
>
<Link href="/explorer" underline="none" color="inherit">
Stake
</Link>
</Button>
</Stack>
),
muiPaginationProps: {
showRowsPerPage: false,
SelectProps: {
sx: {
fontFamily: "labGrotesqueMono",
fontSize: "14px",
},
},
color: "primary",
shape: "circular",
},
initialState: {
columnPinning: isMobile ? {} : { right: ["Action", "Favorite"] }, // No pinning on mobile
},
muiColumnActionsButtonProps: {
sx: {
color: "red",
},
size: "small",
},
muiTablePaperProps: {
elevation: 0,
},
muiTableHeadCellProps: {
sx: {
alignItems: "center",
},
},
muiTableHeadRowProps: {
sx: {
bgcolor: "background.paper",
},
},
muiTableBodyCellProps: {
sx: {
border: "none",
whiteSpace: "unset", // Allow text wrapping in body cells
wordBreak: "break-word",
},
},
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>
{isDataLoading && <Loading />}
<StakeModal
nodeId={selectedNodeForStaking?.nodeId}
identityKey={selectedNodeForStaking?.identityKey}
onStake={handleStakeOnNode}
onClose={() => setSelectedNodeForStaking(undefined)}
/>
<InfoModal {...infoModalProps} />
<MaterialReactTable table={table} />
</div>
);
};
export default StakeTable;