diff --git a/apps/insights/src/components/ChangePercent/index.tsx b/apps/insights/src/components/ChangePercent/index.tsx index d77bbc6787..ff441638d5 100644 --- a/apps/insights/src/components/ChangePercent/index.tsx +++ b/apps/insights/src/components/ChangePercent/index.tsx @@ -1,157 +1,47 @@ -"use client"; +import type { ComponentProps } from "react"; -import { type ComponentProps, createContext, use } from "react"; -import { useNumberFormatter } from "react-aria"; -import { z } from "zod"; - -import { StateType, useData } from "../../use-data"; import { ChangeValue } from "../ChangeValue"; -import { useLivePrice } from "../LivePrices"; - -const ONE_SECOND_IN_MS = 1000; -const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; -const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS; -const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS; - -type Props = Omit, "value"> & { - feeds: Record; -}; - -const YesterdaysPricesContext = createContext< - undefined | ReturnType>> ->(undefined); - -export const YesterdaysPricesProvider = ({ feeds, ...props }: Props) => { - const state = useData( - ["yesterdaysPrices", Object.keys(feeds)], - () => getYesterdaysPrices(feeds), - { - refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL, - }, - ); - - return ; -}; - -const getYesterdaysPrices = async ( - feeds: Props["feeds"], -): Promise> => { - const url = new URL("/yesterdays-prices", window.location.origin); - for (const symbol of Object.keys(feeds)) { - url.searchParams.append("symbols", symbol); - } - const response = await fetch(url); - const data = yesterdaysPricesSchema.parse(await response.json()); - return new Map( - Object.entries(data).map(([symbol, value]) => [feeds[symbol] ?? "", value]), - ); -}; - -const yesterdaysPricesSchema = z.record(z.string(), z.number()); - -const useYesterdaysPrices = () => { - const state = use(YesterdaysPricesContext); - - if (state) { - return state; - } else { - throw new YesterdaysPricesNotInitializedError(); - } -}; - -type ChangePercentProps = { - className?: string | undefined; - feedKey: string; -}; - -export const ChangePercent = ({ feedKey, className }: ChangePercentProps) => { - const yesterdaysPriceState = useYesterdaysPrices(); - - switch (yesterdaysPriceState.type) { - case StateType.Error: - case StateType.Loading: - case StateType.NotLoaded: { - return ; - } - - case StateType.Loaded: { - const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey); - return yesterdaysPrice === undefined ? ( - - ) : ( - - ); - } - } -}; +import { FormattedNumber } from "../FormattedNumber"; -type ChangePercentLoadedProps = { +type PriceDifferenceProps = Omit< + ComponentProps, + "children" | "direction" | "isLoading" +> & { className?: string | undefined; - priorPrice: number; - feedKey: string; -}; - -const ChangePercentLoaded = ({ - className, - priorPrice, - feedKey, -}: ChangePercentLoadedProps) => { - const { current } = useLivePrice(feedKey); - - return current === undefined ? ( - - ) : ( - +} & ( + | { isLoading: true } + | { + isLoading?: false; + currentValue: number; + previousValue: number; + } ); -}; - -type PriceDifferenceProps = { - className?: string | undefined; - currentPrice: number; - priorPrice: number; -}; -const PriceDifference = ({ - className, - currentPrice, - priorPrice, -}: PriceDifferenceProps) => { - const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 }); - const direction = getDirection(currentPrice, priorPrice); - - return ( - - {numberFormatter.format( - (100 * Math.abs(currentPrice - priorPrice)) / priorPrice, - )} +export const ChangePercent = ({ ...props }: PriceDifferenceProps) => + props.isLoading ? ( + + ) : ( + + % ); -}; -const getDirection = (currentPrice: number, priorPrice: number) => { - if (currentPrice < priorPrice) { +const getDirection = (currentValue: number, previousValue: number) => { + if (currentValue < previousValue) { return "down"; - } else if (currentPrice > priorPrice) { + } else if (currentValue > previousValue) { return "up"; } else { return "flat"; } }; - -class YesterdaysPricesNotInitializedError extends Error { - constructor() { - super( - "This component must be contained within a ", - ); - this.name = "YesterdaysPricesNotInitializedError"; - } -} diff --git a/apps/insights/src/components/ChartCard/index.module.scss b/apps/insights/src/components/ChartCard/index.module.scss new file mode 100644 index 0000000000..e399e8d68c --- /dev/null +++ b/apps/insights/src/components/ChartCard/index.module.scss @@ -0,0 +1,13 @@ +@use "@pythnetwork/component-library/theme"; + +.chartCard { + .line { + color: theme.color("chart", "series", "neutral"); + } + + &[data-variant="primary"] { + .line { + color: theme.color("chart", "series", "primary"); + } + } +} diff --git a/apps/insights/src/components/Publisher/chart-card.tsx b/apps/insights/src/components/ChartCard/index.tsx similarity index 93% rename from apps/insights/src/components/Publisher/chart-card.tsx rename to apps/insights/src/components/ChartCard/index.tsx index 352b4a70a1..24e561cd87 100644 --- a/apps/insights/src/components/Publisher/chart-card.tsx +++ b/apps/insights/src/components/ChartCard/index.tsx @@ -1,6 +1,7 @@ "use client"; import { StatCard } from "@pythnetwork/component-library/StatCard"; +import clsx from "clsx"; import dynamic from "next/dynamic"; import { type ElementType, @@ -14,6 +15,8 @@ import { import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts"; import type { CategoricalChartState } from "recharts/types/chart/types"; +import styles from "./index.module.scss"; + const LineChart = dynamic( () => import("recharts").then((recharts) => recharts.LineChart), { @@ -25,7 +28,6 @@ const CHART_HEIGHT = 36; type OwnProps = { chartClassName?: string | undefined; - lineClassName?: string | undefined; data: Point[]; }; @@ -43,8 +45,8 @@ type Props = Omit< OwnProps; export const ChartCard = ({ + className, chartClassName, - lineClassName, data, stat, miniStat, @@ -77,6 +79,7 @@ export const ChartCard = ({ return ( ({ diff --git a/apps/insights/src/components/Overview/index.module.scss b/apps/insights/src/components/Overview/index.module.scss index 33435d9de1..c358201402 100644 --- a/apps/insights/src/components/Overview/index.module.scss +++ b/apps/insights/src/components/Overview/index.module.scss @@ -8,5 +8,59 @@ color: theme.color("heading"); font-weight: theme.font-weight("semibold"); + margin-bottom: theme.spacing(6); + } + + .stats { + display: flex; + flex-flow: row nowrap; + align-items: stretch; + gap: theme.spacing(6); + + & > * { + flex: 1 1 0px; + width: 0; + } + + .publishersChart, + .priceFeedsChart { + & svg { + cursor: pointer; + } + } + } + + .overviewMainContent { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: theme.spacing(40); + align-items: center; + padding: theme.spacing(18) 0; + + .headline { + @include theme.text("3xl", "medium"); + + color: theme.color("heading"); + line-height: 125%; + margin-top: theme.spacing(8); + margin-bottom: theme.spacing(4); + } + + .message { + @include theme.text("base", "normal"); + + color: theme.color("heading"); + line-height: 150%; + } + + .tabList { + margin: theme.spacing(12) 0; + } + + .buttons { + display: flex; + flex-flow: row nowrap; + gap: theme.spacing(3); + } } } diff --git a/apps/insights/src/components/Overview/index.tsx b/apps/insights/src/components/Overview/index.tsx index 3185cbd16d..fcf47b4516 100644 --- a/apps/insights/src/components/Overview/index.tsx +++ b/apps/insights/src/components/Overview/index.tsx @@ -1,7 +1,147 @@ +import { Badge } from "@pythnetwork/component-library/Badge"; +import { Button } from "@pythnetwork/component-library/Button"; +import { CrossfadeTabPanels } from "@pythnetwork/component-library/CrossfadeTabPanels"; +import { Tabs } from "@pythnetwork/component-library/unstyled/Tabs"; + import styles from "./index.module.scss"; +import PriceFeeds from "./price-feeds.svg"; +import Publishers from "./publishers.svg"; +import { TabList } from "./tab-list"; +import { + totalVolumeTraded, + activeChains, + activePublishers, + activeFeeds, +} from "../../static-data/stats"; +import { ChangePercent } from "../ChangePercent"; +import { ChartCard } from "../ChartCard"; +import { FormattedDate } from "../FormattedDate"; +import { FormattedNumber } from "../FormattedNumber"; export const Overview = () => (

Overview

+
+ ({ + x: date, + displayX: , + y: volume, + displayY: ( + + ), + }))} + miniStat={ + + } + stat={ + + } + /> + ({ + x: date, + displayX: , + y: numPublishers, + }))} + miniStat={ + + } + stat={activePublishers.at(-1)?.numPublishers} + /> + ({ + x: date, + displayX: , + y: numFeeds, + }))} + miniStat={ + + } + stat={activeFeeds.at(-1)?.numFeeds} + /> + ({ + x: date, + displayX: , + y: chains, + }))} + miniStat={ + + } + stat={activeChains.at(-1)?.chains} + /> +
+ +
+ INSIGHTS +

