Skip to content

Commit 0250215

Browse files
committed
Dashboard: Add Tokens page (#8073)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces enhancements to the token management system, focusing on adding price-related fields, sorting options, and improving the user interface for token display and selection. ### Detailed summary - Added `marketCapUsd` and `volume24hUsd` fields to the `Token` type. - Introduced `sortBy` option in token fetching. - Updated `PublicPageConnectButton` to include a `detailsButton`. - Created a new `Page` component for displaying token information. - Implemented a `PageHeader` component for navigation. - Added `TokenPage` component for token management with sorting capabilities. - Developed `TokensTable` component for listing tokens with price, market cap, and volume. - Introduced `BridgeNetworkSelector` for selecting blockchain networks. - Enhanced UI with loading states and better token presentation. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Introduced a Tokens dashboard page with header and hero section. - Added network selector for choosing chains. - Implemented token list with columns for Token, Price, Market Cap, and 24h Volume. - Enabled sorting by Popular (market cap) and Trending (volume), plus pagination. - Improved Connect button with an additional details button style. - Enhancements - Loading states and empty-state messaging for token lists. - Backend now supports token sorting and exposes market cap and 24h volume for richer displays. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent e9225c3 commit 0250215

File tree

8 files changed

+487
-1
lines changed

8 files changed

+487
-1
lines changed

apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3+
import { useQuery } from "@tanstack/react-query";
34
import { useCallback, useMemo } from "react";
4-
import type { ThirdwebClient } from "thirdweb";
5+
import { Bridge, type ThirdwebClient } from "thirdweb";
56
import { MultiSelect } from "@/components/blocks/multi-select";
67
import { SelectWithSearch } from "@/components/blocks/select-with-search";
78
import { Badge } from "@/components/ui/badge";
@@ -296,3 +297,102 @@ export function SingleNetworkSelector(props: {
296297
/>
297298
);
298299
}
300+
301+
export function BridgeNetworkSelector(props: {
302+
chainId: number | undefined;
303+
onChange: (chainId: number) => void;
304+
className?: string;
305+
popoverContentClassName?: string;
306+
side?: "left" | "right" | "top" | "bottom";
307+
align?: "center" | "start" | "end";
308+
placeholder?: string;
309+
client: ThirdwebClient;
310+
}) {
311+
const chainsQuery = useQuery({
312+
queryKey: ["bridge-chains"],
313+
queryFn: () => {
314+
return Bridge.chains({ client: props.client });
315+
},
316+
refetchOnMount: false,
317+
refetchOnWindowFocus: false,
318+
});
319+
320+
const options = useMemo(() => {
321+
return (chainsQuery.data || [])?.map((chain) => {
322+
return {
323+
label: cleanChainName(chain.name),
324+
value: String(chain.chainId),
325+
};
326+
});
327+
}, [chainsQuery.data]);
328+
329+
const searchFn = useCallback(
330+
(option: Option, searchValue: string) => {
331+
const chain = chainsQuery.data?.find(
332+
(chain) => chain.chainId === Number(option.value),
333+
);
334+
if (!chain) {
335+
return false;
336+
}
337+
338+
if (Number.isInteger(Number.parseInt(searchValue))) {
339+
return String(chain.chainId).startsWith(searchValue);
340+
}
341+
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
342+
},
343+
[chainsQuery.data],
344+
);
345+
346+
const renderOption = useCallback(
347+
(option: Option) => {
348+
const chain = chainsQuery.data?.find(
349+
(chain) => chain.chainId === Number(option.value),
350+
);
351+
if (!chain) {
352+
return option.label;
353+
}
354+
355+
return (
356+
<div className="flex justify-between gap-4">
357+
<span className="flex grow gap-2 truncate text-left">
358+
<ChainIconClient
359+
className="size-5"
360+
client={props.client}
361+
src={chain.icon}
362+
loading="lazy"
363+
/>
364+
{cleanChainName(chain.name)}
365+
</span>
366+
</div>
367+
);
368+
},
369+
[chainsQuery.data, props.client],
370+
);
371+
372+
const isLoadingChains = chainsQuery.isPending;
373+
374+
return (
375+
<SelectWithSearch
376+
align={props.align}
377+
className={props.className}
378+
closeOnSelect={true}
379+
disabled={isLoadingChains}
380+
onValueChange={(chainId) => {
381+
props.onChange(Number(chainId));
382+
}}
383+
options={options}
384+
overrideSearchFn={searchFn}
385+
placeholder={
386+
isLoadingChains
387+
? "Loading Chains..."
388+
: props.placeholder || "Select Chain"
389+
}
390+
popoverContentClassName={props.popoverContentClassName}
391+
renderOption={renderOption}
392+
searchPlaceholder="Search by Name or Chain ID"
393+
showCheck={false}
394+
side={props.side}
395+
value={props.chainId?.toString()}
396+
/>
397+
);
398+
}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
2828
connectButton={{
2929
className: props.connectButtonClassName,
3030
}}
31+
detailsButton={{
32+
className: props.connectButtonClassName,
33+
}}
3134
connectModal={{
3235
privacyPolicyUrl: "/privacy-policy",
3336
showThirdwebBranding: false,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Link from "next/link";
2+
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
3+
import { cn } from "@/lib/utils";
4+
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
5+
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
6+
7+
export function PageHeader(props: { containerClassName?: string }) {
8+
return (
9+
<div className="border-b">
10+
<header
11+
className={cn(
12+
"container flex max-w-7xl justify-between py-3",
13+
props.containerClassName,
14+
)}
15+
>
16+
<div className="flex items-center gap-4">
17+
<Link className="flex items-center gap-2" href="/team">
18+
<ThirdwebMiniLogo className="h-5" />
19+
<span className="hidden font-bold text-2xl tracking-tight lg:block">
20+
thirdweb
21+
</span>
22+
</Link>
23+
</div>
24+
25+
<div className="flex items-center gap-5">
26+
<Link
27+
href="https://portal.thirdweb.com/bridge"
28+
target="_blank"
29+
className="text-sm text-muted-foreground hover:text-foreground"
30+
>
31+
Docs
32+
</Link>
33+
<ToggleThemeButton />
34+
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
35+
</div>
36+
</header>
37+
</div>
38+
);
39+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { cn } from "@workspace/ui/lib/utils";
5+
import { ActivityIcon, TrendingUpIcon } from "lucide-react";
6+
import { useState } from "react";
7+
import { Bridge } from "thirdweb";
8+
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
9+
import { Button } from "@/components/ui/button";
10+
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
11+
import { TokensTable } from "./tokens-table";
12+
13+
const client = getClientThirdwebClient();
14+
15+
const pageSize = 20;
16+
17+
export function TokenPage() {
18+
const [page, setPage] = useState(1);
19+
const [chainId, setChainId] = useState(1);
20+
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
21+
22+
const tokensQuery = useQuery({
23+
queryKey: [
24+
"tokens",
25+
{
26+
page,
27+
chainId,
28+
sortBy,
29+
},
30+
],
31+
queryFn: () => {
32+
return Bridge.tokens({
33+
client: client,
34+
chainId: chainId,
35+
limit: pageSize,
36+
offset: (page - 1) * pageSize,
37+
sortBy,
38+
});
39+
},
40+
refetchOnMount: false,
41+
refetchOnWindowFocus: false,
42+
});
43+
44+
return (
45+
<div className="pb-20 pt-8">
46+
<div className="container max-w-7xl">
47+
<div className="mb-4 flex gap-3 flex-col lg:flex-row">
48+
<BridgeNetworkSelector
49+
client={client}
50+
chainId={chainId}
51+
onChange={setChainId}
52+
className="rounded-full bg-card lg:w-fit min-w-[320px]"
53+
popoverContentClassName="!w-[350px] rounded-xl overflow-hidden"
54+
/>
55+
56+
<div className="flex gap-3">
57+
<SortButton
58+
label="Popular"
59+
onClick={() => setSortBy("market_cap")}
60+
isSelected={sortBy === "market_cap"}
61+
icon={ActivityIcon}
62+
/>
63+
<SortButton
64+
label="Trending"
65+
onClick={() => setSortBy("volume")}
66+
isSelected={sortBy === "volume"}
67+
icon={TrendingUpIcon}
68+
/>
69+
</div>
70+
</div>
71+
72+
<TokensTable
73+
pageSize={pageSize}
74+
tokens={tokensQuery.data ?? []}
75+
isFetching={tokensQuery.isFetching}
76+
pagination={{
77+
onNext: () => setPage(page + 1),
78+
onPrevious: () => setPage(page - 1),
79+
nextDisabled: !!(
80+
tokensQuery.data && tokensQuery.data.length < pageSize
81+
),
82+
previousDisabled: page === 1,
83+
}}
84+
/>
85+
</div>
86+
</div>
87+
);
88+
}
89+
90+
function SortButton(props: {
91+
label: string;
92+
onClick: () => void;
93+
isSelected: boolean;
94+
icon: React.FC<{ className?: string }>;
95+
}) {
96+
return (
97+
<Button
98+
variant="outline"
99+
onClick={props.onClick}
100+
className={cn(
101+
"rounded-xl gap-2 bg-card",
102+
props.isSelected && "bg-accent border-active-border",
103+
)}
104+
>
105+
<props.icon className="size-3.5" />
106+
{props.label}
107+
</Button>
108+
);
109+
}

0 commit comments

Comments
 (0)