src/layouts/components/Header.tsx

Summary

Maintainability
C
1 day
Test Coverage
import { Link } from "@components/commons/link/Link";
import { IoChevronDown } from "react-icons/io5";
import { DeFiChainLogo } from "@components/icons/DeFiChainLogo";
import classNames from "classnames";
import { useRouter } from "next/router";
import React, {
  useEffect,
  useState,
  useRef,
  createContext,
  useContext,
} from "react";
import { MdClose, MdMenu } from "react-icons/md";
import { BiSearchAlt2 } from "react-icons/bi";
import { Container } from "@components/commons/Container";
import { SearchBar } from "@components/commons/searchbar/SearchBar";
import { Menu, Transition } from "@headlessui/react";
import { useWindowDimensions } from "hooks/useWindowDimensions";
import { useWhaleApiClient } from "@contexts/WhaleContext";
import {
  ListProposalsType,
  ListProposalsStatus,
} from "@defichain/jellyfish-api-core/dist/category/governance";
import { NumericFormat } from "react-number-format";
import { HeaderNetworkMenu } from "./HeaderNetworkMenu";
import { HeaderCountBar } from "./HeaderCountBar";

const OpenProposal = createContext<undefined | number>(undefined);

enum ViewPort {
  Desktop = "Desktop",
  Tablet = "Tablet",
  Mobile = "Mobile",
}

export function Header(): JSX.Element {
  const [menu, setMenu] = useState(false);
  const [atTop, setAtTop] = useState(true);
  const [isSearchIconClicked, setIsSearchIconClicked] = useState(false);
  const [openProposalsLength, setOpenProposalsLength] = useState<
    number | undefined
  >(undefined);
  const router = useRouter();

  const api = useWhaleApiClient();

  async function getOpenProposalsLength() {
    await api.governance
      .listGovProposals({
        type: ListProposalsType.ALL,
        status: ListProposalsStatus.VOTING,
        all: true,
      })
      .then((res) => {
        setOpenProposalsLength(res.length);
      })
      .catch(() => {
        setOpenProposalsLength(undefined);
      });
  }

  useEffect(() => {
    getOpenProposalsLength();
  }, []);

  useEffect(() => {
    function routeChangeStart(): void {
      setMenu(false);
    }

    router.events.on("routeChangeStart", routeChangeStart);
    return () => router.events.off("routeChangeStart", routeChangeStart);
  }, []);

  useEffect(() => {
    function scrollHandler(): void {
      window.pageYOffset > 30 ? setAtTop(false) : setAtTop(true);
    }

    window.addEventListener("scroll", scrollHandler);
    return () => {
      window.removeEventListener("scroll", scrollHandler);
    };
  }, []);

  useEffect(() => {
    if (menu) {
      document.documentElement.style.overflow = "hidden";
    } else {
      document.documentElement.style.overflow = "auto";
    }
  }, [menu]);

  return (
    <OpenProposal.Provider value={openProposalsLength}>
      <header
        className={classNames(
          "sticky top-0 z-50 bg-white md:static md:shadow-none",
          { "shadow-lg": !atTop }
        )}
      >
        <div className="hidden border-b border-gray-100 bg-primary-700 dark:border-0 dark:bg-gray-800 md:block">
          <Container className="py-1">
            <div className="flex h-8 items-center justify-between">
              <HeaderCountBar className="flex h-full" />
              <HeaderNetworkMenu />
            </div>
          </Container>
        </div>

        <div className="border-b border-gray-100 dark:border-gray-800 dark:bg-gray-900">
          <Container className="py-4 md:py-6">
            <div className="flex items-center justify-between">
              {isSearchIconClicked ? (
                <div className="flex flex-row items-center w-full m-2 h-9">
                  <div
                    data-testid="Mobile.HeaderSearchBar"
                    className="flex grow"
                  >
                    <SearchBar atHeader />
                  </div>
                  <MdClose
                    role="button"
                    onClick={() => setIsSearchIconClicked(false)}
                    className="h-6 w-6 text-primary-500 ml-4"
                  />
                </div>
              ) : (
                <>
                  <div className="flex w-full items-end">
                    <Link href={{ pathname: "/" }} passHref>
                      <a className="flex cursor-pointer hover:text-primary-500">
                        <DeFiChainLogo className="h-full w-36 lg:w-40" />
                      </a>
                    </Link>
                    <DesktopNavbar />
                  </div>
                  <div className="lg:hidden flex flex-row items-center md:gap-x-6 gap-x-5">
                    <div
                      data-testid="Tablet.HeaderSearchBar"
                      className="md:block hidden w-[275px]"
                    >
                      <SearchBar atHeader />
                    </div>
                    <div className="md:hidden">
                      <BiSearchAlt2
                        size={24}
                        className="text-gray-600 dark:text-dark-gray-600"
                        role="button"
                        onClick={() => setIsSearchIconClicked(true)}
                        data-testid="Header.Mobile.SearchIcon"
                      />
                    </div>

                    {menu ? (
                      <MdClose
                        className="h-6 w-6 text-primary-500"
                        onClick={() => setMenu(false)}
                        data-testid="Header.CloseMenu"
                      />
                    ) : (
                      <MdMenu
                        role="button"
                        className="h-6 w-6 text-primary-500"
                        onClick={() => setMenu(true)}
                        data-testid="Header.OpenMenu"
                      />
                    )}
                  </div>
                </>
              )}
            </div>
          </Container>
        </div>
      </header>
      {menu && (
        <>
          <div className="fixed z-50 md:hidden">
            <MobileMenu toggleMenu={() => setMenu(false)} />
          </div>
          <div className="w-full hidden md:block md:fixed md:z-50">
            <TabletMenu toggleMenu={() => setMenu(false)} />
          </div>
        </>
      )}
    </OpenProposal.Provider>
  );
}

