Skip to content

Commit 2792071

Browse files
committed
feat: adds token selector
1 parent f3c06b7 commit 2792071

File tree

8 files changed

+372
-28
lines changed

8 files changed

+372
-28
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const UB_BASE_URL = process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use server";
2+
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
3+
import { UB_BASE_URL } from "./constants";
4+
5+
export type TokenMetadata = {
6+
name: string;
7+
symbol: string;
8+
address: string;
9+
decimals: number;
10+
chainId: number;
11+
iconUri?: string;
12+
};
13+
14+
export async function getUniversalBrigeTokens(props: {
15+
clientId?: string;
16+
chainId?: number;
17+
}) {
18+
const authToken = await getAuthToken();
19+
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
20+
21+
if (props.chainId) {
22+
url.searchParams.append("chainId", String(props.chainId));
23+
}
24+
url.searchParams.append("limit", "1000");
25+
26+
const res = await fetch(url.toString(), {
27+
method: "GET",
28+
headers: {
29+
"Content-Type": "application/json",
30+
"x-client-id-override": props.clientId,
31+
Authorization: `Bearer ${authToken}`,
32+
} as Record<string, string>,
33+
});
34+
35+
if (!res.ok) {
36+
const text = await res.text();
37+
throw new Error(text);
38+
}
39+
40+
const json = await res.json();
41+
return json.data as Array<TokenMetadata>;
42+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export function SingleNetworkSelector(props: {
218218
<SelectWithSearch
219219
searchPlaceholder="Search by Name or Chain ID"
220220
value={String(props.chainId)}
221+
showCheck={false}
221222
options={options}
222223
onValueChange={(chainId) => {
223224
props.onChange(Number(chainId));
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { useState } from "react";
3+
import {
4+
BadgeContainer,
5+
storybookThirdwebClient,
6+
} from "../../../stories/utils";
7+
import { TokenSelector } from "./TokenSelector";
8+
9+
const meta = {
10+
title: "blocks/Cards/TokenSelector",
11+
component: Story,
12+
parameters: {
13+
nextjs: {
14+
appDirectory: true,
15+
},
16+
},
17+
} satisfies Meta<typeof Story>;
18+
19+
export default meta;
20+
type Story = StoryObj<typeof meta>;
21+
22+
export const Variants: Story = {
23+
args: {},
24+
};
25+
26+
function Story() {
27+
return (
28+
<div className="container flex max-w-6xl flex-col gap-8 py-10">
29+
<Variant label="No Chains selected by default" />
30+
</div>
31+
);
32+
}
33+
34+
function Variant(props: {
35+
label: string;
36+
selectedChainId?: number;
37+
}) {
38+
const [tokenAddress, setTokenAddress] = useState<string>("");
39+
return (
40+
<BadgeContainer label={props.label}>
41+
<TokenSelector
42+
tokenAddress={tokenAddress}
43+
chainId={props.selectedChainId}
44+
client={storybookThirdwebClient}
45+
onChange={setTokenAddress}
46+
/>
47+
</BadgeContainer>
48+
);
49+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useCallback, useMemo } from "react";
2+
import type { ThirdwebClient } from "thirdweb";
3+
import { useTokensData } from "../../../hooks/tokens/tokens";
4+
import { replaceIpfsUrl } from "../../../lib/sdk";
5+
import { fallbackChainIcon } from "../../../utils/chain-icons";
6+
import { cn } from "../../lib/utils";
7+
import { Badge } from "../ui/badge";
8+
import { Img } from "./Img";
9+
import { SelectWithSearch } from "./select-with-search";
10+
11+
type Option = { label: string; value: string };
12+
13+
export function TokenSelector(props: {
14+
tokenAddress: string | undefined;
15+
onChange: (tokenAddress: string) => void;
16+
className?: string;
17+
popoverContentClassName?: string;
18+
chainId?: number;
19+
side?: "left" | "right" | "top" | "bottom";
20+
disableChainId?: boolean;
21+
align?: "center" | "start" | "end";
22+
placeholder?: string;
23+
client: ThirdwebClient;
24+
disabled?: boolean;
25+
}) {
26+
const { tokens, isLoading } = useTokensData({
27+
clientId: props.client.clientId,
28+
chainId: props.chainId,
29+
});
30+
31+
const options = useMemo(() => {
32+
return tokens.allTokens.map((token) => {
33+
return {
34+
label: token.symbol,
35+
value: `${token.chainId}:${token.address}`,
36+
};
37+
});
38+
}, [tokens.allTokens]);
39+
40+
const searchFn = useCallback(
41+
(option: Option, searchValue: string) => {
42+
const token = tokens.addressChainToToken.get(option.value);
43+
if (!token) {
44+
return false;
45+
}
46+
47+
if (Number.isInteger(Number.parseInt(searchValue))) {
48+
return String(token.chainId).startsWith(searchValue);
49+
}
50+
return (
51+
token.name.toLowerCase().includes(searchValue.toLowerCase()) ||
52+
token.symbol.toLowerCase().includes(searchValue.toLowerCase()) ||
53+
token.address.toLowerCase().includes(searchValue.toLowerCase())
54+
);
55+
},
56+
[tokens],
57+
);
58+
59+
const renderOption = useCallback(
60+
(option: Option) => {
61+
const token = tokens.addressChainToToken.get(option.value);
62+
if (!token) {
63+
return option.label;
64+
}
65+
const resolvedSrc = token.iconUri
66+
? replaceIpfsUrl(token.iconUri, props.client)
67+
: fallbackChainIcon;
68+
69+
return (
70+
<div className="flex items-center justify-between gap-4">
71+
<span className="flex grow gap-2 truncate text-left">
72+
<Img
73+
// render different image element if src changes to avoid showing old image while loading new one
74+
key={resolvedSrc}
75+
className={cn("size-5 rounded-full object-contain")}
76+
src={resolvedSrc}
77+
loading={"lazy"}
78+
alt=""
79+
// eslint-disable-next-line @next/next/no-img-element
80+
fallback={<img src={fallbackChainIcon} alt="" />}
81+
skeleton={
82+
<div className="animate-pulse rounded-full bg-border" />
83+
}
84+
/>
85+
{token.symbol}
86+
</span>
87+
88+
{!props.disableChainId && (
89+
<Badge variant="outline" className="gap-2 max-sm:hidden">
90+
<span className="text-muted-foreground">Chain ID</span>
91+
{token.chainId}
92+
</Badge>
93+
)}
94+
</div>
95+
);
96+
},
97+
[tokens, props.disableChainId, props.client],
98+
);
99+
100+
return (
101+
<SelectWithSearch
102+
searchPlaceholder="Search by name or symbol"
103+
value={props.tokenAddress}
104+
options={options}
105+
onValueChange={(tokenAddress) => {
106+
props.onChange(tokenAddress);
107+
}}
108+
closeOnSelect={true}
109+
showCheck={false}
110+
placeholder={
111+
isLoading ? "Loading Tokens..." : props.placeholder || "Select Token"
112+
}
113+
overrideSearchFn={searchFn}
114+
renderOption={renderOption}
115+
className={props.className}
116+
popoverContentClassName={props.popoverContentClassName}
117+
disabled={isLoading || props.disabled}
118+
side={props.side}
119+
align={props.align}
120+
/>
121+
);
122+
}

apps/dashboard/src/@/components/blocks/select-with-search.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface SelectWithSearchProps
3434
side?: "left" | "right" | "top" | "bottom";
3535
align?: "center" | "start" | "end";
3636
closeOnSelect?: boolean;
37+
showCheck?: boolean;
3738
}
3839

3940
export const SelectWithSearch = React.forwardRef<
@@ -52,6 +53,7 @@ export const SelectWithSearch = React.forwardRef<
5253
popoverContentClassName,
5354
searchPlaceholder,
5455
closeOnSelect,
56+
showCheck = true,
5557
...props
5658
},
5759
ref,
@@ -193,9 +195,11 @@ export const SelectWithSearch = React.forwardRef<
193195
i === optionsToShow.length - 1 ? lastItemRef : undefined
194196
}
195197
>
196-
<div className="flex size-4 items-center justify-center">
197-
{isSelected && <CheckIcon className="size-4" />}
198-
</div>
198+
{showCheck && (
199+
<div className="flex size-4 items-center justify-center">
200+
{isSelected && <CheckIcon className="size-4" />}
201+
</div>
202+
)}
199203

200204
<div className="min-w-0 grow">
201205
{renderOption ? renderOption(option) : option.label}

0 commit comments

Comments
 (0)