Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 104 additions & 66 deletions apps/developer-hub/src/components/Playground/PriceFeedSelector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,83 +6,121 @@ import { SearchInput } from "@pythnetwork/component-library/SearchInput";
import { Spinner } from "@pythnetwork/component-library/Spinner";
import clsx from "clsx";
import { useMemo, useState } from "react";

import { usePlaygroundContext } from "../PlaygroundContext";
import styles from "./index.module.scss";
import { usePriceFeeds } from "./use-price-feeds";
import { usePlaygroundContext } from "../PlaygroundContext";

type PriceFeedSelectorProps = {
className?: string;
};

type CategoryId =
| "all"
| "crypto"
| "equity"
| "fx"
| "rates"
| "commodity"
| "kalshi"
| "nav";

type CategoryConfig = {
id: CategoryId;
id: string;
label: string;
assetTypes: string[];
};

// TODO: Derive categories dynamically from the API response instead of hardcoding.
// The feeds API returns `asset_type` for each feed, so we could:
// 1. Extract unique asset types from feedsState.feeds
// 2. Group related types (e.g., "crypto-index" with "crypto")
// 3. Generate tabs dynamically with accurate counts
// This would auto-discover new asset classes without code changes.
const CATEGORIES: CategoryConfig[] = [
{ id: "all", label: "All", assetTypes: [] },
{ id: "crypto", label: "Crypto", assetTypes: ["crypto", "crypto-index"] },
{ id: "equity", label: "Equity", assetTypes: ["equity"] },
{ id: "fx", label: "FX", assetTypes: ["fx"] },
{
id: "rates",
label: "Rates",
assetTypes: ["rates", "crypto-redemption-rate", "funding-rate"],
},
{ id: "commodity", label: "Commodity", assetTypes: ["commodity", "metal"] },
{ id: "kalshi", label: "Kalshi", assetTypes: ["kalshi"] },
{ id: "nav", label: "NAV", assetTypes: ["nav"] },
];
// Asset types that should be merged into a single category.
// Any asset type NOT listed here gets its own tab automatically.
const ASSET_TYPE_GROUPS: Record<string, string[]> = {
crypto: ["crypto", "crypto-index"],
};

// Display labels for known category IDs.
// Unknown types are auto-labeled by title-casing (e.g., "my-type" → "My Type").
const CATEGORY_LABELS: Record<string, string> = {
commodity: "Commodity",
crypto: "Crypto",
"crypto-redemption-rate": "Redemption Rates",
custom: "Custom",
equity: "Equity",
"funding-rate": "Funding Rate",
fx: "FX",
kalshi: "Kalshi",
metal: "Metal",
nav: "NAV",
rates: "Rates",
};

function formatCategoryLabel(id: string): string {
return (
CATEGORY_LABELS[id] ??
id
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
);
}

/**
* Derives categories dynamically from the feeds' asset_type values.
* Groups related types (e.g. crypto + crypto-index) and auto-discovers
* new asset classes without code changes.
*/
function buildCategories(feeds: { assetType: string }[]): CategoryConfig[] {
const allAssetTypes = new Set(feeds.map((f) => f.assetType));

// Build reverse map: asset_type → category id
const assetTypeToCategory = new Map<string, string>();
for (const [categoryId, groupedTypes] of Object.entries(ASSET_TYPE_GROUPS)) {
for (const type of groupedTypes) {
assetTypeToCategory.set(type, categoryId);
}
}

// Collect asset types per category
const categoryMap = new Map<string, string[]>();
for (const assetType of allAssetTypes) {
const categoryId = assetTypeToCategory.get(assetType) ?? assetType;
const existing = categoryMap.get(categoryId) ?? [];
existing.push(assetType);
categoryMap.set(categoryId, existing);
}

// Sort categories by feed count (largest first)
const sorted = [...categoryMap.entries()].sort((a, b) => {
const countA = feeds.filter((f) => a[1].includes(f.assetType)).length;
const countB = feeds.filter((f) => b[1].includes(f.assetType)).length;
return countB - countA;
});

const categories: CategoryConfig[] = [
{ assetTypes: [], id: "all", label: "All" },
];
for (const [id, assetTypes] of sorted) {
categories.push({ assetTypes, id, label: formatCategoryLabel(id) });
}

return categories;
}

export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
const { config, updateConfig } = usePlaygroundContext();
const feedsState = usePriceFeeds();
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<CategoryId>("all");
const [activeCategory, setActiveCategory] = useState("all");

