This commit is contained in:
Yana
2024-12-11 15:38:27 +07:00
parent bc849c7c46
commit c2086eef9e
18 changed files with 2894 additions and 128 deletions
+245
View File
@@ -0,0 +1,245 @@
import {
CIRCULATING_NYM_SUPPLY,
CURRENT_EPOCH,
CURRENT_EPOCH_REWARDS,
HARBOURMASTER_API_MIXNODES_STATS,
HARBOURMASTER_API_SUMMARY,
} from "./urls";
type Denom = "unym" | "nym";
export interface IPacketsAndStakingData {
date_utc: string;
total_packets_received: number;
total_packets_sent: number;
total_packets_dropped: number;
total_stake: number;
}
export interface ExplorerData {
circulatingNymSupplyData: {
circulating_supply: { denom: Denom; amount: string };
mixmining_reserve: { denom: Denom; amount: string };
total_supply: { denom: Denom; amount: string };
vesting_tokens: { denom: Denom; amount: string };
};
nymNodesData: {
gateways: {
bonded: { count: number; last_updated_utc: string };
blacklisted: { count: number; last_updated_utc: string };
historical: { count: number; last_updated_utc: string };
explorer: { count: number; last_updated_utc: string };
};
mixnodes: {
bonded: {
count: number;
active: number;
inactive: number;
reserve: number;
last_updated_utc: string;
};
blacklisted: {
count: number;
last_updated_utc: string;
};
historical: { count: number; last_updated_utc: string };
};
};
packetsAndStakingData: IPacketsAndStakingData[];
currentEpochData: {
id: number;
current_epoch_id: number;
current_epoch_start: string;
epoch_length: { secs: number; nanos: number };
epochs_in_interval: number;
total_elapsed_epochs: number;
};
currentEpochRewardsData: {
interval: {
reward_pool: string;
staking_supply: string;
staking_supply_scale_factor: string;
epoch_reward_budget: string;
stake_saturation_point: string;
active_set_work_factor: string;
interval_pool_emission: string;
sybil_resistance: string;
};
rewarded_set: {
entry_gateways: number;
exit_gateways: number;
mixnodes: number;
standby: number;
};
};
}
export interface ExplorerCache {
data?: ExplorerData;
lastUpdated?: Date;
}
declare global {
// Extend the global object with our custom property
let explorerCache: ExplorerCache | undefined;
}
const CACHE_TIME_SECONDS = 60 * 5; // 5 minutes
const getExplorerData = async () => {
// FETCH NYMNODES
const fetchNymNodes = await fetch(HARBOURMASTER_API_SUMMARY, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CURRENT EPOCH
const fetchCurrentEpoch = await fetch(CURRENT_EPOCH, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CURRENT EPOCH REWARDS
const fetchCurrentEpochRewards = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CIRCULATING NYM SUPPLY
const fetchCirculatingNymSupply = await fetch(CIRCULATING_NYM_SUPPLY, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH PACKETS AND STAKING
const fetchPacketsAndStaking = await fetch(HARBOURMASTER_API_MIXNODES_STATS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
const [
circulatingNymSupplyRes,
nymNodesRes,
packetsAndStakingRes,
currentEpochRes,
currentEpochRewardsRes,
] = await Promise.all([
fetchCirculatingNymSupply,
fetchNymNodes,
fetchPacketsAndStaking,
fetchCurrentEpoch,
fetchCurrentEpochRewards,
]);
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
] = await Promise.all([
circulatingNymSupplyRes.json(),
nymNodesRes.json(),
packetsAndStakingRes.json(),
currentEpochRes.json(),
currentEpochRewardsRes.json(),
]);
return [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
];
};
export async function ensureCacheExists() {
// makes sure the cache exists in global memory
let doUpdate = false;
const now = new Date();
if (!global.explorerCache) {
global.explorerCache = {};
doUpdate = true;
}
if (
global.explorerCache.lastUpdated &&
now.getDate() - global.explorerCache.lastUpdated.getDate() >
CACHE_TIME_SECONDS
) {
doUpdate = true;
}
// if the cache has expired or never existed, get it from API's
if (doUpdate) {
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
] = await getExplorerData();
packetsAndStakingData.pop();
global.explorerCache.data = {
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
};
global.explorerCache.lastUpdated = now;
}
}
export async function getCacheExplorerData() {
await ensureCacheExists();
if (!global.explorerCache?.data) {
return null;
}
console.log("global.explorerCache.data :>> ", global.explorerCache.data);
return global.explorerCache.data || null;
}
/**
* This is a custom API route that returns metadata from Strapi about images: height, width, strapi download url.
*
* The response from Strapi is cached in memory for CACHE_TIME_SECONDS.
*/
// export default async function handler(
// req: NextApiRequest,
// res: NextApiResponse
// ) {
// // return cached data
// const data = await getCacheExplorerData();
// if (data) {
// res.status(200).json(data);
// res.end();
// }
// // catch-all
// res.status(404).end();
// }
+18
View File
@@ -0,0 +1,18 @@
export const HARBOURMASTER_API_SUMMARY =
"https://harbourmaster.nymtech.net/v2/summary";
export const EXPLORER_API = "https://explorer.nymtech.net/api/v1/countries";
export const VALIDATOR_API_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
export const COSMOS_API =
"https://api.nymtech.net/cosmos/bank/v1beta1/balances/n1a53udazy8ayufvy0s434pfwjcedzqv34yg485t";
export const VALIDATOR_API_EPOCH =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const HARBOURMASTER_API_MIXNODES_STATS =
"https://harbourmaster.nymtech.net/v2/mixnodes/stats";
export const HARBOURMASTER_API_BASE = "https://harbourmaster.nymtech.net";
export const CURRENT_EPOCH =
" https://validator.nymtech.net/api/v1/epoch/current";
export const CURRENT_EPOCH_REWARDS =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const CIRCULATING_NYM_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
+1
View File
@@ -0,0 +1 @@
export const TABLET_WIDTH = "(min-width:700px)";
+65
View File
@@ -5,10 +5,35 @@ import ProgressBar from "@/components/RatingMeter/RatingMeter";
import StarRarating from "@/components/StarRating/StarRating";
import CopyFile from "@/components/icons/CopyFile";
import Gateway from "@/components/icons/Gateway";
("use client");
import TwoSidedSwitch from "@/components/TwoSidedButtonSwitch";
import {
AccountStatsCard,
type IAccountStatsCardProps,
} from "@/components/cards/AccountStatsCard";
import { CurrentEpochCard } from "@/components/landingPageComponents/CurrentEpochCard";
import { NetworkStakeCard } from "@/components/landingPageComponents/NetworkStakeCard";
import { NoiseCard } from "@/components/landingPageComponents/NoiseCard";
import { RewardsCard } from "@/components/landingPageComponents/RewardsCard";
import { TokenomicsCard } from "@/components/landingPageComponents/TokenomicsCard";
import { Wrapper } from "@/components/wrapper";
import { Container, Grid2, IconButton, Stack, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import React, { useEffect, useState } from "react";
import { type ContentCardProps, MonoCard } from "../components/cards/MonoCard";
import { type ExplorerData, getCacheExplorerData } from "./api";
export default function Home() {
const [explorerData, setExplorerData] = useState<ExplorerData>();
useEffect(() => {
async function fetchData() {
const data = await getCacheExplorerData();
setExplorerData(data);
}
fetchData();
}, []);
return (
<div>
<main>
@@ -42,6 +67,46 @@ export default function Home() {
value={<ProgressBar value={50} color="secondary" />}
/>
</ExplorerCard>
<Typography variant="h1" textTransform={"uppercase"} mb={5}>
Mixnet in your hands
</Typography>
<Grid container rowSpacing={3} columnSpacing={2} mb={2}>
<Grid size={{ xs: 12, md: 3 }}>
{explorerData && <NoiseCard explorerData={explorerData} />}
</Grid>
<Grid container rowSpacing={3} size={{ xs: 12, md: 3 }}>
<Grid size={{ xs: 12 }}>
<RewardsCard />
</Grid>
<Grid size={{ xs: 12 }}>
{explorerData && (
<CurrentEpochCard explorerData={explorerData} />
)}
</Grid>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
{explorerData && (
<NetworkStakeCard explorerData={explorerData} />
)}
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<TokenomicsCard />
</Grid>
</Grid>
<Grid container gap={2} alignItems={"flex-start"}>
<Grid size={{ xs: 12, md: 5 }}>
<MonoCard {...explorerCard} />
</Grid>
<Grid container size={{ xs: 6 }}>
<Grid size={{ xs: 12 }}>
<TwoSidedSwitch
leftLabel="Account"
rightLabel="Mixnode"
// onSwitch={() => console.log("object :>> ")}
/>
</Grid>
</Grid>
</Grid>
<Grid2 container spacing={4}>
<Grid2 size={6}>
<ExplorerHeroCard
@@ -0,0 +1,14 @@
export const formatBigNum = (num: number) => {
if (typeof num === "number") {
if (num >= 1000000000) {
return `${(num / 1000000000).toFixed(1).replace(/\.0$/, "")}B`;
}
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1).replace(/\.0$/, "")}K`;
}
return num;
}
};
@@ -0,0 +1,308 @@
"use client";
import { TABLET_WIDTH } from "@/app/constants";
import CircleIcon from "@mui/icons-material/Circle";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Card, CardContent } from "@mui/material";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import { MultiSegmentProgressBar } from "../progressBars/MultiSegmentProgressBar";
import { StaticProgressBar } from "../progressBars/StaticProgressBar";
export interface IAccontStatsRowProps {
type: string;
allocation: number;
amount: number;
value: number;
history?: { type: string; amount: number }[];
isLastRow?: boolean;
progressBarColor?: string;
}
const progressBarColours = [
"#BEF885",
"#7FB0FF",
"#00D17D",
"#004650",
"#FEECB3",
];
const Row = (props: IAccontStatsRowProps) => {
const tablet = useMediaQuery(TABLET_WIDTH);
const {
type,
allocation,
amount,
value,
history,
isLastRow,
progressBarColor,
} = props;
const [open, setOpen] = React.useState(false);
return (
<React.Fragment>
{/* Main Row */}
{tablet ? (
<TableRow>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "25%",
}}
>
<Typography>{type}</Typography>
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "25%",
}}
>
<Box>
<Typography>{allocation}%</Typography>
<StaticProgressBar
value={allocation}
color={progressBarColor || "green"}
/>
</Box>
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "20%",
}}
>
{amount} NYM
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "20%",
}}
>
$ {value}
</TableCell>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "10%",
}}
>
{history && (
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
)}
</TableCell>
</TableRow>
) : (
// MOBILE VIEW
<TableRow>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "45%",
}}
>
<Box display={"flex"} gap={1} alignItems={"center"}>
<CircleIcon sx={{ color: progressBarColor }} fontSize="small" />
{type}
</Box>
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "45%",
}}
>
<Typography>{amount} NYM</Typography>
<Typography>$ {value}</Typography>
</TableCell>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "10%",
}}
>
{history && (
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
)}
</TableCell>
</TableRow>
)}
{/* History Rows */}
{history &&
open &&
history.map((historyRow) => (
<TableRow key={historyRow.type}>
<TableCell
sx={{
display: "flex",
alignItems: "center",
pl: 5,
borderBottom: "none", // Explicitly remove border
}}
>
<span style={{ marginRight: 8 }}></span>
{historyRow.type}
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: "none", // Explicitly remove border
}}
>
{historyRow.amount}
</TableCell>
<TableCell
sx={{
borderBottom: "none", // Explicitly remove border
}}
>
{/* Any additional content */}
</TableCell>
</TableRow>
))}
</React.Fragment>
);
};
export interface IAccountStatsCardProps {
rows: Array<IAccontStatsRowProps>;
overTitle?: string;
priceTitle?: number;
}
export const AccountStatsCard = (props: IAccountStatsCardProps) => {
const { rows, overTitle, priceTitle } = props;
const tablet = useMediaQuery(TABLET_WIDTH);
const progressBarPercentages = () => {
return rows.map((row) => row.allocation);
};
const getProgressValues = () => {
const percentages = progressBarPercentages();
const result: Array<{ percentage: number; color: string }> = [];
percentages.map((value, i) => {
result.push({
percentage: value,
color: progressBarColours[i],
});
});
return result;
};
const progressValues = getProgressValues();
return (
<Card sx={{ height: "100%", borderRadius: "unset" }}>
<CardContent>
{overTitle && (
<Typography fontSize={14} mb={3} textTransform={"uppercase"}>
{overTitle}
</Typography>
)}
{priceTitle && (
<Typography fontSize={24} mb={3}>
${priceTitle}
</Typography>
)}
{!tablet && <MultiSegmentProgressBar values={progressValues} />}
<TableContainer>
<Table aria-label="collapsible table" sx={{ marginBottom: 3 }}>
<TableHead>
{tablet ? (
<TableRow>
<TableCell>
<Typography textTransform={"uppercase"}>Type</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>
Allocation
</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>Amount</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>Value</Typography>
</TableCell>
<TableCell />
</TableRow>
) : (
<TableRow>
<TableCell>
<Typography textTransform={"uppercase"}>Type</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>
Amount / Value
</Typography>
</TableCell>
<TableCell />
</TableRow>
)}
</TableHead>
<TableBody>
{rows.map((row, i) => (
<Row
key={row.type}
{...row}
isLastRow={i === rows.length - 1}
progressBarColor={progressBarColours[i]}
/>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
@@ -0,0 +1,374 @@
"use client";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import StarIcon from "@mui/icons-material/Star";
import { Box, Button, Card, CardContent, Typography } from "@mui/material";
import { CopyToClipboard } from "@nymproject/react/clipboard/CopyToClipboard";
import Image from "next/image";
import { QRCodeCanvas } from "qrcode.react";
import type React from "react";
import type { FC, ReactElement } from "react";
import Flag from "react-world-flags";
import profileImagePlaceholder from "../../../public/profileImagePlaceholder.png";
// import { Remark42Comments } from "../comments";
import { NymTokenSVG } from "../icons/NymTokenSVG";
import { type ILineChartData, LineChart } from "../lineChart";
import {
DynamicProgressBar,
type IDynamicProgressBarProps,
} from "../progressBars/DynamicProgressBar";
interface ICardUpDownPriceLineProps {
percentage: number;
numberWentUp: boolean;
}
const CardUpDownPriceLine = (
props: ICardUpDownPriceLineProps,
): ReactElement => {
const { percentage, numberWentUp } = props;
return (
<Box display={"flex"} alignItems={"center"}>
{numberWentUp ? (
<ArrowUpwardIcon sx={{ color: "#00CA33", fontSize: 13 }} />
) : (
<ArrowDownwardIcon sx={{ color: "#DF1400", fontSize: 13 }} />
)}
<Typography
fontSize={13}
sx={{ color: numberWentUp ? "#00CA33" : "#DF1400" }}
>
{percentage}% (24H)
</Typography>
</Box>
);
};
interface ICardTitlePriceProps {
price: number;
upDownLine: ICardUpDownPriceLineProps;
}
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>NYM</Typography>
</Box>
<Typography>${price}</Typography>
</Box>
<CardUpDownPriceLine {...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}
paddingTop={1}
paddingBottom={1}
display={"flex"}
justifyContent={"space-between"}
borderBottom={i === 0 ? "1px solid #C3D7D7" : "none"}
>
<Typography fontSize={14} textTransform={"uppercase"}>
{row.key}
</Typography>
<Typography fontSize={14} textTransform={"uppercase"}>
{row.value}
</Typography>
</Box>
);
})}
</Box>
);
};
interface ICardProileImage {
url?: string;
}
const CardProfileImage = (props: ICardProileImage) => {
const { url } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"} mb={3}>
{url ? (
<Image src={url} alt="linkedIn" width={80} height={80} />
) : (
<Image
src={profileImagePlaceholder}
alt="linkedIn"
width={80}
height={80}
/>
)}
</Box>
);
};
interface ICardProfileCountry {
countryCode: string;
countryName: string;
}
const CardProfileCountry = (props: ICardProfileCountry) => {
const { countryCode, countryName } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"} gap={2}>
<Flag code={countryCode} width="20" />
<Typography textTransform={"uppercase"}>{countryName}</Typography>
</Box>
);
};
interface ICardCopyAddressProps {
title: string;
address: string;
}
const CardCopyAddress = (props: ICardCopyAddressProps) => {
const { title, address } = props;
return (
<Box
paddingTop={2}
paddingBottom={2}
display={"flex"}
flexDirection={"column"}
gap={2}
borderBottom={"1px solid #C3D7D7"}
>
<Typography textTransform={"uppercase"}>{title}</Typography>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography maxWidth={"90%"} sx={{ wordWrap: "break-word" }}>
{address}
</Typography>
<CopyToClipboard
sx={{ mr: 0.5, color: "grey.400" }}
smallIcons
value={address}
tooltip={`Copy identity key ${address} to clipboard`}
/>
</Box>
</Box>
);
};
interface ICardQRCodeProps {
url: string;
}
const CardQRCode = (props: ICardQRCodeProps) => {
const { url } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"}>
<Box
padding={2}
border={"1px solid #C3D7D7"}
display={"block"}
width={"unset"}
>
<QRCodeCanvas value={url} />
</Box>
</Box>
);
};
interface ICardRatingsProps {
ratings: Array<{ title: string; numberOfStars: number }>;
}
const CardRatings = (props: ICardRatingsProps) => {
const { ratings } = props;
return (
<Box>
{ratings.map((rating, i) => {
const Stars = () => {
const stars = [];
for (let i = 0; i < rating.numberOfStars; i++) {
stars.push(
<StarIcon sx={{ color: "#14E76F" }} fontSize="small" key={i} />,
);
}
return stars;
};
const RatingTitle = () => {
switch (rating.numberOfStars) {
case 1:
case 2:
return <Typography>Bad</Typography>;
case 3:
return <Typography>ok</Typography>;
case 4:
return <Typography>Good</Typography>;
default:
return <Typography>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>{rating.title}</Typography>
<Box display={"flex"} gap={1} alignItems={"center"}>
<Stars />
<RatingTitle />
</Box>
</Box>
);
})}
</Box>
);
};
export type ContentCardProps = {
overTitle?: string;
profileImage?: ICardProileImage;
title?: string | number;
profileCountry?: ICardProfileCountry;
upDownLine?: ICardUpDownPriceLineProps;
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,
progressBar,
paragraph,
profileImage,
profileCountry,
nymAddress,
identityKey,
qrCode,
ratings,
// comments,
stakeButton,
}) => (
<Card sx={{ height: "100%", borderRadius: "unset", padding: 1 }}>
<CardContent
sx={{
paddingBottom: "0px !important",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<Box>
{overTitle && (
<Typography fontSize={14} mb={3} textTransform={"uppercase"}>
{overTitle}
</Typography>
)}
{profileImage && <CardProfileImage {...profileImage} />}
{title && (
<Typography fontSize={24} mb={upDownLine ? 0 : 3}>
{title}
</Typography>
)}
{profileCountry && (
<Box mb={3}>
<CardProfileCountry {...profileCountry} />
</Box>
)}
{upDownLine && (
<Box mb={3}>
<CardUpDownPriceLine {...upDownLine} />
</Box>
)}
{titlePrice && <CardTitlePrice {...titlePrice} />}
</Box>
{qrCode && (
<Box mb={3}>
<CardQRCode {...qrCode} />
</Box>
)}
{nymAddress && (
<Box mb={3}>
<CardCopyAddress {...nymAddress} />
</Box>
)}
{identityKey && (
<Box mb={3}>
<CardCopyAddress {...identityKey} />
</Box>
)}
{dataRows && (
<Box mb={3}>
<CardDataRows {...dataRows} />
</Box>
)}
{ratings && (
<Box mb={3}>
<CardRatings {...ratings} />
</Box>
)}
{graph && (
<Box mb={3}>
<LineChart
data={graph.data}
color={graph.color}
label={graph.label}
/>
</Box>
)}
{progressBar && (
<Box mb={3}>
<DynamicProgressBar {...progressBar} />
</Box>
)}
{paragraph && <Typography mb={3}>{paragraph}</Typography>}
{/* {comments && (
<Box mb={3}>
<Remark42Comments />
</Box>
)} */}
{stakeButton && (
<Box mb={3}>
<Button
onClick={() =>
console.log(
"stakeButton.identityKey :>> ",
stakeButton.identityKey,
)
}
variant="contained"
>
{stakeButton.label}
</Button>
</Box>
)}
</CardContent>
</Card>
);
@@ -0,0 +1,71 @@
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,19 @@
import type { ExplorerData } from "@/app/api";
import { MonoCard } from "../cards/MonoCard";
interface ICurrentEpochCardProps {
explorerData: ExplorerData | null;
}
export const CurrentEpochCard = (props: ICurrentEpochCardProps) => {
const { explorerData } = props;
const currentEpochStart =
explorerData?.currentEpochData.current_epoch_start || "";
const progressBar = {
start: currentEpochStart || "",
showEpoch: true,
};
return <MonoCard progressBar={progressBar} overTitle="Current NGM epoch" />;
};
@@ -0,0 +1,49 @@
"use client";
import type { ExplorerData, IPacketsAndStakingData } from "@/app/api";
import { formatBigNum } from "@/app/utils/formatBigNumbers";
import { useEffect, useState } from "react";
import { MonoCard } from "../cards/MonoCard";
import type { ILineChartData } from "../lineChart";
interface INetworkStakeCardProps {
explorerData: ExplorerData | null;
}
export const NetworkStakeCard = (props: INetworkStakeCardProps) => {
const { explorerData } = props;
const [stakeLineGraphData, setStakeLineGraphData] = useState<{
color: string;
label: string;
data: ILineChartData[];
}>();
const currentStake =
Number(explorerData?.currentEpochRewardsData.interval.staking_supply) /
1000000 || 0;
useEffect(() => {
const getStakeData = () => {
const data: Array<ILineChartData> = [];
explorerData?.packetsAndStakingData.map(
(item: IPacketsAndStakingData) => {
data.push({
date_utc: item.date_utc,
numericData: item.total_stake / 1000000,
});
},
);
return data;
};
const stakeLineGraphData = {
color: "#00CA33",
label: "Total stake delegated in NYM",
data: getStakeData(),
};
setStakeLineGraphData(stakeLineGraphData);
}, [explorerData]);
const stakeCard = {
overTitle: "Current network stake",
title: `${currentStake} NYM` || "",
graph: stakeLineGraphData,
};
return <MonoCard {...stakeCard} />;
};
@@ -0,0 +1,79 @@
"use client";
import type { ExplorerData, IPacketsAndStakingData } from "@/app/api";
import { formatBigNum } from "@/app/utils/formatBigNumbers";
import { useEffect, useState } from "react";
import { MonoCard } from "../cards/MonoCard";
import type { ILineChartData } from "../lineChart";
interface INoiseCardProps {
explorerData: ExplorerData;
}
export const NoiseCard = (props: INoiseCardProps) => {
const { explorerData } = props;
const [noiseLineGraphData, setNoiseLineGraphData] = useState<{
color: string;
label: string;
data: ILineChartData[];
}>();
const noiseLast24H =
explorerData.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 1
].total_packets_sent +
explorerData.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 1
].total_packets_received;
const noisePrevious24H =
explorerData.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 2
].total_packets_sent +
explorerData.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 2
].total_packets_received;
const calculatePercentageChange = (last24H: number, previous24H: number) => {
if (previous24H === 0) {
throw new Error(
"Cannot calculate percentage change when yesterday's value is zero.",
);
}
const change = ((last24H - previous24H) / previous24H) * 100;
return Number.parseFloat(change.toFixed(2));
};
const percentage = calculatePercentageChange(noiseLast24H, noisePrevious24H);
useEffect(() => {
const getPacketsData = () => {
const data: Array<ILineChartData> = [];
explorerData?.packetsAndStakingData.map(
(item: IPacketsAndStakingData) => {
data.push({
date_utc: item.date_utc,
numericData: item.total_packets_sent + item.total_packets_received,
});
},
);
return data;
};
const noiseLineGraphData = {
color: "#8482FD",
label: "Total packets sent and received",
data: getPacketsData(),
};
setNoiseLineGraphData(noiseLineGraphData);
}, [explorerData]);
const noiseCard = {
overTitle: "Noise generated last 24h",
title: formatBigNum(noiseLast24H) || "",
upDownLine: {
percentage: Math.abs(percentage) || 0,
numberWentUp: percentage > 0,
},
graph: noiseLineGraphData,
};
return <MonoCard {...noiseCard} />;
};
@@ -0,0 +1,9 @@
import { MonoCard } from "../cards/MonoCard";
export const RewardsCard = () => {
const rewardsCard = {
overTitle: "Operator rewards this month",
title: "198.841720 NYM",
};
return <MonoCard {...rewardsCard} />;
};
@@ -0,0 +1,21 @@
import { MonoCard } from "../cards/MonoCard";
export const TokenomicsCard = () => {
const tokenomicsCard = {
overTitle: "Tokenomics overview",
titlePrice: {
price: 1.15,
upDownLine: {
percentage: 10,
numberWentUp: true,
},
},
dataRows: {
rows: [
{ key: "Market cap", value: "$ 1000000" },
{ key: "24H VOL", value: "$ 1000000" },
],
},
};
return <MonoCard {...tokenomicsCard} />;
};
@@ -0,0 +1,153 @@
"use client";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import Loading from "../loading";
const NivoLineChart = dynamic(
() => import("@nivo/line").then((m) => m.ResponsiveLine),
{
loading: () => <Loading />,
ssr: false,
},
);
export interface ILineChartData {
date_utc: string;
numericData?: number;
// purpleLineNumericData?: number;
}
interface IAxes {
x: Date;
y: number;
}
interface ILineAxes {
id: string;
data: Array<IAxes>;
}
export const LineChart = ({
data,
color,
label,
}: {
data: Array<ILineChartData>;
color: string;
label: string;
}) => {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("lg"));
const [chartData, setChartData] = useState<Array<ILineAxes>>();
useEffect(() => {
const resultData = transformData(data);
if (resultData.length > 0) {
setChartData(resultData);
}
}, [data]);
const transformData = (data: Array<ILineChartData>) => {
const lineData: ILineAxes = {
id: label,
data: [],
};
// const purpleLineData: ILineAxes = {
// id: "Numeric Data 2",
// data: [],
// };
data.map((item: ILineChartData) => {
const axesGreenLineData: IAxes = {
x: new Date(item.date_utc),
y: item.numericData || 0,
};
lineData.data.push(axesGreenLineData);
// const axesPurpleLineData: IAxes = {
// x: new Date(item.date_utc),
// y: item.purpleLineNumericData,
// };
// purpleLineData.data.push(axesPurpleLineData);
});
return [{ ...lineData }];
};
const yformat = (num: number | string | Date) => {
if (typeof num === "number") {
if (num >= 1000000000) {
return `${(num / 1000000000).toFixed(1).replace(/\.0$/, "")}B`;
}
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1).replace(/\.0$/, "")}K`;
}
return num;
}
throw new Error("Unexpected value");
};
return (
<Box width={"100%"} height={isDesktop ? 200 : 150}>
{chartData && (
<NivoLineChart
curve="monotoneX"
colors={[color]}
data={chartData}
animate
enableSlices="x"
margin={{
bottom: 24,
left: 30,
right: 12,
top: 20,
}}
theme={{
grid: { line: { strokeWidth: 0 } },
tooltip: { container: { color: "black" } },
axis: {
domain: {
line: { stroke: "#C3D7D7", strokeWidth: 1, strokeOpacity: 1 },
},
ticks: {
text: {
fill: "#818386",
},
},
legend: {
text: {
fill: "#818386",
},
},
},
}}
xScale={{
type: "time",
format: "%Y-%m-%d",
}}
yScale={{ min: 1, type: "linear" }}
xFormat="time:%Y-%m-%d"
axisLeft={{
legendOffset: 12,
tickSize: 3,
format: yformat,
tickValues: 5,
}}
axisBottom={{
format: "%b %d",
legendOffset: -12,
tickValues:
chartData[0].data.length > 7 ? "every 5 days" : "every day",
}}
/>
)}
</Box>
);
};
@@ -0,0 +1,156 @@
import { TABLET_WIDTH } from "@/app/constants";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutline from "@mui/icons-material/ErrorOutline";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import {
Box,
Button,
Modal,
Stack,
type SxProps,
Typography,
useMediaQuery,
} from "@mui/material";
import type React from "react";
export const modalStyle = (width: number | string = 600) => ({
position: "absolute",
top: "50%",
left: "50%",
width,
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
borderRadius: "16px",
p: 4,
});
export const StyledBackButton = ({
onBack,
label,
fullWidth,
sx,
}: {
onBack: () => void;
label?: string;
fullWidth?: boolean;
sx?: SxProps;
}) => (
<Button
disableFocusRipple
size="large"
fullWidth={fullWidth}
variant="outlined"
onClick={onBack}
sx={sx}
>
{label || <ArrowBackIosNewIcon fontSize="small" />}
</Button>
);
export const SimpleModal: FCWithChildren<{
open: boolean;
hideCloseIcon?: boolean;
displayErrorIcon?: boolean;
displayInfoIcon?: boolean;
headerStyles?: SxProps;
subHeaderStyles?: SxProps;
buttonFullWidth?: boolean;
onClose?: () => void;
onOk?: () => Promise<void>;
onBack?: () => void;
header: string | React.ReactNode;
subHeader?: string;
okLabel: string;
backLabel?: string;
backButtonFullWidth?: boolean;
okDisabled?: boolean;
sx?: SxProps;
children?: React.ReactNode;
}> = ({
open,
hideCloseIcon,
displayErrorIcon,
displayInfoIcon,
headerStyles,
buttonFullWidth,
onClose,
okDisabled,
onOk,
onBack,
header,
subHeader,
okLabel,
backLabel,
backButtonFullWidth,
sx,
children,
}) => {
const isTablet = useMediaQuery(TABLET_WIDTH);
const styles = modalStyle(isTablet ? 600 : "90%");
return (
<Modal open={open} onClose={onClose}>
<Box sx={{ styles, ...sx }}>
{displayErrorIcon && <ErrorOutline color="error" sx={{ mb: 3 }} />}
{displayInfoIcon && <InfoOutlinedIcon sx={{ mb: 2, color: "blue" }} />}
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
{typeof header === "string" ? (
<Typography
fontSize={20}
fontWeight={600}
sx={{ color: "text.primary", ...headerStyles }}
>
{header}
</Typography>
) : (
header
)}
{!hideCloseIcon && <CloseIcon onClick={onClose} cursor="pointer" />}
</Stack>
<Typography mt={subHeader ? 0.5 : 0} mb={3} fontSize={12}>
{subHeader}
</Typography>
{children}
{(onOk || onBack) && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
mt: 2,
width: buttonFullWidth ? "100%" : null,
}}
>
{onBack && (
<StyledBackButton
onBack={onBack}
label={backLabel}
fullWidth={backButtonFullWidth}
/>
)}
{onOk && (
<Button
variant="contained"
fullWidth
size="large"
onClick={onOk}
disabled={okDisabled}
>
{okLabel}
</Button>
)}
</Box>
)}
</Box>
</Modal>
);
};
@@ -0,0 +1,109 @@
"use client";
import { Typography } from "@mui/material";
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import * as React from "react";
export interface IDynamicProgressBarProps {
overTitle?: string;
start: string; // Start timestamp as ISO 8601 string
showEpoch: boolean;
}
export const DynamicProgressBar = (props: IDynamicProgressBarProps) => {
const { start, showEpoch, overTitle } = props;
const [progress, setProgress] = React.useState(0);
React.useEffect(() => {
// Parse the start timestamp
const startTime = new Date(start).getTime();
const endTime = startTime + 60 * 60 * 1000; // Add 1 hour to the start time
// Validate start timestamp
if (Number.isNaN(startTime)) {
console.error("Invalid start timestamp:", { start });
return;
}
// Function to calculate progress
const calculateProgress = () => {
const currentTime = Date.now();
if (currentTime < startTime) {
return 0;
}
if (currentTime >= endTime) {
return 100;
}
const elapsed = currentTime - startTime;
const total = endTime - startTime;
return (elapsed / total) * 100;
};
// Set initial progress and start timer
setProgress(calculateProgress());
const timer = setInterval(() => {
setProgress(calculateProgress());
}, 60000); // Update every minute (60000 milliseconds)
// Cleanup on unmount
return () => {
clearInterval(timer);
};
}, [start]);
// Helper function to format date
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0"); // Months are 0-based
const year = date.getFullYear();
return `${hours}:${minutes}, ${day}/${month}/${year}`;
};
const startTime = new Date(start).getTime();
const endTime = startTime + 60 * 60 * 1000;
return (
<Box sx={{ width: "100%" }}>
{overTitle && (
<Typography fontSize={14} mb={2} textTransform={"uppercase"}>
{overTitle}
</Typography>
)}
<LinearProgress
variant="determinate"
value={progress}
sx={{
backgroundColor: "#CAD6D7",
"& .MuiLinearProgress-bar": {
backgroundColor: "#14E76F",
},
}}
/>
{showEpoch && (
<Box mt={2}>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography fontSize={14} textTransform={"uppercase"}>
START:
</Typography>
<Typography fontSize={14}>
{startTime ? formatDate(startTime) : ""}
</Typography>
</Box>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography fontSize={14} textTransform={"uppercase"}>
END:
</Typography>
<Typography fontSize={14}>
{endTime ? formatDate(endTime) : ""}
</Typography>
</Box>
</Box>
)}
</Box>
);
};
+1
View File
@@ -0,0 +1 @@
declare type FCWithChildren<P> = React.FC<React.PropsWithChildren<P>>;
+1202 -128
View File
File diff suppressed because it is too large Load Diff