kleros/kleros-v2

View on GitHub
web/src/pages/Courts/CourtDetails/StakePanel/SimulatorPopup/index.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import React, { useMemo } from "react";
import styled, { css } from "styled-components";
import { landscapeStyle } from "styles/landscapeStyle";

import { useParams } from "react-router-dom";
import Skeleton from "react-loading-skeleton";
import { useAccount } from "wagmi";
import { formatEther } from "viem";

import { CoinIds } from "consts/coingecko";

import { formatUSD } from "utils/format";
import { isUndefined } from "utils/index";
import { beautifyStatNumber } from "utils/beautifyStatNumber";

import { useCoinPrice } from "hooks/useCoinPrice";
import { useHomePageExtraStats } from "queries/useHomePageExtraStats";
import { useJurorStakeDetailsQuery } from "queries/useJurorStakeDetailsQuery";

import GavelIcon from "svgs/icons/gavel.svg";
import LawBalanceIcon from "svgs/icons/law-balance.svg";
import DiceIcon from "svgs/icons/dice.svg";
import DollarIcon from "svgs/icons/dollar.svg";
import ArrowRightIcon from "svgs/icons/arrow-right.svg";

import Header from "./Header";
import QuantityToSimulate from "./QuantityToSimulate";
import Info from "../../Info";
import WithHelpTooltip from "components/WithHelpTooltip";
import { Divider } from "components/Divider";

const Container = styled.div`
  display: flex;
  flex-direction: column;
  max-width: 480px;
  background-color: ${({ theme }) => theme.lightBlue};
  box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
  padding: 20px;
  border-radius: 8px;
  border: 1px solid ${({ theme }) => theme.mediumBlue};
  justify-content: center;
`;

const ItemsContainer = styled.div`
  display: flex;
  flex-direction: column;
  gap: 8px 0;
  margin: 24px 0 12px 0;
`;

const SimulatorItem = styled.div`
  display: flex;
  align-items: center;
  font-size: 14px;
  justify-content: space-between;
`;

const IconWrapper = styled.div`
  svg {
    width: 14px;
    height: 14px;
    fill: ${({ theme }) => theme.secondaryPurple};
  }
`;

const StyledDivider = styled(Divider)`
  background-color: ${({ theme }) => theme.mediumBlue};
  margin: 12px 0 8px 0;
`;

const LeftContent = styled.div`
  display: flex;
  align-items: flex-start;
  flex-direction: row;
  gap: 8px;

  ${landscapeStyle(
    () => css`
      align-items: center;
    `
  )}
`;

const RightContent = styled.div`
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 8px;
`;

const StyledTitle = styled.span`
  color: ${({ theme }) => theme.secondaryText};
`;

const StyledCurrentValue = styled.span`
  font-weight: 600;
  color: ${({ theme }) => theme.secondaryText};
`;

const StyledFutureValue = styled.span`
  font-weight: 600;
  color: ${({ theme }) => theme.primaryText};
`;

const StyledArrowRightIcon = styled(ArrowRightIcon)<{ isStaking: boolean }>`
  fill: ${({ theme, isStaking }) => (isStaking ? theme.success : theme.warning)};
`;

const InfoContainer = styled.div`
  padding-top: 4px;
`;

const calculateJurorOdds = (newStake: number, totalStake: number): string => {
  const odds = totalStake !== 0 ? (newStake * 100) / totalStake : 0;
  return `${odds.toFixed(2)}%`;
};

interface ISimulatorPopup {
  amountToStake: number;
  isStaking: boolean;
}