const selectedIds = config.priceFeedIds;

// Derive categories dynamically from feeds
const categories = useMemo(() => {
if (feedsState.status !== "loaded") {
return [{ assetTypes: [] as string[], id: "all", label: "All" }];
}
return buildCategories(feedsState.feeds);
}, [feedsState]);

// Compute category counts
const categoryCounts = useMemo(() => {
if (feedsState.status !== "loaded") {
return new Map<CategoryId, number>();
return new Map<string, number>();
}

const counts = new Map<CategoryId, number>();
const categoryMap = new Map<string, CategoryId>();

// Build map of asset type to category
for (const category of CATEGORIES) {
const counts = new Map<string, number>();
for (const category of categories) {
if (category.id === "all") {
counts.set("all", feedsState.feeds.length);
} else {
for (const assetType of category.assetTypes) {
categoryMap.set(assetType, category.id);
}
}
}

// Count feeds per category
for (const category of CATEGORIES) {
if (category.id !== "all") {
const count = feedsState.feeds.filter((feed) =>
category.assetTypes.includes(feed.assetType),
).length;
Expand All @@ -91,12 +129,12 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
}

return counts;
}, [feedsState]);
}, [feedsState, categories]);

const filteredFeeds = useMemo(() => {
if (feedsState.status !== "loaded") return [];

const activeCategoryConfig = CATEGORIES.find(
const activeCategoryConfig = categories.find(
(cat) => cat.id === activeCategory,
);
if (!activeCategoryConfig) return [];
Expand All @@ -123,7 +161,7 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
String(feed.id).includes(searchLower),
)
.slice(0, 50);
}, [feedsState, search, activeCategory]);
}, [feedsState, search, activeCategory, categories]);

const selectedFeeds = useMemo(() => {
if (feedsState.status !== "loaded") return [];
Expand Down Expand Up @@ -153,17 +191,17 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
{selectedFeeds.length > 0 && (
<div className={styles.selectedChips}>
{selectedFeeds.map((feed) => (
<div key={feed.id} className={styles.chip}>
<div className={styles.chip} key={feed.id}>
<span className={styles.chipText}>
{feed.symbol} ({feed.id})
</span>
<button
type="button"
aria-label={`Remove ${feed.symbol}`}
className={styles.chipRemove}
onClick={() => {
handleRemoveFeed(feed.id);
}}
aria-label={`Remove ${feed.symbol}`}
type="button"
>
<X weight="bold" />
</button>
Expand All @@ -174,29 +212,29 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {

{/* Search input */}
<SearchInput
className={styles.searchInput ?? ""}
label="Search price feeds"
onChange={setSearch}
placeholder="Search by symbol, name, or ID..."
value={search}
onChange={setSearch}
className={styles.searchInput ?? ""}
/>

{/* Category tabs */}
<div className={styles.categoryTabs}>
{CATEGORIES.map((category) => {
{categories.map((category) => {
const count = categoryCounts.get(category.id) ?? 0;
const formattedCount =
count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
return (
<button
key={category.id}
type="button"
className={clsx(styles.categoryTab, {
[styles.active ?? ""]: activeCategory === category.id,
})}
key={category.id}
onClick={() => {
setActiveCategory(category.id);
}}
type="button"
>
<span className={styles.tabLabel}>{category.label}</span>
{count > 0 && (
Expand All @@ -211,7 +249,7 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
<div className={styles.feedList}>
{feedsState.status === "loading" && (
<div className={styles.loading}>
<Spinner label="Loading price feeds..." isIndeterminate />
<Spinner isIndeterminate label="Loading price feeds..." />
</div>
)}

Expand All @@ -228,13 +266,13 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
const isSelected = selectedIds.includes(feed.id);
return (
<Button
key={feed.id}
variant={isSelected ? "primary" : "outline"}
size="sm"
className={styles.feedItem ?? ""}
key={feed.id}
onPress={() => {
handleToggleFeed(feed.id);
}}
size="sm"
variant={isSelected ? "primary" : "outline"}
>
<span className={styles.feedSymbol}>{feed.symbol}</span>
<span className={styles.feedId}>({feed.id})</span>
Expand All @@ -244,10 +282,10 @@ export function PriceFeedSelector({ className }: PriceFeedSelectorProps) {
</div>

<a
className={styles.link}
href="/price-feeds/pro/price-feed-ids"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
target="_blank"
>
View all Price Feed IDs →
</a>
Expand Down
Loading