Skip to content

Commit 7ccd242

Browse files
committed
Dashboard: Add Tokens page
1 parent 4482692 commit 7ccd242

File tree

6 files changed

+463
-1
lines changed

6 files changed

+463
-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+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { cn } from "@workspace/ui/lib/utils";
5+
import {
6+
ActivityIcon,
7+
ArrowUpDownIcon,
8+
CalendarArrowDownIcon,
9+
CalendarArrowUpIcon,
10+
CheckIcon,
11+
DollarSignIcon,
12+
} from "lucide-react";
13+
import { useState } from "react";
14+
import { Bridge } from "thirdweb";
15+
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
16+
import { Button } from "@/components/ui/button";
17+
import {
18+
DropdownMenu,
19+
DropdownMenuContent,
20+
DropdownMenuItem,
21+
DropdownMenuRadioGroup,
22+
DropdownMenuTrigger,
23+
} from "@/components/ui/dropdown-menu";
24+
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
25+
import { TokensTable } from "./tokens-table";
26+
27+
const client = getClientThirdwebClient();
28+
29+
type Sort = "newest" | "oldest" | "volume" | "market_cap";
30+
const pageSize = 20;
31+
32+
const sortIds: Sort[] = ["newest", "oldest", "volume", "market_cap"];
33+
34+
export function TokenPage() {
35+
const [page, setPage] = useState(1);
36+
const [chainId, setChainId] = useState(1);
37+
const [sortBy, setSortBy] = useState<
38+
"newest" | "oldest" | "volume" | "market_cap"
39+
>("volume");
40+
41+
const tokensQuery = useQuery({
42+
queryKey: [
43+
"tokens",
44+
{
45+
page,
46+
chainId,
47+
sortBy,
48+
},
49+
],
50+
queryFn: () => {
51+
return Bridge.tokens({
52+
client: client,
53+
chainId: chainId,
54+
limit: pageSize,
55+
offset: (page - 1) * pageSize,
56+
sortBy,
57+
});
58+
},
59+
refetchOnMount: false,
60+
refetchOnWindowFocus: false,
61+
});
62+
63+
console.log(tokensQuery.data);
64+
65+
return (
66+
<div className="py-20">
67+
<div className="container max-w-7xl">
68+
<h1 className="text-5xl font-bold tracking-tighter text-center mb-16">
69+
Swap any token in seconds
70+
</h1>
71+
72+
<div className="mb-4 flex gap-3">
73+
<BridgeNetworkSelector
74+
client={client}
75+
chainId={chainId}
76+
onChange={setChainId}
77+
className="rounded-full bg-card lg:w-fit min-w-[240px]"
78+
popoverContentClassName="!w-[350px] rounded-xl overflow-hidden"
79+
/>
80+
81+
<SortDropdown sortBy={sortBy} onSortChange={setSortBy} />
82+
</div>
83+
84+
<TokensTable
85+
pageSize={pageSize}
86+
tokens={tokensQuery.data ?? []}
87+
isFetching={tokensQuery.isFetching}
88+
pagination={{
89+
onNext: () => setPage(page + 1),
90+
onPrevious: () => setPage(page - 1),
91+
nextDisabled: !!(
92+
tokensQuery.data && tokensQuery.data.length < pageSize
93+
),
94+
previousDisabled: page === 1,
95+
}}
96+
/>
97+
</div>
98+
</div>
99+
);
100+
}
101+
102+
const sortByLabel: Record<Sort, string> = {
103+
newest: "Newest",
104+
oldest: "Oldest",
105+
volume: "Popular",
106+
market_cap: "Market Cap",
107+
};
108+
109+
const sortByIcon: Record<Sort, React.FC<{ className?: string }>> = {
110+
newest: CalendarArrowDownIcon,
111+
oldest: CalendarArrowUpIcon,
112+
volume: ActivityIcon,
113+
market_cap: DollarSignIcon,
114+
};
115+
116+
function SortDropdown(props: {
117+
sortBy: Sort;
118+
onSortChange: (value: Sort) => void;
119+
}) {
120+
const valueToLabel: Record<Sort, string> = {
121+
newest: "Newest",
122+
oldest: "Oldest",
123+
volume: "Popular",
124+
market_cap: "Market Cap",
125+
};
126+
127+
return (
128+
<DropdownMenu>
129+
<DropdownMenuTrigger asChild>
130+
<Button className="gap-1.5 rounded-full bg-card" variant="outline">
131+
<ArrowUpDownIcon className="size-4 text-muted-foreground" />
132+
<span className="hidden lg:inline">
133+
<span className="text-muted-foreground mr-1">Sort by</span>
134+
<span className="text-foreground">{sortByLabel[props.sortBy]}</span>
135+
</span>
136+
</Button>
137+
</DropdownMenuTrigger>
138+
<DropdownMenuContent
139+
align="end"
140+
className="w-60 rounded-xl p-1.5 shadow-lg"
141+
sideOffset={10}
142+
>
143+
<DropdownMenuRadioGroup
144+
className="flex flex-col gap-1"
145+
onValueChange={(v) => props.onSortChange(v as Sort)}
146+
value={props.sortBy}
147+
>
148+
{sortIds.map((value) => {
149+
const Icon = sortByIcon[value];
150+
return (
151+
<DropdownMenuItem
152+
className={cn(
153+
"flex cursor-pointer items-center justify-between gap-2 rounded-lg py-2",
154+
props.sortBy === value && "bg-accent",
155+
)}
156+
key={value}
157+
onClick={() => props.onSortChange(value)}
158+
>
159+
<div className="flex items-center gap-2">
160+
<Icon className="size-4 text-muted-foreground" />
161+
{valueToLabel[value]}
162+
</div>
163+
164+
{props.sortBy === value ? (
165+
<CheckIcon className="size-4 text-foreground" />
166+
) : (
167+
<div className="size-4" />
168+
)}
169+
</DropdownMenuItem>
170+
);
171+
})}
172+
</DropdownMenuRadioGroup>
173+
</DropdownMenuContent>
174+
</DropdownMenu>
175+
);
176+
}

0 commit comments

Comments
 (0)