function DesktopNavbar(): JSX.Element {
  return (
    <div className="ml-2 hidden items-end text-gray-600 dark:text-dark-gray-900 md:w-full md:justify-between lg:ml-8 lg:flex">
      <div className="hidden md:flex items-end">
        <HeaderLink
          className="ml-1 lg:ml-2"
          text="DEX"
          pathname="/dex"
          testId="Desktop.HeaderLink.DEX"
          viewPort={ViewPort.Desktop}
        />
        <HeaderLink
          className="ml-1 lg:ml-2"
          text="Blocks"
          pathname="/blocks"
          testId="Desktop.HeaderLink.Blocks"
          viewPort={ViewPort.Desktop}
        />
        <HeaderLink
          className="ml-1 lg:ml-2"
          text="Vaults"
          pathname="/vaults"
          testId="Desktop.HeaderLink.Vaults"
          viewPort={ViewPort.Desktop}
        />
        <HeaderLink
          className="ml-1 lg:ml-2"
          text="Auctions"
          pathname="/auctions"
          testId="Desktop.HeaderLink.Auctions"
          viewPort={ViewPort.Desktop}
        />
        <HeaderLink
          className="ml-1 lg:ml-2"
          text="Oracles"
          pathname="/oracles"
          testId="Desktop.HeaderLink.Oracles"
          viewPort={ViewPort.Desktop}
        />
        <HeaderLink
          className="ml-1 lg:ml-2 whitespace-nowrap"
          text="Governance"
          pathname="/governance"
          testId="Desktop.HeaderLink.Governance"
          viewPort={ViewPort.Desktop}
        />
        <HeaderLink
          className="ml-1 lg:ml-2"
          text="Masternodes"
          pathname="/masternodes"
          testId="Desktop.HeaderLink.Masternodes"
          viewPort={ViewPort.Desktop}
        />
        <MoreDropdown />
      </div>
      <div
        className="hidden w-1/4 md:block"
        data-testid="Desktop.HeaderSearchBar"
      >
        <SearchBar atHeader />
      </div>
    </div>
  );
}

function TabletMenu({ toggleMenu }: { toggleMenu: () => void }): JSX.Element {
  return (
    <div
      onClick={() => {
        toggleMenu();
      }}
      className="h-screen w-screen backdrop-blur-[2px] backdrop-brightness-50"
    >
      <div
        onClick={(e) => {
          e.stopPropagation();
        }}
        className="flex flex-col float-right h-screen w-5/12 bg-white dark:bg-gray-900 lg:hidden"
        data-testid="TabletMenu"
      >
        <div className="flex flex-row justify-between p-6 items-center">
          <Link href={{ pathname: "/" }} passHref>
            <a className="hover:text-primary-500">
              <DeFiChainLogo className="h-full w-36" />
            </a>
          </Link>
          <MdClose
            role="button"
            className="h-6 w-6 text-primary-500"
            onClick={() => toggleMenu()}
            data-testid="Header.CloseMenu"
          />
        </div>
        <div className="flex h-full flex-col ">
          <div className="flex flex-wrap bg-primary-700 p-4 dark:bg-gray-900">
            <HeaderCountBar className="flex w-full flex-wrap" />
            <div className="mt-4 w-full">
              <HeaderNetworkMenu />
            </div>
          </div>
          <div className="mt-2">
            <MenuItems viewPort={ViewPort.Tablet} />
          </div>
        </div>
      </div>
    </div>
  );
}

