瀏覽代碼

Merge pull request #2175 from pyth-network/cprussin/add-card-corners

feat(insights): add StatCard icons and Alerts, plus code cleanup
Connor Prussin 11 月之前
父節點
當前提交
f6ea5c2613

+ 8 - 1
apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx

@@ -80,7 +80,14 @@ const AssetClassTable = ({
             count: <Badge style="outline">{count}</Badge>,
           },
         })),
-    [numFeedsByAssetClass, collator, closeDrawer, pathname, updateAssetClass],
+    [
+      numFeedsByAssetClass,
+      collator,
+      closeDrawer,
+      pathname,
+      updateAssetClass,
+      updateSearch,
+    ],
   );
   return (
     <Table

+ 28 - 0
apps/insights/src/components/PriceFeeds/change-percent.module.scss

@@ -0,0 +1,28 @@
+@use "@pythnetwork/component-library/theme";
+
+.changePercent {
+  font-size: theme.font-size("sm");
+  transition: color 100ms linear;
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(1);
+  align-items: center;
+
+  .caret {
+    width: theme.spacing(3);
+    height: theme.spacing(3);
+    transition: transform 300ms linear;
+  }
+
+  &[data-direction="up"] {
+    color: theme.color("states", "success", "base");
+  }
+
+  &[data-direction="down"] {
+    color: theme.color("states", "error", "base");
+
+    .caret {
+      transform: rotate3d(1, 0, 0, 180deg);
+    }
+  }
+}

+ 57 - 64
apps/insights/src/components/PriceFeeds/featured-recently-added.tsx → apps/insights/src/components/PriceFeeds/change-percent.tsx

@@ -1,15 +1,14 @@
 "use client";
 
 import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp";
-import { Card } from "@pythnetwork/component-library/Card";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { type ReactNode, useMemo } from "react";
+import { type ComponentProps, createContext, use } from "react";
 import { useNumberFormatter } from "react-aria";
 import { z } from "zod";
 
-import styles from "./featured-recently-added.module.scss";
+import styles from "./change-percent.module.scss";
 import { StateType, useData } from "../../use-data";
-import { LivePrice, useLivePrice } from "../LivePrices";
+import { useLivePrice } from "../LivePrices";
 
 const ONE_SECOND_IN_MS = 1000;
 const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
@@ -18,87 +17,64 @@ const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;
 
 const CHANGE_PERCENT_SKELETON_WIDTH = 15;
 
-type Props = {
-  recentlyAdded: RecentlyAddedPriceFeed[];
+type Props = Omit<ComponentProps<typeof YesterdaysPricesContext>, "value"> & {
+  symbolsToFeedKeys: Record<string, string>;
 };
 
-type RecentlyAddedPriceFeed = {
-  id: string;
-  symbol: string;
-  priceFeedName: ReactNode;
-};
+const YesterdaysPricesContext = createContext<
+  undefined | ReturnType<typeof useData<Map<string, number>>>
+>(undefined);
 
-export const FeaturedRecentlyAdded = ({ recentlyAdded }: Props) => {
-  const feedKeys = useMemo(
-    () => recentlyAdded.map(({ id }) => id),
-    [recentlyAdded],
-  );
-  const symbols = useMemo(
-    () => recentlyAdded.map(({ symbol }) => symbol),
-    [recentlyAdded],
-  );
+export const YesterdaysPricesProvider = ({
+  symbolsToFeedKeys,
+  ...props
+}: Props) => {
   const state = useData(
-    ["yesterdaysPrices", feedKeys],
-    () => getYesterdaysPrices(symbols),
+    ["yesterdaysPrices", Object.values(symbolsToFeedKeys)],
+    () => getYesterdaysPrices(symbolsToFeedKeys),
     {
       refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
     },
   );
 
-  return (
-    <>
-      {recentlyAdded.map(({ priceFeedName, id, symbol }, i) => (
-        <Card
-          key={i}
-          href="#"
-          title={priceFeedName}
-          footer={
-            <div className={styles.footer}>
-              <LivePrice account={id} />
-              <div className={styles.changePercent}>
-                <ChangePercent
-                  yesterdaysPriceState={state}
-                  feedKey={id}
-                  symbol={symbol}
-                />
-              </div>
-            </div>
-          }
-          className={styles.recentlyAddedFeed ?? ""}
-          variant="tertiary"
-        />
-      ))}
-    </>
-  );
+  return <YesterdaysPricesContext value={state} {...props} />;
 };
 
 const getYesterdaysPrices = async (
-  symbols: string[],
-): Promise<Record<string, number>> => {
+  symbolsToFeedKeys: Record<string, string>,
+): Promise<Map<string, number>> => {
   const url = new URL("/yesterdays-prices", window.location.origin);
-  for (const symbol of symbols) {
+  for (const symbol of Object.keys(symbolsToFeedKeys)) {
     url.searchParams.append("symbols", symbol);
   }
   const response = await fetch(url);
   const data: unknown = await response.json();
-  return yesterdaysPricesSchema.parse(data);
+  return new Map(
+    Object.entries(yesterdaysPricesSchema.parse(data)).map(
+      ([symbol, value]) => [symbolsToFeedKeys[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 = {
-  yesterdaysPriceState: ReturnType<
-    typeof useData<Awaited<ReturnType<typeof getYesterdaysPrices>>>
-  >;
   feedKey: string;
-  symbol: string;
 };
 
-const ChangePercent = ({
-  yesterdaysPriceState,
-  feedKey,
-  symbol,
-}: ChangePercentProps) => {
+export const ChangePercent = ({ feedKey }: ChangePercentProps) => {
+  const yesterdaysPriceState = useYesterdaysPrices();
+
   switch (yesterdaysPriceState.type) {
     case StateType.Error: {
       // eslint-disable-next-line unicorn/no-null
@@ -107,11 +83,16 @@ const ChangePercent = ({
 
     case StateType.Loading:
     case StateType.NotLoaded: {
-      return <Skeleton width={CHANGE_PERCENT_SKELETON_WIDTH} />;
+      return (
+        <Skeleton
+          className={styles.changePercent}
+          width={CHANGE_PERCENT_SKELETON_WIDTH}
+        />
+      );
     }
 
     case StateType.Loaded: {
-      const yesterdaysPrice = yesterdaysPriceState.data[symbol];
+      const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey);
       // eslint-disable-next-line unicorn/no-null
       return yesterdaysPrice === undefined ? null : (
         <ChangePercentLoaded priorPrice={yesterdaysPrice} feedKey={feedKey} />
@@ -132,7 +113,10 @@ const ChangePercentLoaded = ({
   const currentPrice = useLivePrice(feedKey);
 
   return currentPrice === undefined ? (
-    <Skeleton width={CHANGE_PERCENT_SKELETON_WIDTH} />
+    <Skeleton
+      className={styles.changePercent}
+      width={CHANGE_PERCENT_SKELETON_WIDTH}
+    />
   ) : (
     <PriceDifference
       currentPrice={currentPrice.price}
@@ -154,7 +138,7 @@ const PriceDifference = ({
   const direction = getDirection(currentPrice, priorPrice);
 
   return (
-    <span data-direction={direction} className={styles.price}>
+    <span data-direction={direction} className={styles.changePercent}>
       <CaretUp weight="fill" className={styles.caret} />
       {numberFormatter.format(
         (100 * Math.abs(currentPrice - priorPrice)) / currentPrice,
@@ -173,3 +157,12 @@ const getDirection = (currentPrice: number, priorPrice: number) => {
     return "flat";
   }
 };
+
+class YesterdaysPricesNotInitializedError extends Error {
+  constructor() {
+    super(
+      "This component must be contained within a <YesterdaysPricesProvider>",
+    );
+    this.name = "YesterdaysPricesNotInitializedError";
+  }
+}

+ 0 - 43
apps/insights/src/components/PriceFeeds/featured-recently-added.module.scss

@@ -1,43 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.recentlyAddedFeed .footer {
-  display: flex;
-  flex-flow: row nowrap;
-  justify-content: space-between;
-  align-items: center;
-  color: theme.color("heading");
-  font-weight: theme.font-weight("medium");
-  line-height: theme.spacing(6);
-  font-size: theme.font-size("base");
-  padding: 0 theme.spacing(2);
-
-  .changePercent {
-    font-size: theme.font-size("sm");
-
-    .price {
-      transition: color 100ms linear;
-      display: flex;
-      flex-flow: row nowrap;
-      gap: theme.spacing(1);
-      align-items: center;
-
-      .caret {
-        width: theme.spacing(3);
-        height: theme.spacing(3);
-        transition: transform 300ms linear;
-      }
-
-      &[data-direction="up"] {
-        color: theme.color("states", "success", "base");
-      }
-
-      &[data-direction="down"] {
-        color: theme.color("states", "error", "base");
-
-        .caret {
-          transform: rotate3d(1, 0, 0, 180deg);
-        }
-      }
-    }
-  }
-}

+ 101 - 59
apps/insights/src/components/PriceFeeds/index.module.scss

@@ -1,55 +1,16 @@
 @use "@pythnetwork/component-library/theme";
 
-.priceFeeds {
-  @include theme.max-width;
-
-  .header {
-    @include theme.h3;
-
-    color: theme.color("heading");
-    font-weight: theme.font-weight("semibold");
-    margin: theme.spacing(6) 0;
-  }
-
-  .body {
-    display: flex;
-    flex-flow: column nowrap;
-    gap: theme.spacing(6);
-
-    .featuredFeeds {
-      display: flex;
-      flex-flow: row nowrap;
-      gap: theme.spacing(1);
-
-      & > * {
-        flex: 1;
-      }
-    }
-
-    .stats {
-      display: flex;
-      flex-flow: row nowrap;
-      gap: theme.spacing(4);
-      align-items: center;
-
-      & > * {
-        flex: 1;
-      }
-    }
-
-    .priceFeedId {
-      color: theme.color("link", "normal");
-      font-weight: theme.font-weight("medium");
-    }
-  }
-}
-
 .priceFeedNameAndIcon,
 .priceFeedNameAndDescription {
   display: flex;
   flex-flow: row nowrap;
   gap: theme.spacing(3);
   align-items: center;
+  width: 100%;
+
+  .priceFeedIcon {
+    flex: none;
+  }
 
   .priceFeedName {
     display: flex;
@@ -73,31 +34,112 @@
   }
 }
 
-.priceFeedNameAndIcon .priceFeedIcon {
-  width: theme.spacing(6);
-  height: theme.spacing(6);
+.priceFeedNameAndIcon {
+  .priceFeedIcon {
+    width: theme.spacing(6);
+    height: theme.spacing(6);
 
-  &.skeleton {
-    border-radius: theme.border-radius("full");
+    &.skeleton {
+      border-radius: theme.border-radius("full");
+    }
+  }
+
+  .priceFeedName {
+    flex-grow: 1;
+    flex-basis: 0;
   }
 }
 
-.priceFeedNameAndDescription {
-  .priceFeedIcon {
-    width: theme.spacing(10);
-    height: theme.spacing(10);
+.priceFeeds {
+  @include theme.max-width;
+
+  .header {
+    @include theme.h3;
+
+    color: theme.color("heading");
+    font-weight: theme.font-weight("semibold");
+    margin: theme.spacing(6) 0;
   }
 
-  .nameAndDescription {
+  .body {
     display: flex;
     flex-flow: column nowrap;
-    gap: theme.spacing(1);
+    gap: theme.spacing(6);
 
-    .description {
-      font-size: theme.font-size("xs");
+    .featuredFeeds,
+    .stats {
+      display: flex;
+      flex-flow: row nowrap;
+      align-items: center;
+
+      & > * {
+        flex: 1 1 0px;
+        width: 0;
+      }
+    }
+
+    .stats {
+      gap: theme.spacing(4);
+    }
+
+    .featuredFeeds {
+      gap: theme.spacing(1);
+
+      .feedCardContents {
+        display: flex;
+        flex-flow: column nowrap;
+        justify-content: space-between;
+        align-items: stretch;
+        padding: theme.spacing(3);
+        gap: theme.spacing(6);
+
+        .priceFeedNameAndDescription {
+          .priceFeedIcon {
+            width: theme.spacing(10);
+            height: theme.spacing(10);
+          }
+
+          .nameAndDescription {
+            display: flex;
+            flex-flow: column nowrap;
+            gap: theme.spacing(1);
+            flex-grow: 1;
+            flex-basis: 0;
+            white-space: nowrap;
+            overflow: hidden;
+
+            .priceFeedName {
+              overflow: hidden;
+              text-overflow: ellipsis;
+            }
+
+            .description {
+              font-size: theme.font-size("xs");
+              font-weight: theme.font-weight("medium");
+              line-height: theme.spacing(4);
+              color: theme.color("muted");
+              overflow: hidden;
+              text-overflow: ellipsis;
+            }
+          }
+        }
+
+        .prices {
+          display: flex;
+          flex-flow: row nowrap;
+          justify-content: space-between;
+          align-items: center;
+          color: theme.color("heading");
+          font-weight: theme.font-weight("medium");
+          line-height: 1;
+          font-size: theme.font-size("base");
+        }
+      }
+    }
+
+    .priceFeedId {
+      color: theme.color("link", "normal");
       font-weight: theme.font-weight("medium");
-      line-height: theme.spacing(4);
-      color: theme.color("muted");
     }
   }
 }

+ 85 - 50
apps/insights/src/components/PriceFeeds/index.tsx

@@ -1,26 +1,33 @@
+import { ArrowLineDown } from "@phosphor-icons/react/dist/ssr/ArrowLineDown";
+import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
 import { ClockCountdown } from "@phosphor-icons/react/dist/ssr/ClockCountdown";
+import { Info } from "@phosphor-icons/react/dist/ssr/Info";
 import { StackPlus } from "@phosphor-icons/react/dist/ssr/StackPlus";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Button } from "@pythnetwork/component-library/Button";
-import { Card } from "@pythnetwork/component-library/Card";
+import {
+  type Props as CardProps,
+  Card,
+} from "@pythnetwork/component-library/Card";
 import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import base58 from "bs58";
 import clsx from "clsx";
 import Generic from "cryptocurrency-icons/svg/color/generic.svg";
-import { Fragment } from "react";
+import { Fragment, type ElementType } from "react";
 import { z } from "zod";
 
 import { AssetClassesDrawer } from "./asset-classes-drawer";
+import { YesterdaysPricesProvider, ChangePercent } from "./change-percent";
 import { ComingSoonList } from "./coming-soon-list";
-import { FeaturedRecentlyAdded } from "./featured-recently-added";
 import styles from "./index.module.scss";
 import { PriceFeedsCard } from "./price-feeds-card";
 import { getIcon } from "../../icons";
 import { client } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { CopyButton } from "../CopyButton";
+import { LivePrice } from "../LivePrices";
 
 const PRICE_FEEDS_ANCHOR = "priceFeeds";
 
@@ -37,6 +44,10 @@ export const PriceFeeds = async () => {
         !priceFeedsStaticConfig.featuredComingSoon.includes(symbol),
     ),
   ].slice(0, 5);
+  const featuredRecentlyAdded = filterFeeds(
+    priceFeeds.activeFeeds,
+    priceFeedsStaticConfig.featuredRecentlyAdded,
+  );
 
   return (
     <div className={styles.priceFeeds}>
@@ -48,6 +59,7 @@ export const PriceFeeds = async () => {
             header="Active Feeds"
             stat={priceFeeds.activeFeeds.length}
             href={`#${PRICE_FEEDS_ANCHOR}`}
+            corner={<ArrowLineDown />}
           />
           <StatCard
             header="Frequency"
@@ -58,35 +70,36 @@ export const PriceFeeds = async () => {
             stat={priceFeedsStaticConfig.activeChains}
             href="https://docs.pyth.network/price-feeds/contract-addresses"
             target="_blank"
+            corner={<ArrowSquareOut weight="fill" />}
           />
           <AssetClassesDrawer numFeedsByAssetClass={numFeedsByAssetClass}>
             <StatCard
               header="Asset Classes"
               stat={Object.keys(numFeedsByAssetClass).length}
+              corner={<Info weight="fill" />}
             />
           </AssetClassesDrawer>
         </div>
-        <Card title="Recently added" icon={<StackPlus />}>
-          <div className={styles.featuredFeeds}>
-            <FeaturedRecentlyAdded
-              recentlyAdded={filterFeeds(
-                priceFeeds.activeFeeds,
-                priceFeedsStaticConfig.featuredRecentlyAdded,
-              ).map(({ product, symbol }) => ({
-                id: product.price_account,
-                symbol,
-                priceFeedName: (
-                  <PriceNameAndDescription description={product.description}>
-                    {product.display_symbol}
-                  </PriceNameAndDescription>
-                ),
-              }))}
-            />
-          </div>
-        </Card>
-        <Card
+        <YesterdaysPricesProvider
+          symbolsToFeedKeys={Object.fromEntries(
+            featuredRecentlyAdded.map(({ symbol, product }) => [
+              symbol,
+              product.price_account,
+            ]),
+          )}
+        >
+          <FeaturedFeedsCard
+            title="Recently added"
+            icon={<StackPlus />}
+            feeds={featuredRecentlyAdded}
+            showPrices
+            linkFeeds
+          />
+        </YesterdaysPricesProvider>
+        <FeaturedFeedsCard
           title="Coming soon"
           icon={<ClockCountdown />}
+          feeds={featuredComingSoon}
           toolbar={
             <DrawerTrigger>
               <Button size="xs" variant="outline">
@@ -124,21 +137,7 @@ export const PriceFeeds = async () => {
               </Drawer>
             </DrawerTrigger>
           }
-        >
-          <div className={styles.featuredFeeds}>
-            {featuredComingSoon.map(({ product }, id) => (
-              <Card
-                key={id}
-                title={
-                  <PriceNameAndDescription description={product.description}>
-                    {product.display_symbol}
-                  </PriceNameAndDescription>
-                }
-                variant="tertiary"
-              />
-            ))}
-          </div>
-        </Card>
+        />
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
           nameLoadingSkeleton={
@@ -187,20 +186,56 @@ export const PriceFeeds = async () => {
   );
 };
 
-const PriceNameAndDescription = ({
-  children,
-  description,
-}: {
-  children: string;
-  description: string;
-}) => (
-  <div className={styles.priceFeedNameAndDescription}>
-    <PriceFeedIcon>{children}</PriceFeedIcon>
-    <div className={styles.nameAndDescription}>
-      <PriceFeedName>{children}</PriceFeedName>
-      <div className={styles.description}>{description.split("/")[0]}</div>
+type FeaturedFeedsCardProps<T extends ElementType> = Omit<
+  CardProps<T>,
+  "children"
+> & {
+  showPrices?: boolean | undefined;
+  linkFeeds?: boolean | undefined;
+  feeds: {
+    product: {
+      display_symbol: string;
+      price_account: string;
+      description: string;
+    };
+  }[];
+};
+
+const FeaturedFeedsCard = <T extends ElementType>({
+  showPrices,
+  linkFeeds,
+  feeds,
+  ...props
+}: FeaturedFeedsCardProps<T>) => (
+  <Card {...props}>
+    <div className={styles.featuredFeeds}>
+      {feeds.map(({ product }) => (
+        <Card
+          key={product.price_account}
+          variant="tertiary"
+          {...(linkFeeds && { href: "#" })}
+        >
+          <div className={styles.feedCardContents}>
+            <div className={styles.priceFeedNameAndDescription}>
+              <PriceFeedIcon>{product.display_symbol}</PriceFeedIcon>
+              <div className={styles.nameAndDescription}>
+                <PriceFeedName>{product.display_symbol}</PriceFeedName>
+                <div className={styles.description}>
+                  {product.description.split("/")[0]}
+                </div>
+              </div>
+            </div>
+            {showPrices && (
+              <div className={styles.prices}>
+                <LivePrice account={product.price_account} />
+                <ChangePercent feedKey={product.price_account} />
+              </div>
+            )}
+          </div>
+        </Card>
+      ))}
     </div>
-  </div>
+  </Card>
 );
 
 const PriceFeedNameAndIcon = ({ children }: { children: string }) => (

+ 0 - 168
apps/insights/src/components/PriceFeeds/use-filtered-price-feeds.tsx

@@ -1,168 +0,0 @@
-"use client";
-
-import { useLogger } from "@pythnetwork/app-logger";
-import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
-import {
-  type ReactNode,
-  type ComponentProps,
-  Suspense,
-  createContext,
-  useCallback,
-  useMemo,
-  use,
-} from "react";
-import { useFilter, useCollator } from "react-aria";
-
-export const queryParams = {
-  assetClass: parseAsString.withDefault(""),
-  page: parseAsInteger.withDefault(1),
-  pageSize: parseAsInteger.withDefault(30),
-  search: parseAsString.withDefault(""),
-};
-
-const FilteredPriceFeedsContext = createContext<
-  undefined | ReturnType<typeof useFilteredPriceFeedsContext>
->(undefined);
-
-type FilteredPriceFeedsProviderProps = Omit<
-  ComponentProps<typeof FilteredPriceFeedsContext>,
-  "value"
-> & {
-  priceFeeds: PriceFeed[];
-};
-
-type PriceFeed = {
-  symbol: string;
-  id: string;
-  displaySymbol: string;
-  assetClassAsString: string;
-  exponent: number;
-  numPublishers: number;
-  priceFeedId: ReactNode;
-  priceFeedName: ReactNode;
-  assetClass: ReactNode;
-};
-
-export const FilteredPriceFeedsProvider = (
-  props: FilteredPriceFeedsProviderProps,
-) => (
-  <Suspense>
-    <ResolvedFilteredPriceFeedsProvider {...props} />
-  </Suspense>
-);
-
-const ResolvedFilteredPriceFeedsProvider = ({
-  priceFeeds,
-  ...props
-}: FilteredPriceFeedsProviderProps) => {
-  const value = useFilteredPriceFeedsContext(priceFeeds);
-
-  return <FilteredPriceFeedsContext value={value} {...props} />;
-};
-
-export const useFilteredPriceFeedsContext = (priceFeeds: PriceFeed[]) => {
-  const logger = useLogger();
-
-  const [{ search, page, pageSize, assetClass }, setQuery] =
-    useQueryStates(queryParams);
-
-  const updateQuery = useCallback(
-    (...params: Parameters<typeof setQuery>) => {
-      setQuery(...params).catch((error: unknown) => {
-        logger.error("Failed to update query", error);
-      });
-    },
-    [setQuery, logger],
-  );
-
-  const updateSearch = useCallback(
-    (newSearch: string) => {
-      updateQuery({ page: 1, search: newSearch });
-    },
-    [updateQuery],
-  );
-
-  const updatePage = useCallback(
-    (newPage: number) => {
-      updateQuery({ page: newPage });
-    },
-    [updateQuery],
-  );
-
-  const updatePageSize = useCallback(
-    (newPageSize: number) => {
-      updateQuery({ page: 1, pageSize: newPageSize });
-    },
-    [updateQuery],
-  );
-
-  const updateAssetClass = useCallback(
-    (newAssetClass: string) => {
-      updateQuery({ page: 1, assetClass: newAssetClass });
-    },
-    [updateQuery],
-  );
-
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const collator = useCollator();
-  const sortedFeeds = useMemo(
-    () =>
-      priceFeeds.sort((a, b) =>
-        collator.compare(a.displaySymbol, b.displaySymbol),
-      ),
-    [priceFeeds, collator],
-  );
-  const feedsFilteredByAssetClass = useMemo(
-    () =>
-      assetClass
-        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
-        : sortedFeeds,
-    [assetClass, sortedFeeds],
-  );
-  const filteredFeeds = useMemo(() => {
-    if (search === "") {
-      return feedsFilteredByAssetClass;
-    } else {
-      const searchTokens = search
-        .split(" ")
-        .flatMap((item) => item.split(","))
-        .filter(Boolean);
-      return feedsFilteredByAssetClass.filter((feed) =>
-        searchTokens.some((token) => filter.contains(feed.symbol, token)),
-      );
-    }
-  }, [search, feedsFilteredByAssetClass, filter]);
-  const paginatedFeeds = useMemo(
-    () => filteredFeeds.slice((page - 1) * pageSize, page * pageSize),
-    [page, pageSize, filteredFeeds],
-  );
-
-  return {
-    filteredFeeds,
-    paginatedFeeds,
-    search,
-    page,
-    pageSize,
-    assetClass,
-    updateSearch,
-    updatePage,
-    updatePageSize,
-    updateAssetClass,
-  };
-};
-
-export const useFilteredPriceFeeds = () => {
-  const value = use(FilteredPriceFeedsContext);
-  if (value) {
-    return value;
-  } else {
-    throw new FilteredPriceFeedsNotInitializedError();
-  }
-};
-
-class FilteredPriceFeedsNotInitializedError extends Error {
-  constructor() {
-    super("This component must be a child of <FilteredPriceFeedsProvider>");
-    this.name = "FilteredPriceFeedsNotInitializedError";
-  }
-}

+ 13 - 0
apps/insights/src/components/Publishers/index.module.scss

@@ -27,6 +27,11 @@
       position: sticky;
       top: root.$header-height;
 
+      .averageMedianScoreExplainButton {
+        margin-top: -#{theme.button-padding("xs", false)};
+        margin-right: -#{theme.button-padding("xs", false)};
+      }
+
       .oisCard {
         grid-column: span 2 / span 2;
 
@@ -98,6 +103,14 @@
   }
 }
 
+.averageMedianScoreDescription {
+  margin: 0;
+
+  b {
+    font-weight: theme.font-weight("semibold");
+  }
+}
+
 .ranking,
 .rankingLoader {
   height: theme.spacing(6);

+ 40 - 1
apps/insights/src/components/Publishers/index.tsx

@@ -1,6 +1,9 @@
 import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
-import { ButtonLink } from "@pythnetwork/component-library/Button";
+import { Info } from "@phosphor-icons/react/dist/ssr/Info";
+import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
+import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
+import { ButtonLink, Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
@@ -41,6 +44,42 @@ export const Publishers = async () => {
           />
           <StatCard
             header="Avg. Median Score"
+            corner={
+              <AlertTrigger>
+                <Button
+                  variant="ghost"
+                  size="xs"
+                  beforeIcon={(props) => <Info weight="fill" {...props} />}
+                  rounded
+                  hideText
+                  className={styles.averageMedianScoreExplainButton ?? ""}
+                >
+                  Explain Average Median Score
+                </Button>
+                <Alert title="Average Median Score" icon={<Lightbulb />}>
+                  <p className={styles.averageMedianScoreDescription}>
+                    Each <b>Price Feed Component</b> that a <b>Publisher</b>{" "}
+                    provides has an associated <b>Score</b>, which is determined
+                    by that component{"'"}s <b>Uptime</b>,{" "}
+                    <b>Price Deviation</b>, and <b>Staleness</b>. The publisher
+                    {"'"}s <b>Median Score</b> measures the 50th percentile of
+                    the <b>Score</b> across all of that publisher{"'"}s{" "}
+                    <b>Price Feed Components</b>. The{" "}
+                    <b>Average Median Score</b> is the average of the{" "}
+                    <b>Median Scores</b> of all publishers who contribute to the
+                    Pyth Network.
+                  </p>
+                  <ButtonLink
+                    size="xs"
+                    variant="solid"
+                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
+                    target="_blank"
+                  >
+                    Learn more
+                  </ButtonLink>
+                </Alert>
+              </AlertTrigger>
+            }
             stat={(
               publishers.reduce(
                 (sum, publisher) => sum + publisher.medianScore,

+ 6 - 4
packages/app-logger/src/index.tsx

@@ -7,11 +7,13 @@ export const useLogger = () => {
   if (logger) {
     return logger;
   } else {
-    throw new NotInitializedError();
+    throw new LoggerNotInitializedError();
   }
 };
 
-class NotInitializedError extends Error {
-  override message =
-    "This component must be contained within a `LoggerProvider`!";
+class LoggerNotInitializedError extends Error {
+  constructor() {
+    super("This component must be contained within a <LoggerProvider>");
+    this.name = "LoggerNotInitializedError";
+  }
 }

+ 6 - 0
packages/component-library/.storybook/preview.tsx

@@ -5,6 +5,7 @@ import clsx from "clsx";
 
 import "../src/Html/base.scss";
 import styles from "./storybook.module.scss";
+import { OverlayVisibleContextProvider } from "../src/overlay-visible-context.js";
 
 const preview = {
   parameters: {
@@ -28,6 +29,11 @@ const preview = {
 export default preview;
 
 export const decorators: Decorator[] = [
+  (Story) => (
+    <OverlayVisibleContextProvider>
+      <Story />
+    </OverlayVisibleContextProvider>
+  ),
   withThemeByClassName({
     themes: {
       Light: clsx(sans.className, styles.light),

+ 63 - 0
packages/component-library/src/Alert/index.module.scss

@@ -0,0 +1,63 @@
+@use "../theme";
+
+.modalOverlay {
+  position: fixed;
+  inset: 0;
+  z-index: 1;
+
+  .modal {
+    position: fixed;
+    bottom: theme.spacing(8);
+    right: theme.spacing(8);
+    outline: none;
+
+    .dialog {
+      background: theme.color("states", "info", "background-opaque");
+      border-radius: theme.border-radius("3xl");
+      backdrop-filter: blur(32px);
+      width: theme.spacing(156);
+      outline: none;
+      position: relative;
+      padding: theme.spacing(6);
+      padding-right: theme.spacing(16);
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(4);
+
+      .closeButton {
+        position: absolute;
+        right: theme.spacing(2);
+        top: theme.spacing(2);
+      }
+
+      .title {
+        @include theme.h4;
+
+        display: flex;
+        flex-flow: row nowrap;
+        gap: theme.spacing(3);
+        align-items: center;
+        color: theme.color("heading");
+        line-height: 1;
+
+        .icon {
+          color: theme.color("states", "info", "normal");
+          flex: none;
+          display: grid;
+          place-content: center;
+          font-size: theme.spacing(6);
+        }
+      }
+
+      .body {
+        color: theme.color("paragraph");
+        font-size: theme.font-size("sm");
+        line-height: 140%;
+        display: flex;
+        flex-flow: column nowrap;
+        gap: theme.spacing(4);
+        align-items: flex-start;
+      }
+    }
+  }
+}

+ 50 - 0
packages/component-library/src/Alert/index.stories.tsx

@@ -0,0 +1,50 @@
+import * as Icon from "@phosphor-icons/react/dist/ssr";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Alert as AlertComponent, AlertTrigger } from "./index.js";
+import { Button } from "../Button/index.js";
+
+const meta = {
+  component: AlertComponent,
+  decorators: [
+    (Story) => (
+      <AlertTrigger>
+        <Button>Click me!</Button>
+        <Story />
+      </AlertTrigger>
+    ),
+  ],
+  argTypes: {
+    icon: {
+      control: "select",
+      options: Object.keys(Icon),
+      mapping: Object.fromEntries(
+        Object.entries(Icon).map(([key, Icon]) => [key, <Icon key={key} />]),
+      ),
+      table: {
+        category: "Contents",
+      },
+    },
+    title: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    children: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+  },
+} satisfies Meta<typeof AlertComponent>;
+export default meta;
+
+export const Alert = {
+  args: {
+    title: "An Alert",
+    children:
+      "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+  },
+} satisfies StoryObj<typeof AlertComponent>;

+ 69 - 0
packages/component-library/src/Alert/index.tsx

@@ -0,0 +1,69 @@
+"use client";
+
+import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
+import clsx from "clsx";
+import type { ComponentProps, ReactNode } from "react";
+import { Dialog, Heading } from "react-aria-components";
+
+import styles from "./index.module.scss";
+import { Button } from "../Button/index.js";
+import { Modal } from "../Modal/index.js";
+
+export { DialogTrigger as AlertTrigger } from "react-aria-components";
+
+const CLOSE_DURATION_IN_S = 0.1;
+export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
+
+type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> & {
+  icon?: ReactNode | undefined;
+  title: ReactNode;
+};
+
+type Props = Omit<ComponentProps<typeof Dialog>, keyof OwnProps> & OwnProps;
+
+export const Alert = ({
+  icon,
+  title,
+  children,
+  className,
+  ...props
+}: Props) => (
+  <Modal
+    overlayProps={{
+      className: styles.modalOverlay ?? "",
+    }}
+    initial={{ y: "100%" }}
+    animate={{
+      y: 0,
+      transition: { type: "spring", duration: 0.75, bounce: 0.5 },
+    }}
+    exit={{
+      y: "100%",
+      transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+    }}
+    className={clsx(styles.modal, className)}
+  >
+    {(state) => (
+      <Dialog className={styles.dialog ?? ""} {...props}>
+        <Button
+          className={styles.closeButton ?? ""}
+          beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+          slot="close"
+          hideText
+          rounded
+          variant="ghost"
+          size="sm"
+        >
+          Close
+        </Button>
+        <Heading className={styles.title} slot="title">
+          {icon && <div className={styles.icon}>{icon}</div>}
+          <div>{title}</div>
+        </Heading>
+        <div className={styles.body}>
+          {typeof children === "function" ? children(state) : children}
+        </div>
+      </Dialog>
+    )}
+  </Modal>
+);

+ 4 - 4
packages/component-library/src/Drawer/index.module.scss

@@ -13,13 +13,13 @@
     right: theme.spacing(4);
     width: 40%;
     max-width: theme.spacing(160);
-    background: theme.color("background", "primary");
-    border: 1px solid theme.color("border");
-    border-radius: theme.border-radius("3xl");
     outline: none;
-    overflow: hidden;
 
     .dialog {
+      background: theme.color("background", "primary");
+      border: 1px solid theme.color("border");
+      border-radius: theme.border-radius("3xl");
+      overflow: hidden;
       outline: none;
       display: flex;
       flex-flow: column nowrap;

+ 46 - 98
packages/component-library/src/Drawer/index.tsx

@@ -2,116 +2,64 @@
 
 import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import clsx from "clsx";
-import { motion, AnimatePresence } from "motion/react";
-import {
-  type ComponentProps,
-  type ReactNode,
-  type ContextType,
-  use,
-  useCallback,
-  useEffect,
-} from "react";
-import {
-  Dialog,
-  Heading,
-  Modal as ModalComponent,
-  ModalOverlay as ModalOverlayComponent,
-  OverlayTriggerStateContext,
-} from "react-aria-components";
+import type { ComponentProps, ReactNode } from "react";
+import { Dialog, Heading } from "react-aria-components";
 
 import styles from "./index.module.scss";
 import { Button } from "../Button/index.js";
-import { useSetOverlayVisible } from "../overlay-visible-context.js";
+import { Modal } from "../Modal/index.js";
 
 export { DialogTrigger as DrawerTrigger } from "react-aria-components";
 
 const CLOSE_DURATION_IN_S = 0.15;
 export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
 
-// @ts-expect-error Looks like there's a typing mismatch currently between
-// motion and react, probably due to us being on react 19.  I'm expecting this
-// will go away when react 19 is officially stabilized...
-const ModalOverlay = motion.create(ModalOverlayComponent);
-// @ts-expect-error Looks like there's a typing mismatch currently between
-// motion and react, probably due to us being on react 19.  I'm expecting this
-// will go away when react 19 is officially stabilized...
-const Modal = motion.create(ModalComponent);
-
-type OwnProps = {
+type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> & {
   title: ReactNode;
-  children:
-    | ReactNode
-    | ((
-        state: NonNullable<ContextType<typeof OverlayTriggerStateContext>>,
-      ) => ReactNode);
 };
 
 type Props = Omit<ComponentProps<typeof Dialog>, keyof OwnProps> & OwnProps;
 
-export const Drawer = ({ title, children, className, ...props }: Props) => {
-  const state = use(OverlayTriggerStateContext);
-  const { hideOverlay, showOverlay } = useSetOverlayVisible();
-
-  useEffect(() => {
-    if (state?.isOpen) {
-      showOverlay();
-    }
-  }, [state, showOverlay]);
-
-  const onOpenChange = useCallback(
-    (newValue: boolean) => {
-      state?.setOpen(newValue);
-    },
-    [state],
-  );
-
-  return (
-    <AnimatePresence onExitComplete={hideOverlay}>
-      {state?.isOpen && (
-        <ModalOverlay
-          isOpen
-          isDismissable
-          onOpenChange={onOpenChange}
-          initial={{ backgroundColor: "#00000000" }}
-          animate={{ backgroundColor: "#00000080" }}
-          exit={{ backgroundColor: "#00000000" }}
-          transition={{ ease: "linear", duration: CLOSE_DURATION_IN_S }}
-          className={styles.modalOverlay ?? ""}
-        >
-          <Modal
-            initial={{ x: "100%" }}
-            animate={{
-              x: 0,
-              transition: { type: "spring", duration: 1, bounce: 0.35 },
-            }}
-            exit={{
-              x: "100%",
-              transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
-            }}
-            className={clsx(styles.modal, className)}
+export const Drawer = ({ title, children, className, ...props }: Props) => (
+  <Modal
+    overlayProps={{
+      initial: { backgroundColor: "#00000000" },
+      animate: { backgroundColor: "#00000080" },
+      exit: { backgroundColor: "#00000000" },
+      transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+      className: styles.modalOverlay ?? "",
+    }}
+    initial={{ x: "100%" }}
+    animate={{
+      x: 0,
+      transition: { type: "spring", duration: 1, bounce: 0.35 },
+    }}
+    exit={{
+      x: "100%",
+      transition: { ease: "linear", duration: CLOSE_DURATION_IN_S },
+    }}
+    className={clsx(styles.modal, className)}
+  >
+    {(state) => (
+      <Dialog className={styles.dialog ?? ""} {...props}>
+        <div className={styles.heading}>
+          <Heading className={styles.title} slot="title">
+            {title}
+          </Heading>
+          <Button
+            className={styles.closeButton ?? ""}
+            beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+            slot="close"
+            hideText
+            rounded
+            variant="ghost"
+            size="sm"
           >
-            <Dialog className={styles.dialog ?? ""} {...props}>
-              <div className={styles.heading}>
-                <Heading className={styles.title} slot="title">
-                  {title}
-                </Heading>
-                <Button
-                  className={styles.closeButton ?? ""}
-                  beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-                  slot="close"
-                  hideText
-                  rounded
-                  variant="ghost"
-                  size="sm"
-                >
-                  Close
-                </Button>
-              </div>
-              {typeof children === "function" ? children(state) : children}
-            </Dialog>
-          </Modal>
-        </ModalOverlay>
-      )}
-    </AnimatePresence>
-  );
-};
+            Close
+          </Button>
+        </div>
+        {typeof children === "function" ? children(state) : children}
+      </Dialog>
+    )}
+  </Modal>
+);

+ 78 - 0
packages/component-library/src/Modal/index.tsx

@@ -0,0 +1,78 @@
+"use client";
+
+import { motion, AnimatePresence } from "motion/react";
+import {
+  type ComponentProps,
+  type ContextType,
+  type ReactNode,
+  use,
+  useCallback,
+  useEffect,
+} from "react";
+import {
+  Modal as ModalComponent,
+  ModalOverlay,
+  OverlayTriggerStateContext,
+} from "react-aria-components";
+
+import { useSetOverlayVisible } from "../overlay-visible-context.js";
+
+// @ts-expect-error Looks like there's a typing mismatch currently between
+// motion and react, probably due to us being on react 19.  I'm expecting this
+// will go away when react 19 is officially stabilized...
+const MotionModal = motion.create(ModalComponent);
+
+// @ts-expect-error Looks like there's a typing mismatch currently between
+// motion and react, probably due to us being on react 19.  I'm expecting this
+// will go away when react 19 is officially stabilized...
+const MotionModalOverlay = motion.create(ModalOverlay);
+
+type OwnProps = {
+  overlayProps?: Omit<
+    ComponentProps<typeof MotionModalOverlay>,
+    "isOpen" | "isDismissable" | "onOpenChange"
+  >;
+  children:
+    | ReactNode
+    | ((
+        state: NonNullable<ContextType<typeof OverlayTriggerStateContext>>,
+      ) => ReactNode);
+};
+
+type Props = Omit<ComponentProps<typeof MotionModal>, keyof OwnProps> &
+  OwnProps;
+
+export const Modal = ({ overlayProps, children, ...props }: Props) => {
+  const state = use(OverlayTriggerStateContext);
+  const { hideOverlay, showOverlay } = useSetOverlayVisible();
+
+  useEffect(() => {
+    if (state?.isOpen) {
+      showOverlay();
+    }
+  }, [state, showOverlay]);
+
+  const onOpenChange = useCallback(
+    (newValue: boolean) => {
+      state?.setOpen(newValue);
+    },
+    [state],
+  );
+
+  return (
+    <AnimatePresence onExitComplete={hideOverlay}>
+      {state?.isOpen && (
+        <MotionModalOverlay
+          isOpen
+          isDismissable
+          onOpenChange={onOpenChange}
+          {...overlayProps}
+        >
+          <MotionModal {...props}>
+            {typeof children === "function" ? children(state) : children}
+          </MotionModal>
+        </MotionModalOverlay>
+      )}
+    </AnimatePresence>
+  );
+};

+ 7 - 0
packages/component-library/src/StatCard/index.module.scss

@@ -11,6 +11,13 @@
     padding: theme.spacing(3);
     padding-bottom: theme.spacing(2);
 
+    .corner {
+      position: absolute;
+      right: theme.spacing(3);
+      top: theme.spacing(3);
+      display: flex;
+    }
+
     .header {
       color: theme.color("muted");
       text-align: left;

+ 7 - 0
packages/component-library/src/StatCard/index.stories.tsx

@@ -31,6 +31,12 @@ const meta = {
         category: "Contents",
       },
     },
+    corner: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
   },
 } satisfies Meta<typeof StatCardComponent>;
 export default meta;
@@ -47,5 +53,6 @@ export const StatCard = {
     header: "Active Feeds",
     stat: "552",
     miniStat: "+5",
+    corner: ":)",
   },
 } satisfies StoryObj<typeof StatCardComponent>;

+ 3 - 0
packages/component-library/src/StatCard/index.tsx

@@ -11,6 +11,7 @@ type Props<T extends ElementType> = Omit<
   header: ReactNode;
   stat: ReactNode;
   miniStat?: ReactNode | undefined;
+  corner?: ReactNode | undefined;
 };
 
 export const StatCard = <T extends ElementType>({
@@ -18,10 +19,12 @@ export const StatCard = <T extends ElementType>({
   stat,
   miniStat,
   className,
+  corner,
   ...props
 }: Props<T>) => (
   <Card className={clsx(styles.statCard, className)} {...props}>
     <div className={styles.cardContents}>
+      {corner && <div className={styles.corner}>{corner}</div>}
       <h2 className={styles.header}>{header}</h2>
       <div className={styles.bottom}>
         <div className={styles.stat}>{stat}</div>

+ 5 - 0
packages/component-library/src/theme.scss

@@ -469,6 +469,11 @@ $color: (
         light-dark(pallette-color("steel", 900), pallette-color("steel", 50)),
     ),
     "info": (
+      "background-opaque":
+        light-dark(
+          rgb(from pallette-color("indigo", 200) r g b / 80%),
+          rgb(from pallette-color("indigo", 950) r g b / 80%)
+        ),
       "normal":
         light-dark(pallette-color("indigo", 600), pallette-color("indigo", 400)),
     ),