Get the most from the Pyth Network

+

+ Insights Hub delivers transparency over the network status and + performance, and maximize productivity while integrating. +

+ +
+ + +
+
+ }, + { id: "price feeds", children: }, + ]} + /> +
); diff --git a/apps/insights/src/components/Overview/price-feeds.svg b/apps/insights/src/components/Overview/price-feeds.svg new file mode 100644 index 0000000000..501748eaaa --- /dev/null +++ b/apps/insights/src/components/Overview/price-feeds.svg @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/insights/src/components/Overview/publishers.svg b/apps/insights/src/components/Overview/publishers.svg new file mode 100644 index 0000000000..1ab878dad7 --- /dev/null +++ b/apps/insights/src/components/Overview/publishers.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/insights/src/components/Overview/tab-list.module.scss b/apps/insights/src/components/Overview/tab-list.module.scss new file mode 100644 index 0000000000..673c3fb35a --- /dev/null +++ b/apps/insights/src/components/Overview/tab-list.module.scss @@ -0,0 +1,73 @@ +@use "@pythnetwork/component-library/theme"; + +.tabList { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(2); + + .tab { + padding: theme.spacing(2) theme.spacing(6); + border-radius: theme.border-radius("lg"); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(2); + transition-property: background-color, border-color, outline-color; + transition-duration: 100ms; + transition-timing-function: linear; + border: 1px solid transparent; + outline-offset: 0; + outline: theme.spacing(1) solid transparent; + position: relative; + + &::before { + content: ""; + background: theme.color("border"); + position: absolute; + left: 0; + top: theme.border-radius("lg"); + height: calc(100% - (2 * theme.border-radius("lg"))); + width: 1px; + } + + & > h2 { + @include theme.text("xl", "medium"); + + color: theme.color("heading"); + line-height: normal; + } + + & > p { + @include theme.text("sm", "normal"); + + color: theme.color("heading"); + line-height: 140%; + } + + & > .bar { + position: absolute; + left: 0; + top: theme.border-radius("lg"); + height: calc(100% - (2 * theme.border-radius("lg"))); + width: theme.spacing(0.75); + background: theme.color("foreground"); + } + + &[data-focus-visible] { + border-color: theme.color("focus"); + outline-color: theme.color("focus-dim"); + } + + &:not([data-selected]) { + cursor: pointer; + + &[data-hovered] { + background-color: theme.color( + "button", + "outline", + "background", + "hover" + ); + } + } + } +} diff --git a/apps/insights/src/components/Overview/tab-list.tsx b/apps/insights/src/components/Overview/tab-list.tsx new file mode 100644 index 0000000000..d0e1e771f6 --- /dev/null +++ b/apps/insights/src/components/Overview/tab-list.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { + Tab, + TabList as UnstyledTabList, +} from "@pythnetwork/component-library/unstyled/Tabs"; +import clsx from "clsx"; +import { motion } from "motion/react"; +import { type ComponentProps, useId } from "react"; + +import styles from "./tab-list.module.scss"; + +type OwnProps = { + label: string; + items: (ComponentProps & { header: string; body: string })[]; +}; +type Props = Omit, keyof OwnProps> & + OwnProps; + +export const TabList = ({ label, className, ...props }: Props) => { + const layoutId = useId(); + + return ( + + {({ header, body, className: tabClassName, ...tabProps }) => ( + + {(args) => ( + <> +

{header}

+

{body}

+ {args.isSelected && ( + + )} + + )} +
+ )} +
+ ); +}; diff --git a/apps/insights/src/components/PriceFeed/layout.tsx b/apps/insights/src/components/PriceFeed/layout.tsx index 738998a33b..08629dcfea 100644 --- a/apps/insights/src/components/PriceFeed/layout.tsx +++ b/apps/insights/src/components/PriceFeed/layout.tsx @@ -15,7 +15,6 @@ import { PriceFeedSelect } from "./price-feed-select"; import { ReferenceData } from "./reference-data"; import { toHex } from "../../hex"; import { Cluster, getData } from "../../services/pyth"; -import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent"; import { FeedKey } from "../FeedKey"; import { LivePrice, @@ -23,6 +22,10 @@ import { LiveLastUpdated, LiveValue, } from "../LivePrices"; +import { + YesterdaysPricesProvider, + PriceFeedChangePercent, +} from "../PriceFeedChangePercent"; import { PriceFeedIcon } from "../PriceFeedIcon"; import { PriceFeedTag } from "../PriceFeedTag"; import { TabPanel, TabRoot, Tabs } from "../Tabs"; @@ -166,7 +169,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => { - + } /> diff --git a/apps/insights/src/components/PriceFeedChangePercent/index.tsx b/apps/insights/src/components/PriceFeedChangePercent/index.tsx new file mode 100644 index 0000000000..3e23d06346 --- /dev/null +++ b/apps/insights/src/components/PriceFeedChangePercent/index.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { type ComponentProps, createContext, use } from "react"; +import { z } from "zod"; + +import { StateType, useData } from "../../use-data"; +import { ChangePercent } from "../ChangePercent"; +import { useLivePrice } from "../LivePrices"; + +const ONE_SECOND_IN_MS = 1000; +const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; +const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS; +const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS; + +type YesterdaysPricesProviderProps = Omit< + ComponentProps, + "value" +> & { + feeds: Record; +}; + +const YesterdaysPricesContext = createContext< + undefined | ReturnType>> +>(undefined); + +export const YesterdaysPricesProvider = ({ + feeds, + ...props +}: YesterdaysPricesProviderProps) => { + const state = useData( + ["yesterdaysPrices", Object.keys(feeds)], + () => getYesterdaysPrices(feeds), + { + refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL, + }, + ); + + return ; +}; + +const getYesterdaysPrices = async ( + feeds: YesterdaysPricesProviderProps["feeds"], +): Promise> => { + const url = new URL("/yesterdays-prices", window.location.origin); + for (const symbol of Object.keys(feeds)) { + url.searchParams.append("symbols", symbol); + } + const response = await fetch(url); + const data = yesterdaysPricesSchema.parse(await response.json()); + return new Map( + Object.entries(data).map(([symbol, value]) => [feeds[symbol] ?? "", value]), + ); +}; + +const yesterdaysPricesSchema = z.record(z.string(), z.number()); + +const useYesterdaysPrices = () => { + const state = use(YesterdaysPricesContext); + + if (state) { + return state; + } else { + throw new YesterdaysPricesNotInitializedError(); + } +}; + +type Props = { + className?: string | undefined; + feedKey: string; +}; + +export const PriceFeedChangePercent = ({ feedKey, className }: Props) => { + const yesterdaysPriceState = useYesterdaysPrices(); + + switch (yesterdaysPriceState.type) { + case StateType.Error: + case StateType.Loading: + case StateType.NotLoaded: { + return ; + } + + case StateType.Loaded: { + const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey); + return yesterdaysPrice === undefined ? ( + + ) : ( + + ); + } + } +}; + +type PriceFeedChangePercentLoadedProps = { + className?: string | undefined; + priorPrice: number; + feedKey: string; +}; + +const PriceFeedChangePercentLoaded = ({ + className, + priorPrice, + feedKey, +}: PriceFeedChangePercentLoadedProps) => { + const { current } = useLivePrice(feedKey); + + return current === undefined ? ( + + ) : ( + + ); +}; + +class YesterdaysPricesNotInitializedError extends Error { + constructor() { + super( + "This component must be contained within a ", + ); + this.name = "YesterdaysPricesNotInitializedError"; + } +} diff --git a/apps/insights/src/components/PriceFeeds/index.tsx b/apps/insights/src/components/PriceFeeds/index.tsx index 16691c0844..9af81d165f 100644 --- a/apps/insights/src/components/PriceFeeds/index.tsx +++ b/apps/insights/src/components/PriceFeeds/index.tsx @@ -19,8 +19,12 @@ import styles from "./index.module.scss"; import { PriceFeedsCard } from "./price-feeds-card"; import { Cluster, getData } from "../../services/pyth"; import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds"; -import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent"; +import { activeChains } from "../../static-data/stats"; import { LivePrice } from "../LivePrices"; +import { + YesterdaysPricesProvider, + PriceFeedChangePercent, +} from "../PriceFeedChangePercent"; import { PriceFeedIcon } from "../PriceFeedIcon"; import { PriceFeedTag } from "../PriceFeedTag"; @@ -62,7 +66,7 @@ export const PriceFeeds = async () => { /> } @@ -182,7 +186,7 @@ const FeaturedFeedsCard = ({ {showPrices && (
- diff --git a/apps/insights/src/components/Publisher/layout.module.scss b/apps/insights/src/components/Publisher/layout.module.scss index 81697a4ec2..b858dc42e1 100644 --- a/apps/insights/src/components/Publisher/layout.module.scss +++ b/apps/insights/src/components/Publisher/layout.module.scss @@ -39,14 +39,6 @@ margin-right: -#{theme.button-padding("xs", false)}; } - .primarySparkChartLine { - color: theme.color("chart", "series", "primary"); - } - - .secondarySparkChartLine { - color: theme.color("chart", "series", "neutral"); - } - .activeDate { color: theme.color("muted"); } diff --git a/apps/insights/src/components/Publisher/layout.tsx b/apps/insights/src/components/Publisher/layout.tsx index fc7cfc7bae..ecd33b8a7d 100644 --- a/apps/insights/src/components/Publisher/layout.tsx +++ b/apps/insights/src/components/Publisher/layout.tsx @@ -16,7 +16,6 @@ import { notFound } from "next/navigation"; import type { ReactNode } from "react"; import { ActiveFeedsCard } from "./active-feeds-card"; -import { ChartCard } from "./chart-card"; import { getPriceFeeds } from "./get-price-feeds"; import styles from "./layout.module.scss"; import { OisApyHistory } from "./ois-apy-history"; @@ -29,7 +28,9 @@ import { getPublisherCaps } from "../../services/hermes"; import { Cluster, getTotalFeedCount } from "../../services/pyth"; import { getPublisherPoolData } from "../../services/staking"; import { Status } from "../../status"; +import { ChangePercent } from "../ChangePercent"; import { ChangeValue } from "../ChangeValue"; +import { ChartCard } from "../ChartCard"; import { FormattedDate } from "../FormattedDate"; import { FormattedNumber } from "../FormattedNumber"; import { FormattedTokens } from "../FormattedTokens"; @@ -113,7 +114,6 @@ export const PublishersLayout = async ({ children, params }: Props) => {