function MobileMenu({ toggleMenu }: { toggleMenu: () => void }): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);
  const dimension = useWindowDimensions();
  useEffect(() => {
    if (ref.current) {
      ref.current.style.height = `${
        dimension.height - ref.current.offsetTop
      }px`;
    }
  }, [ref, dimension]);

  return (
    <div
      className="bg-white dark:bg-gray-900 md:hidden"
      data-testid="MobileMenu"
    >
      <div className="flex flex-row justify-between p-6 items-center">
        <Link href={{ pathname: "/" }} passHref>
          <a className="hover:text-primary-500">
            <DeFiChainLogo className="h-full w-36" />
          </a>
        </Link>
        <MdClose
          role="button"
          className="h-6 w-6 text-primary-500"
          onClick={() => toggleMenu()}
          data-testid="Header.Mobile.CloseMenu"
        />
      </div>
      <div className="flex flex-wrap bg-primary-700 p-4 dark:bg-gray-900 md:p-0">
        <HeaderCountBar className="flex w-full flex-wrap" />
        <div className="mt-4 w-full">
          <HeaderNetworkMenu />
        </div>
      </div>
      <div
        ref={ref}
        className={classNames(
          "text-gray-600 dark:text-dark-gray-900 overflow-auto"
        )}
      >
        <MenuItems viewPort={ViewPort.Mobile} />
      </div>
    </div>
  );
}

export function HeaderLink({
  text,
  pathname,
  className,
  testId,
  viewPort,
}: {
  text: string;
  pathname: string;
  className: string;
  testId?: string;
  viewPort: ViewPort;
}): JSX.Element {
  const router = useRouter();
  const openProposals = useContext(OpenProposal);
  return (
    <Link href={{ pathname: pathname }}>
      <a
        data-testid={testId}
        className={classNames(
          className,
          viewPort === ViewPort.Desktop ? "flex flex-col" : "flex flex-row"
        )}
      >
        {pathname.includes("governance") && openProposals !== undefined && (
          <div
            role="button"
            className={classNames(
              "lg:px-1 px-2 lg:py-0.5 py-1 w-fit rounded-[20px] font-bold lg:text-[10px] leading-[10px] text-xs lg:mr-2",
              viewPort !== ViewPort.Desktop
                ? "order-last place-self-center"
                : "place-self-end",
              router.pathname.includes(pathname)
                ? "bg-primary-500 text-dark-gray-900"
                : "bg-gray-600 dark:bg-dark-gray-600 dark:text-black text-white"
            )}
          >
            <div className="text-center">
              <NumericFormat
                displayType="text"
                thousandSeparator
                value={openProposals}
                decimalScale={0}
              />
            </div>
          </div>
        )}
        <div
          className={classNames(
            {
              "dark:text-dark-50 text-primary-500":
                router.pathname.includes(pathname),
            },
            {
              "text-gray-900 dark:text-dark-gray-900":
                !router.pathname.includes(pathname),
            }
          )}
        >
          <div
            className={classNames(
              "dark:hover:text-dark-50 m-2 inline cursor-pointer pb-0.5 text-lg hover:text-primary-500",
              {
                "dark:border-dark-50 border-b-2 border-primary-500":
                  router.pathname.includes(pathname),
              }
            )}
          >
            {text}
          </div>
        </div>
      </a>
    </Link>
  );
}

function MenuItems({ viewPort }: { viewPort: ViewPort }): JSX.Element {
  return (
    <div className="flex flex-col">
      {drawerMenuItemLinks.map((item, index) => {
        return (
          <HeaderLink
            key={index}
            className={classNames(
              "flex justify-start border-b border-gray-200 dark:border-gray-700 px-4 py-5",
              { "md:pt-3": index === 0 }
            )}
            text={item.text}
            pathname={item.pathname}
            testId={`${viewPort}.HeaderLink.${item.testId}`}
            viewPort={viewPort}
          />
        );
      })}
    </div>
  );
}

