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 + + +
+ +
+ + Docs + + + +
+
+
+ ); +} 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 & {