From c82b606cd2cc1b91a6388f277cef32f028250bb3 Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 12 Sep 2025 19:25:59 +0000 Subject: [PATCH] [MNY-166] Dashboard: Add Dex Screener charts in public ERC20 token page (#8035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR primarily focuses on removing the `token-price-data` API and the `PriceChart` component, while introducing a new `DexScreener` feature that displays token price information based on the chain ID and contract address. ### Detailed summary - Deleted `token-price-data.ts` and `PriceChart.tsx`. - Added `mapChainIdToDexScreenerChainSlug` to map chain IDs to DexScreener chain slugs. - Introduced `DexScreener` and `DexScreenerIframe` components for displaying token price data. - Updated `erc20.tsx` to integrate `DexScreener` and remove token price fetching logic. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit * **New Features** * Added embedded DexScreener chart on ERC20 public pages for supported chains with automatic light/dark theming and responsive SSR fallback. * Introduced chain-to-DexScreener slug mapping for type-safe embeds. * **Refactor** * Removed token price data fetching and the TokenStats/price chart UI from ERC20 public pages. * **Style** * Widened layout to max-w-7xl, simplified Buy section spacing, and moved DexScreener before analytics; analytics and recent transfers now sit in a unified bottom container. --- .../erc20/_apis/token-price-data.ts | 51 ---- .../erc20/_components/PriceChart.tsx | 264 ------------------ .../erc20/_components/dex-screener-chains.ts | 77 +++++ .../erc20/_components/dex-screener.tsx | 58 ++++ .../public-pages/erc20/erc20.tsx | 111 ++++---- 5 files changed, 189 insertions(+), 372 deletions(-) delete mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_apis/token-price-data.ts delete mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener-chains.ts create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener.tsx diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_apis/token-price-data.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_apis/token-price-data.ts deleted file mode 100644 index c76d25a7e9b..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_apis/token-price-data.ts +++ /dev/null @@ -1,51 +0,0 @@ -import "server-only"; -import { isProd } from "@/constants/env-utils"; -import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; - -export type TokenPriceData = { - price_usd: number; - price_usd_cents: number; - percent_change_24h: number; - market_cap_usd: number; - volume_24h_usd: number; - volume_change_24h: number; - holders: number; - historical_prices: Array<{ - date: string; - price_usd: number; - price_usd_cents: number; - }>; -}; - -export async function getTokenPriceData(params: { - chainId: number; - contractAddress: string; -}) { - try { - const url = new URL( - `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`, - ); - - url.searchParams.set("include_historical_prices", "true"); - url.searchParams.set("chain_id", params.chainId.toString()); - url.searchParams.set("address", params.contractAddress); - url.searchParams.set("include_holders", "true"); - - const res = await fetch(url, { - headers: { - "x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY, - }, - }); - if (!res.ok) { - console.error("Failed to fetch token price data", await res.text()); - return undefined; - } - - const json = await res.json(); - const priceData = json.data[0] as TokenPriceData | undefined; - - return priceData; - } catch { - return undefined; - } -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx deleted file mode 100644 index 5c0fbd73a0a..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -import { differenceInCalendarDays, format } from "date-fns"; -import { ArrowDownIcon, ArrowUpIcon, InfoIcon } from "lucide-react"; -import { useMemo, useState } from "react"; -import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ToolTipLabel } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import type { TokenPriceData } from "../_apis/token-price-data"; - -function PriceChartUI(props: { - showTimeOfDay: boolean; - data: Array<{ - date: string; - price_usd: number; - price_usd_cents: number; - }>; -}) { - const data = props.data.map((item) => ({ - price: item.price_usd, - time: new Date(item.date).getTime(), - })); - - return ( - { - return tokenPriceUSDFormatter.format(value as number); - }} - /> - ); -} - -const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", { - currency: "USD", - maximumFractionDigits: 8, - minimumFractionDigits: 0, - notation: "compact", - roundingMode: "halfEven", - style: "currency", -}); - -const marketCapFormatter = new Intl.NumberFormat("en-US", { - currency: "USD", - maximumFractionDigits: 0, - minimumFractionDigits: 0, - notation: "compact", - roundingMode: "halfEven", - style: "currency", -}); - -const holdersFormatter = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - minimumFractionDigits: 0, - notation: "compact", -}); - -const percentChangeFormatter = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 2, - minimumFractionDigits: 0, -}); - -function getTooltipLabelFormatter(includeTimeOfDay: boolean) { - return (_v: string, item: unknown) => { - if (Array.isArray(item)) { - const time = item[0].payload.time as number; - return format( - new Date(time), - includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy", - ); - } - return undefined; - }; -} - -export function TokenStats(params: { - chainId: number; - contractAddress: string; - tokenPriceData: TokenPriceData; -}) { - const [interval, setInterval] = useState("max"); - - const filteredHistoricalPrices = useMemo(() => { - const currentDate = new Date(); - - return params.tokenPriceData.historical_prices.filter((item) => { - const date = new Date(item.date); - const maxDiff = - interval === "24h" - ? 1 - : interval === "7d" - ? 7 - : interval === "30d" - ? 30 - : interval === "1y" - ? 365 - : Number.MAX_SAFE_INTEGER; - - return differenceInCalendarDays(currentDate, date) <= maxDiff; - }); - }, [params.tokenPriceData, interval]); - - const priceUsd = params.tokenPriceData.price_usd; - const percentChange24h = params.tokenPriceData.percent_change_24h; - const marketCap = params.tokenPriceData.market_cap_usd; - const holders = params.tokenPriceData.holders; - - const formattedAbsChange = percentChangeFormatter.format( - Math.abs(percentChange24h), - ); - - const isAlmostZeroChange = - formattedAbsChange === percentChangeFormatter.format(0); - - const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd); - - return ( -
- {/* price and change */} -
-
-

Current Price

-
-

8 && "text-2xl lg:text-4xl", - )} - > - {typeof priceUsd === "number" ? formattedPriceUSD : priceUsd} -