const SimulatorPopup: React.FC<ISimulatorPopup> = ({ amountToStake, isStaking }) => {
  const { id } = useParams();
  const { address } = useAccount();
  const { data: stakeData } = useJurorStakeDetailsQuery(address?.toLowerCase() as `0x${string}`);
  const jurorStakeData = stakeData?.jurorTokensPerCourts?.find(({ court }) => court.id === id);
  const jurorCurrentEffectiveStake = address && jurorStakeData ? Number(formatEther(jurorStakeData.effectiveStake)) : 0;
  const jurorCurrentSpecificStake = address && jurorStakeData ? Number(formatEther(jurorStakeData.staked)) : 0;

  const timeframedCourtData = useHomePageExtraStats(30);
  const { prices: pricesData } = useCoinPrice([CoinIds.ETH]);
  const ethPriceUSD = pricesData ? pricesData[CoinIds.ETH]?.price : undefined;

  const foundCourt = useMemo(() => {
    return timeframedCourtData?.data?.courts?.find((c) => c.id === id);
  }, [timeframedCourtData, id]);

  const courtCurrentEffectiveStake = foundCourt ? Number(foundCourt.effectiveStake) / 1e18 : undefined;

  const currentTreeVotesPerPnk = foundCourt?.treeVotesPerPnk;
  const currentTreeDisputesPerPnk = foundCourt?.treeDisputesPerPnk;
  const currentTreeExpectedRewardPerPnk = foundCourt?.treeExpectedRewardPerPnk;

  const totals = useMemo(() => {
    if (isUndefined(courtCurrentEffectiveStake)) return {};
    return {
      votes: !isUndefined(currentTreeVotesPerPnk) ? courtCurrentEffectiveStake * currentTreeVotesPerPnk : undefined,
      cases: !isUndefined(currentTreeDisputesPerPnk)
        ? courtCurrentEffectiveStake * currentTreeDisputesPerPnk
        : undefined,
      rewards: !isUndefined(currentTreeExpectedRewardPerPnk)
        ? courtCurrentEffectiveStake * currentTreeExpectedRewardPerPnk
        : undefined,
    };
  }, [courtCurrentEffectiveStake, currentTreeVotesPerPnk, currentTreeDisputesPerPnk, currentTreeExpectedRewardPerPnk]);

  const { votes: totalVotes, cases: totalCases, rewards: totalRewards } = totals;

  const courtFutureEffectiveStake = !isUndefined(courtCurrentEffectiveStake)
    ? Math.max(isStaking ? courtCurrentEffectiveStake + amountToStake : courtCurrentEffectiveStake - amountToStake, 0)
    : undefined;

  const futureTreeVotesPerPnk =
    !isUndefined(courtFutureEffectiveStake) && !isUndefined(totalVotes)
      ? totalVotes / courtFutureEffectiveStake
      : undefined;
  const futureTreeDisputesPerPnk =
    !isUndefined(courtFutureEffectiveStake) && !isUndefined(totalCases)
      ? totalCases / courtFutureEffectiveStake
      : undefined;
  const futureTreeExpectedRewardPerPnk =
    !isUndefined(courtFutureEffectiveStake) && !isUndefined(totalRewards)
      ? totalRewards / courtFutureEffectiveStake
      : undefined;

  const jurorFutureEffectiveStake = !isUndefined(jurorCurrentEffectiveStake)
    ? Math.max(isStaking ? jurorCurrentEffectiveStake + amountToStake : jurorCurrentEffectiveStake - amountToStake, 0)
    : undefined;

  const currentExpectedVotes =
    !isUndefined(jurorCurrentEffectiveStake) && !isUndefined(currentTreeVotesPerPnk)
      ? beautifyStatNumber(jurorCurrentEffectiveStake * currentTreeVotesPerPnk)
      : undefined;
  const futureExpectedVotes =
    !isUndefined(jurorFutureEffectiveStake) && !isUndefined(futureTreeVotesPerPnk)
      ? beautifyStatNumber(jurorFutureEffectiveStake * futureTreeVotesPerPnk)
      : undefined;

  const currentExpectedCases =
    !isUndefined(jurorCurrentEffectiveStake) && !isUndefined(currentTreeDisputesPerPnk)
      ? beautifyStatNumber(jurorCurrentEffectiveStake * currentTreeDisputesPerPnk)
      : undefined;
  const futureExpectedCases =
    !isUndefined(jurorFutureEffectiveStake) && !isUndefined(futureTreeDisputesPerPnk)
      ? beautifyStatNumber(jurorFutureEffectiveStake * futureTreeDisputesPerPnk)
      : undefined;

  const currentDrawingOdds =
    !isUndefined(jurorCurrentEffectiveStake) && !isUndefined(courtCurrentEffectiveStake)
      ? calculateJurorOdds(jurorCurrentEffectiveStake, courtCurrentEffectiveStake)
      : undefined;
  const futureDrawingOdds =
    !isUndefined(jurorFutureEffectiveStake) && !isUndefined(courtFutureEffectiveStake)
      ? calculateJurorOdds(jurorFutureEffectiveStake, courtFutureEffectiveStake)
      : undefined;

  const currentExpectedRewardsUSD =
    !isUndefined(jurorCurrentEffectiveStake) &&
    !isUndefined(currentTreeExpectedRewardPerPnk) &&
    !isUndefined(ethPriceUSD)
      ? formatUSD(jurorCurrentEffectiveStake * currentTreeExpectedRewardPerPnk * ethPriceUSD)
      : undefined;
  const futureExpectedRewardsUSD =
    !isUndefined(jurorFutureEffectiveStake) && !isUndefined(futureTreeExpectedRewardPerPnk) && !isUndefined(ethPriceUSD)
      ? formatUSD(jurorFutureEffectiveStake * futureTreeExpectedRewardPerPnk * ethPriceUSD)
      : undefined;

  const simulatorItems = [
    {
      title: "Votes",
      icon: <GavelIcon />,
      currentValue: currentExpectedVotes,
      futureValue: futureExpectedVotes,
    },
    {
      title: "Cases",
      icon: <LawBalanceIcon />,
      currentValue: currentExpectedCases,
      futureValue: futureExpectedCases,
    },
    {
      title: "Drawing Odds",
      icon: <DiceIcon />,
      currentValue: currentDrawingOdds,
      futureValue: futureDrawingOdds,
    },
    {
      title: "Rewards",
      icon: <DollarIcon />,
      currentValue: currentExpectedRewardsUSD,
      futureValue: futureExpectedRewardsUSD,
      tooltipMsg:
        "Estimated rewards in USD, assuming 100% coherent voting. If other jurors vote incoherently, additional rewards in the form of PNK tokens may be earned beyond this estimate.",
    },
  ];

  return (
    <Container>
      <Header />
      <StyledDivider />
      <QuantityToSimulate {...{ jurorCurrentEffectiveStake, jurorCurrentSpecificStake, isStaking, amountToStake }} />
      <ItemsContainer>
        {simulatorItems.map((item, index) => (
          <SimulatorItem key={index}>
            <LeftContent>
              <IconWrapper>{item.icon}</IconWrapper>
              {item.tooltipMsg ? (
                <WithHelpTooltip place="top" tooltipMsg={item.tooltipMsg}>
                  <StyledTitle>{item.title}: </StyledTitle>
                </WithHelpTooltip>
              ) : (
                <StyledTitle>{item.title}: </StyledTitle>
              )}
            </LeftContent>
            <RightContent>
              <StyledCurrentValue>
                {!isUndefined(item.currentValue) ? item.currentValue : <Skeleton width={32} />}
              </StyledCurrentValue>
              <StyledArrowRightIcon {...{ isStaking }} />
              <StyledFutureValue>
                {!amountToStake || amountToStake === 0 ? "Enter amount" : null}
                {!isUndefined(amountToStake) &&
                  amountToStake > 0 &&
                  (!isUndefined(item.futureValue) ? item.futureValue : <Skeleton width={32} />)}
              </StyledFutureValue>
            </RightContent>
          </SimulatorItem>
        ))}
      </ItemsContainer>
      <StyledDivider />
      <InfoContainer>
        <Info />
      </InfoContainer>
    </Container>
  );
};

export default SimulatorPopup;