diff --git a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx
index c3dd895db25..81637c09f54 100644
--- a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx
+++ b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx
@@ -1,7 +1,8 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
-import type { ThirdwebClient } from "thirdweb";
+import { Bridge, type ThirdwebClient } from "thirdweb";
import { MultiSelect } from "@/components/blocks/multi-select";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { Badge } from "@/components/ui/badge";
@@ -296,3 +297,102 @@ export function SingleNetworkSelector(props: {
/>
);
}
+
+export function BridgeNetworkSelector(props: {
+ chainId: number | undefined;
+ onChange: (chainId: number) => void;
+ className?: string;
+ popoverContentClassName?: string;
+ side?: "left" | "right" | "top" | "bottom";
+ align?: "center" | "start" | "end";
+ placeholder?: string;
+ client: ThirdwebClient;
+}) {
+ const chainsQuery = useQuery({
+ queryKey: ["bridge-chains"],
+ queryFn: () => {
+ return Bridge.chains({ client: props.client });
+ },
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ const options = useMemo(() => {
+ return (chainsQuery.data || [])?.map((chain) => {
+ return {
+ label: cleanChainName(chain.name),
+ value: String(chain.chainId),
+ };
+ });
+ }, [chainsQuery.data]);
+
+ const searchFn = useCallback(
+ (option: Option, searchValue: string) => {
+ const chain = chainsQuery.data?.find(
+ (chain) => chain.chainId === Number(option.value),
+ );
+ if (!chain) {
+ return false;
+ }
+
+ if (Number.isInteger(Number.parseInt(searchValue))) {
+ return String(chain.chainId).startsWith(searchValue);
+ }
+ return chain.name.toLowerCase().includes(searchValue.toLowerCase());
+ },
+ [chainsQuery.data],
+ );
+
+ const renderOption = useCallback(
+ (option: Option) => {
+ const chain = chainsQuery.data?.find(
+ (chain) => chain.chainId === Number(option.value),
+ );
+ if (!chain) {
+ return option.label;
+ }
+
+ return (
+
+
+
+ {cleanChainName(chain.name)}
+
+
+ );
+ },
+ [chainsQuery.data, props.client],
+ );
+
+ const isLoadingChains = chainsQuery.isPending;
+
+ return (
+ {
+ props.onChange(Number(chainId));
+ }}
+ options={options}
+ overrideSearchFn={searchFn}
+ placeholder={
+ isLoadingChains
+ ? "Loading Chains..."
+ : props.placeholder || "Select Chain"
+ }
+ popoverContentClassName={props.popoverContentClassName}
+ renderOption={renderOption}
+ searchPlaceholder="Search by Name or Chain ID"
+ showCheck={false}
+ side={props.side}
+ value={props.chainId?.toString()}
+ />
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx
index db9e6bb4b5a..a98f28ab997 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
connectButton={{
className: props.connectButtonClassName,
}}
+ detailsButton={{
+ className: props.connectButtonClassName,
+ }}
connectModal={{
privacyPolicyUrl: "/privacy-policy",
showThirdwebBranding: false,
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/header.tsx b/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/header.tsx
new file mode 100644
index 00000000000..31232ca0583
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/header.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
+import { cn } from "@/lib/utils";
+import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
+import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
+
+export function PageHeader(props: { containerClassName?: string }) {
+ return (
+
+
+
+
+
+
+ thirdweb
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/token-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/token-page.tsx
new file mode 100644
index 00000000000..ebde5ed448a
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/token-page.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { cn } from "@workspace/ui/lib/utils";
+import { ActivityIcon, TrendingUpIcon } from "lucide-react";
+import { useState } from "react";
+import { Bridge } from "thirdweb";
+import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
+import { Button } from "@/components/ui/button";
+import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
+import { TokensTable } from "./tokens-table";
+
+const client = getClientThirdwebClient();
+
+const pageSize = 20;
+
+export function TokenPage() {
+ const [page, setPage] = useState(1);
+ const [chainId, setChainId] = useState(1);
+ const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
+
+ const tokensQuery = useQuery({
+ queryKey: [
+ "tokens",
+ {
+ page,
+ chainId,
+ sortBy,
+ },
+ ],
+ queryFn: () => {
+ return Bridge.tokens({
+ client: client,
+ chainId: chainId,
+ limit: pageSize,
+ offset: (page - 1) * pageSize,
+ sortBy,
+ });
+ },
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ return (
+
+
+
+
+
+
+ setSortBy("market_cap")}
+ isSelected={sortBy === "market_cap"}
+ icon={ActivityIcon}
+ />
+ setSortBy("volume")}
+ isSelected={sortBy === "volume"}
+ icon={TrendingUpIcon}
+ />
+
+
+
+
setPage(page + 1),
+ onPrevious: () => setPage(page - 1),
+ nextDisabled: !!(
+ tokensQuery.data && tokensQuery.data.length < pageSize
+ ),
+ previousDisabled: page === 1,
+ }}
+ />
+
+
+ );
+}
+
+function SortButton(props: {
+ label: string;
+ onClick: () => void;
+ isSelected: boolean;
+ icon: React.FC<{ className?: string }>;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/tokens-table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/tokens-table.tsx
new file mode 100644
index 00000000000..475eb677d91
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/(dashboard)/tokens/components/tokens-table.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import Link from "next/link";
+import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+export function TokensTable(props: {
+ tokens: Bridge.TokenWithPrices[];
+ pageSize: number;
+ isFetching: boolean;
+ pagination: {
+ onNext: () => void;
+ onPrevious: () => void;
+ nextDisabled: boolean;
+ previousDisabled: boolean;
+ };
+}) {
+ const { tokens, isFetching } = props;
+
+ return (
+
+
+
+
+
+ Token
+ Price
+ Market cap
+ Volume (24h)
+
+
+
+ {isFetching
+ ? new Array(props.pageSize).fill(0).map((_, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: ok
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ : tokens.length > 0
+ ? tokens.map((token) => {
+ const price = token.prices.USD;
+ return (
+
+
+
+
+
+
+ {token.symbol?.slice(0, 2)?.toUpperCase()}
+
+
+
+
+ {token.symbol}
+
+
+
+ {token.name}
+
+
+
+
+
+
+ {price ? formatPrice(price) : "N/A"}
+
+
+ {token.marketCapUsd
+ ? formatUsdCompact(token.marketCapUsd)
+ : "N/A"}
+
+
+ {token.volume24hUsd
+ ? formatUsdCompact(token.volume24hUsd)
+ : "N/A"}
+
+
+ );
+ })
+ : null}
+
+
+
+
+ {tokens.length === 0 && !isFetching && (
+
+ No tokens found
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function formatPrice(value: number): string {
+ if (value < 100) {
+ return smallValueUSDFormatter.format(value);
+ }
+ return largeValueUSDFormatter.format(value);
+}
+
+const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 6,
+ roundingMode: "halfEven",
+});
+
+const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 2,
+ roundingMode: "halfEven",
+});
+
+const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ notation: "compact",
+ maximumFractionDigits: 2,
+ roundingMode: "halfEven",
+});
+
+function formatUsdCompact(value: number): string {
+ return compactValueUSDFormatter.format(value);
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/tokens/page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/tokens/page.tsx
new file mode 100644
index 00000000000..4d28c722d97
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/(dashboard)/tokens/page.tsx
@@ -0,0 +1,32 @@
+import { BringToFrontIcon } from "lucide-react";
+import Link from "next/link";
+import { PageHeader } from "./components/header";
+import { TokenPage } from "./components/token-page";
+
+export default function Page() {
+ return (
+
+
+
+
+
+
+ Discover and swap any tokens on any chain, instantly
+
+
+ Powered by thirdweb bridge
+
+
+
+
+
+ );
+}
diff --git a/packages/thirdweb/src/bridge/Token.ts b/packages/thirdweb/src/bridge/Token.ts
index 4c4398328e2..518dc1ae463 100644
--- a/packages/thirdweb/src/bridge/Token.ts
+++ b/packages/thirdweb/src/bridge/Token.ts
@@ -141,6 +141,7 @@ export async function tokens<
limit,
offset,
includePrices,
+ sortBy,
} = options;
const clientFetch = getClientFetch(client);
@@ -167,6 +168,9 @@ export async function tokens<
if (includePrices !== undefined) {
url.searchParams.set("includePrices", includePrices.toString());
}
+ if (sortBy !== undefined) {
+ url.searchParams.set("sortBy", sortBy);
+ }
const response = await clientFetch(url.toString());
if (!response.ok) {
@@ -204,6 +208,8 @@ export declare namespace tokens {
offset?: number | null;
/** Whether or not to include prices for the tokens. Setting this to false will speed up the request. */
includePrices?: IncludePrices;
+ /** Sort by a specific field. */
+ sortBy?: "newest" | "oldest" | "volume" | "market_cap";
};
/**
diff --git a/packages/thirdweb/src/bridge/types/Token.ts b/packages/thirdweb/src/bridge/types/Token.ts
index c7588f657f2..04f184f4ae5 100644
--- a/packages/thirdweb/src/bridge/types/Token.ts
+++ b/packages/thirdweb/src/bridge/types/Token.ts
@@ -7,6 +7,8 @@ export type Token = {
symbol: string;
name: string;
iconUri?: string;
+ marketCapUsd?: number;
+ volume24hUsd?: number;
};
export type TokenWithPrices = Token & {