- 0 - ? "success" - : "destructive" - } - > -
- {isAlmostZeroChange ? null : percentChange24h > 0 ? ( - - ) : ( - - )} - - {isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`} - -
- (1d) -
-
-
- - -
- -
- -
- -
- - - -
-
- ); -} - -function TokenStat(props: { - value: string | number; - label: string; - tooltip: string; -}) { - return ( -
-
-

{props.label}

- - - -
-
-

- {props.value} -

-
-
- ); -} - -type Interval = "24h" | "7d" | "30d" | "1y" | "max"; - -function IntervalSelector(props: { - interval: Interval; - setInterval: (timeframe: Interval) => void; -}) { - const intervals: Record = { - "1y": "1Y", - "7d": "1W", - "24h": "1D", - "30d": "1M", - max: "MAX", - }; - - return ( -
- {Object.entries(intervals).map(([key, value]) => ( - - ))} -
- ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener-chains.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener-chains.ts new file mode 100644 index 00000000000..8188a19762c --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener-chains.ts @@ -0,0 +1,77 @@ +export const mapChainIdToDexScreenerChainSlug = { + 8453: "base", + 1: "ethereum", + 56: "bsc", + 369: "pulsechain", + 137: "polygon", + 2741: "abstract", + 43114: "avalanche", + 999: "hyperliquid", + 480: "worldchain", + 42161: "arbitrum", + 388: "cronos", + 59144: "linea", + 1514: "story", + 397: "near", + 295: "hedera", + 146: "sonic", + 10: "optimism", + 80094: "berachain", + 57073: "ink", + 130: "unichain", + 5000: "mantle", + 324: "zksync", + 466: "apechain", + 1116: "core", + 250: "fantom", + 1868: "soneium", + 81457: "blast", + 2000: "dogechain", + 14: "flare", + 2040: "vana", + 4337: "beam", + 109: "shibarium", + 747: "flowevm", + 1088: "metis", + 1030: "conflux", + 43113: "avalanchedfk", + 534352: "scroll", + 747474: "katana", + 42220: "celo", + 1284: "moonbeam", + 4200: "merlinchain", + 2222: "kava", + 39797: "energi", + 34443: "mode", + 252: "fraxtal", + 48900: "zircuit", + 20: "elastos", + 100: "gnosischain", + 204: "opbnb", + 169: "manta", + 1313161554: "aurora", + 3073: "movement", + 4689: "iotex", + 23294: "oasissapphire", + 6001: "bouncebit", + 42170: "arbitrumnova", + 1101: "polygonzkevm", + 40: "telos", + 592: "astar", + 42262: "oasisemerald", + 1285: "moonriver", + 245022934: "neonevm", + 7777777: "zora", + 122: "fuse", + 321: "kcc", + 1234: "stepnetwork", + 106: "velas", + 167000: "taiko", + 288: "boba", + 42766: "zkfair", + 32520: "bitgert", + 82: "meter", +} as const; + +export type DexScreenerChainSlug = + (typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug]; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener.tsx new file mode 100644 index 00000000000..a6d38eb2feb --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/dex-screener.tsx @@ -0,0 +1,58 @@ +"use client"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { useTheme } from "next-themes"; +import { useMemo } from "react"; +import { ClientOnly } from "@/components/blocks/client-only"; +import type { DexScreenerChainSlug } from "./dex-screener-chains"; + +function DexScreenerIframe(props: { + chain: DexScreenerChainSlug; + contractAddress: string; +}) { + const { theme } = useTheme(); + + const iframeUrl = useMemo(() => { + const resolvedTheme = theme === "light" ? "light" : "dark"; + const url = new URL("https://dexscreener.com"); + url.pathname = `${props.chain}/${props.contractAddress}`; + url.searchParams.set("embed", "1"); + url.searchParams.set("loadChartSettings", "0"); + url.searchParams.set("chartTheme", resolvedTheme); + url.searchParams.set("theme", resolvedTheme); + url.searchParams.set("trades", "1"); + url.searchParams.set("chartStyle", "1"); + url.searchParams.set("chartLeftToolbar", "0"); + url.searchParams.set("chartType", "usd"); + url.searchParams.set("interval", "15"); + url.searchParams.set("chartDefaultOnMobile", "1"); + return url.toString(); + }, [theme, props.chain, props.contractAddress]); + + return ( + + ); +} + +export function DexScreener(props: { + chain: DexScreenerChainSlug; + contractAddress: string; +}) { + return ( + + + + } + > + + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx index 78fdbf98a3d..82ddcda6d0b 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx @@ -9,12 +9,12 @@ import { resolveFunctionSelectors } from "@/lib/selectors"; import { AssetPageView } from "../_components/asset-page-view"; import { getContractCreator } from "../_components/getContractCreator"; import { PageHeader } from "../_components/PageHeader"; -import { getTokenPriceData } from "./_apis/token-price-data"; import { ContractHeaderUI } from "./_components/ContractHeader"; import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui"; import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics"; +import { DexScreener } from "./_components/dex-screener"; +import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains"; import { BuyTokenEmbed } from "./_components/PayEmbedSection"; -import { TokenStats } from "./_components/PriceChart"; import { RecentTransfers } from "./_components/RecentTransfers"; import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info"; import { getCurrencyMeta } from "./_utils/getCurrencyMeta"; @@ -30,7 +30,6 @@ export async function ERC20PublicPage(props: { tokenDecimals, tokenInfoFromUB, functionSelectors, - tokenPriceData, ] = await Promise.all([ getContractMetadata({ contract: props.serverContract, @@ -45,10 +44,6 @@ export async function ERC20PublicPage(props: { tokenAddress: props.serverContract.address, }), resolveFunctionSelectors(props.serverContract), - getTokenPriceData({ - chainId: props.serverContract.chain.id, - contractAddress: props.serverContract.address, - }), ]); if (!contractMetadata.image && tokenInfoFromUB) { @@ -83,10 +78,10 @@ export async function ERC20PublicPage(props: { return (
- +
-
+
-
-
- {showBuyEmbed && ( -
- + + {showBuyEmbed && ( +
+
+ +
+ -
- -
- )} - - {tokenPriceData ? ( - <> - +
{" "} +
+ )} - - - ) : ( - - )} + {props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && ( +
+ +
+ )} + +
+
+