function MoreDropdown(): JSX.Element {
  const [isItemClicked, setIsItemClicked] = useState(false);
  const router = useRouter();
  useEffect(() => {
    setIsItemClicked(
      dropDownLinks.some((ddl) =>
        router.pathname.includes(ddl.rootPathName.toLowerCase())
      )
    );
  }, [router.pathname]);

  return (
    <Menu as="div" className="relative">
      {({ open, close }) => (
        <>
          <Menu.Button
            data-testid="Desktop.HeaderLink.More"
            className={classNames(
              "flex flex-row items-center mx-4 dark:hover:text-dark-50 cursor-pointer text-lg hover:text-primary-500",
              {
                "dark:text-dark-50 text-primary-500": isItemClicked,
              }
            )}
          >
            More
            <IoChevronDown
              className={classNames("ml-2 transition", { "rotate-180": open })}
              size={20}
            />
          </Menu.Button>
          <Transition
            enter="transition duration-100 ease-out"
            enterFrom="transform scale-95 opacity-0"
            enterTo="transform scale-100 opacity-100"
            leave="transition duration-75 ease-out"
            leaveFrom="transform scale-100 opacity-100"
            leaveTo="transform scale-95 opacity-0"
          >
            <Menu.Items
              data-testid="Desktop.HeaderLink.More.Items"
              className="absolute m-4 min-w-max flex flex-col divide-y bg-white border rounded-lg border-gray-200 dark:border-gray-700 dark:bg-gray-700"
            >
              {dropDownLinks.map((item, index) => {
                return (
                  <Menu.Item key={index}>
                    <DropDownLink
                      routerPathName={router.pathname}
                      item={item}
                      close={close}
                    />
                  </Menu.Item>
                );
              })}
            </Menu.Items>
          </Transition>
        </>
      )}
    </Menu>
  );
}

interface DropDownLinkProps {
  routerPathName: string;
  item: {
    name: string;
    rootPathName: string;
    link: string;
  };
  close: () => void;
}

const DropDownLink = React.forwardRef<HTMLAnchorElement, DropDownLinkProps>(
  ({ routerPathName, item, close }, ref) => {
    return (
      <Link href={{ pathname: item.link }} passHref legacyBehavior>
        <a
          data-testid={`Desktop.HeaderLink.More.Items.${item.rootPathName}`}
          ref={ref}
          href={item.link}
          onClick={close}
          className={classNames(
            "px-6 py-3.5 cursor-pointer text-sm border-gray-200 dark:border-dark-gray-300 border-b-[0.5px] hover:text-primary-500 dark:hover:text-dark-50",
            {
              "dark:text-dark-50 text-primary-500": routerPathName.includes(
                item.rootPathName
              ),
            }
          )}
        >
          {item.name}
        </a>
      </Link>
    );
  }
);

const dropDownLinks = [
  // {
  //   name: "Consortium",
  //   link: "/consortium/asset_breakdown",
  //   rootPathName: "consortium",
  // },
  {
    name: "Tokens",
    link: "/tokens",
    rootPathName: "tokens",
  },
  {
    name: "Proof of Backing",
    link: "/proof-of-backing",
    rootPathName: "proof-of-backing",
  },
];

let drawerMenuItemLinks = [
  {
    text: "Dex",
    pathname: "/dex",
    testId: "Dex",
  },
  {
    text: "Blocks",
    pathname: "/blocks",
    testId: "Blocks",
  },
  {
    text: "Vaults",
    pathname: "/vaults",
    testId: "Vaults",
  },
  {
    text: "Auctions",
    pathname: "/auctions",
    testId: "Auctions",
  },
  {
    text: "Oracles",
    pathname: "/oracles",
    testId: "Oracles",
  },
  {
    text: "Governance",
    pathname: "/governance",
    testId: "Governance",
  },
  {
    text: "Masternodes",
    pathname: "/masternodes",
    testId: "Masternodes",
  },
  // {
  //   text: "Consortium",
  //   pathname: "/consortium/asset_breakdown"
  // },
  {
    text: "Tokens",
    pathname: "/tokens",
    testId: "Tokens",
  },
  {
    text: "Proof of Backing",
    pathname: "/proof-of-backing",
    testId: "/proof-of-backing",
  },
];