"use client"; import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material"; import { useQuery } from "@tanstack/react-query"; import DOMPurify from "isomorphic-dompurify"; import { useEffect, useState } from "react"; import { fetchEpochRewards, fetchNSApiNodes, getRecommendedNodes, } from "../../app/api"; import type { ExplorerData, NS_NODE } from "../../app/api/types"; import { countryName } from "../../utils/countryName"; import AdvancedFilters from "./AdvancedFilters"; import NodeTable from "./NodeTable"; function getNodeSaturationPoint( totalStake: number, stakeSaturationPoint: string, ): number { const saturation = Number.parseFloat(stakeSaturationPoint); if (Number.isNaN(saturation) || saturation <= 0) { throw new Error("Invalid stake saturation point provided"); } const ratio = (totalStake / saturation) * 100; return Number(ratio.toFixed()); } const mappedNSApiNodes = ( nodes: NS_NODE[], epochRewardsData: ExplorerData["currentEpochRewardsData"], ) => nodes .map((node) => { const nodeSaturationPoint = getNodeSaturationPoint( +node.total_stake, epochRewardsData.interval.stake_saturation_point, ); const cleanMoniker = DOMPurify.sanitize(node.description.moniker).replace( /&/g, "&", ); const selfBondFormatted = node.original_pledge ? Number(node.original_pledge) / 1_000_000 : 0; const operatingCostsFormatted = node.rewarding_details ? Number( node.rewarding_details.cost_params.interval_operating_cost.amount, ) / 1_000_000 : 0; return { name: cleanMoniker, nodeId: node.node_id, identity_key: node.identity_key, countryCode: node.geoip?.country || null, countryName: countryName(node.geoip?.country || null) || null, selfBond: selfBondFormatted, operatingCosts: operatingCostsFormatted, profitMarginPercentage: node.rewarding_details ? +node.rewarding_details.cost_params.profit_margin_percent * 100 : 0, owner: node.bonding_address, stakeSaturation: nodeSaturationPoint, qualityOfService: +node.uptime * 100, mixnode: node.self_description?.declared_role.mixnode === true, gateway: node.self_description?.declared_role.entry === true || node.self_description?.declared_role.exit_ipr === true || node.self_description?.declared_role.exit_nr === true, }; }) .sort((a, b) => { if (!a.countryName && !b.countryName) return 0; if (!a.countryName) return 1; if (!b.countryName) return -1; return a.countryName.localeCompare(b.countryName); }); export type MappedNymNodes = ReturnType; export type MappedNymNode = MappedNymNodes[0]; const NodeTableWithAction = () => { // All hooks at the top! const [activeFilter, setActiveFilter] = useState< "all" | "mixnodes" | "gateways" | "recommended" >(() => { const stored = sessionStorage.getItem("nodeTableActiveFilter"); return ( (stored as "all" | "mixnodes" | "gateways" | "recommended") || "recommended" ); }); const [uptime, setUptime] = useState<[number, number]>(() => { const stored = sessionStorage.getItem("nodeTableUptime"); return stored ? JSON.parse(stored) : [0, 100]; }); const [saturation, setSaturation] = useState<[number, number]>([0, 100]); const [profitMargin, setProfitMargin] = useState<[number, number]>(() => { const stored = sessionStorage.getItem("nodeTableProfitMargin"); return stored ? JSON.parse(stored) : [0, 100]; }); const [advancedOpen, setAdvancedOpen] = useState(() => { const stored = sessionStorage.getItem("nodeTableAdvancedOpen"); return stored ? JSON.parse(stored) : false; }); // Wrapper functions to handle filter changes and sessionStorage const handleActiveFilterChange = ( newFilter: "all" | "mixnodes" | "gateways" | "recommended" ) => { setActiveFilter(newFilter); sessionStorage.setItem("nodeTableActiveFilter", newFilter); }; const handleUptimeChange = (newUptime: [number, number]) => { setUptime(newUptime); sessionStorage.setItem("nodeTableUptime", JSON.stringify(newUptime)); }; const handleSaturationChange = (newSaturation: [number, number]) => { setSaturation(newSaturation); sessionStorage.setItem( "nodeTableSaturation", JSON.stringify(newSaturation) ); }; const handleProfitMarginChange = (newProfitMargin: [number, number]) => { setProfitMargin(newProfitMargin); sessionStorage.setItem( "nodeTableProfitMargin", JSON.stringify(newProfitMargin) ); }; const handleAdvancedOpenChange = (newAdvancedOpen: boolean) => { setAdvancedOpen(newAdvancedOpen); sessionStorage.setItem( "nodeTableAdvancedOpen", JSON.stringify(newAdvancedOpen) ); }; // Use React Query to fetch epoch rewards const { data: epochRewardsData, isLoading: isEpochLoading, isError: isEpochError, } = useQuery({ queryKey: ["epochRewards"], queryFn: fetchEpochRewards, staleTime: 10 * 60 * 1000, // 10 minutes refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, }); // Use React Query to fetch Nym nodes const { data: nsApiNodes = [], isLoading: isNSApiNodesLoading, isError: isNSApiNodesError, } = useQuery({ queryKey: ["nsApiNodes"], queryFn: fetchNSApiNodes, staleTime: 10 * 60 * 1000, // 10 minutes refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, }); const recommendedIds = getRecommendedNodes(nsApiNodes); // Map nodes with rewards data const nsApiNodesData = epochRewardsData ? mappedNSApiNodes(nsApiNodes || [], epochRewardsData) : []; // Calculate max saturation from all nodes const maxSaturation = Math.max( 100, ...nsApiNodesData.map((n) => n.stakeSaturation || 0) ); // Initialize saturation from sessionStorage or set to maxSaturation when data is loaded useEffect(() => { const stored = sessionStorage.getItem("nodeTableSaturation"); if (stored) { setSaturation(JSON.parse(stored)); } else if (nsApiNodesData.length > 0) { setSaturation([0, maxSaturation]); } }, [maxSaturation, nsApiNodesData.length]); // Calculate node counts for each type const nodeCounts = { all: nsApiNodesData.length, mixnodes: nsApiNodesData.filter((node) => node.mixnode).length, gateways: nsApiNodesData.filter((node) => node.gateway).length, }; // Handle loading state if (isEpochLoading || isNSApiNodesLoading) { return ( ); } // Handle error state if (isEpochError || isNSApiNodesError) { return ( Error loading data. Please try again later. ); } if (!epochRewardsData) { return null; } // Step 1: Filter nodes by type const typeFilteredNodes = nsApiNodesData.filter((node) => { switch (activeFilter) { case "mixnodes": return node.mixnode; case "gateways": return node.gateway; case "recommended": return recommendedIds.includes(node.nodeId); default: return true; } }); // Step 2: Apply advanced filters if open (but only if sliders moved from defaults) const isDefault = { uptime: uptime[0] === 0 && uptime[1] === 100, saturation: saturation[0] === 0 && saturation[1] === maxSaturation, profitMargin: profitMargin[0] === 0 && profitMargin[1] === 100, }; const filteredNodes = advancedOpen ? typeFilteredNodes.filter((node) => { const uptimeMatch = isDefault.uptime || (node.qualityOfService >= uptime[0] && node.qualityOfService <= uptime[1]); const saturationMatch = isDefault.saturation || (node.stakeSaturation >= saturation[0] && node.stakeSaturation <= saturation[1]); const profitMarginMatch = isDefault.profitMargin || (node.profitMarginPercentage >= profitMargin[0] && node.profitMarginPercentage <= profitMargin[1]); return uptimeMatch && saturationMatch && profitMarginMatch; }) : typeFilteredNodes; const recommendedCount = recommendedIds.length; return ( ); }; export default NodeTableWithAction;