Skip to content

Commit 198cfd0

Browse files
[Playground] Add TokenSelector component to Pay embed configuration (#7527)
1 parent 9fba016 commit 198cfd0

File tree

9 files changed

+860
-243
lines changed

9 files changed

+860
-243
lines changed

apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx

Lines changed: 219 additions & 241 deletions
Large diffs are not rendered by default.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { arbitrum, base, ethereum } from "thirdweb/chains";
5+
import { PageLayout } from "@/components/blocks/APIHeader";
6+
import ThirdwebProvider from "@/components/thirdweb-provider";
7+
import { TokenSelector } from "@/components/ui/TokenSelector";
8+
import { THIRDWEB_CLIENT } from "@/lib/client";
9+
import type { TokenMetadata } from "@/lib/types";
10+
11+
export default function TokenSelectorDemo() {
12+
const [selectedToken, setSelectedToken] = useState<
13+
{ chainId: number; address: string } | undefined
14+
>(undefined);
15+
16+
const [selectedChain, setSelectedChain] = useState<number>(ethereum.id);
17+
18+
const chains = [
19+
{ id: ethereum.id, name: "Ethereum" },
20+
{ id: base.id, name: "Base" },
21+
{ id: arbitrum.id, name: "Arbitrum" },
22+
];
23+
24+
return (
25+
<ThirdwebProvider>
26+
<PageLayout
27+
description="Demo of the TokenSelector component ported from dashboard"
28+
docsLink="https://portal.thirdweb.com/react/v5/components/onchain?utm_source=playground"
29+
title="Token Selector Demo"
30+
>
31+
<div className="space-y-8">
32+
<div className="space-y-4">
33+
<h2 className="text-xl font-semibold">Select a Chain</h2>
34+
<select
35+
className="rounded border border-border bg-background p-2"
36+
onChange={(e) => {
37+
setSelectedChain(Number(e.target.value));
38+
setSelectedToken(undefined); // Reset token when chain changes
39+
}}
40+
value={selectedChain}
41+
>
42+
{chains.map((chain) => (
43+
<option key={chain.id} value={chain.id}>
44+
{chain.name}
45+
</option>
46+
))}
47+
</select>
48+
</div>
49+
50+
<div className="space-y-4">
51+
<h2 className="text-xl font-semibold">Select a Token</h2>
52+
<div className="max-w-md">
53+
<TokenSelector
54+
addNativeTokenIfMissing={true}
55+
chainId={selectedChain}
56+
client={THIRDWEB_CLIENT}
57+
enabled={true}
58+
onChange={(token: TokenMetadata) => {
59+
setSelectedToken({
60+
address: token.address,
61+
chainId: token.chainId,
62+
});
63+
}}
64+
placeholder="Select a token"
65+
selectedToken={selectedToken}
66+
/>
67+
</div>
68+
</div>
69+
70+
{selectedToken && (
71+
<div className="space-y-4">
72+
<h2 className="text-xl font-semibold">Selected Token</h2>
73+
<div className="rounded border border-border bg-muted p-4">
74+
<p>
75+
<strong>Chain ID:</strong> {selectedToken.chainId}
76+
</p>
77+
<p>
78+
<strong>Address:</strong> {selectedToken.address}
79+
</p>
80+
</div>
81+
</div>
82+
)}
83+
</div>
84+
</PageLayout>
85+
</ThirdwebProvider>
86+
);
87+
}

apps/playground-web/src/components/blocks/NetworkSelectors.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useMemo } from "react";
44
import { Badge } from "@/components/ui/badge";
55
import { useAllChainsData } from "../../app/hooks/chains";
6+
import { SelectWithSearch } from "../ui/select-with-search";
67
import { ChainIcon } from "./ChainIcon";
78
import { MultiSelect } from "./multi-select";
89

@@ -127,3 +128,115 @@ export function MultiNetworkSelector(props: {
127128
/>
128129
);
129130
}
131+
132+
export function SingleNetworkSelector(props: {
133+
chainId: number | undefined;
134+
onChange: (chainId: number) => void;
135+
className?: string;
136+
popoverContentClassName?: string;
137+
// if specified - only these chains will be shown
138+
chainIds?: number[];
139+
side?: "left" | "right" | "top" | "bottom";
140+
disableChainId?: boolean;
141+
align?: "center" | "start" | "end";
142+
disableTestnets?: boolean;
143+
placeholder?: string;
144+
}) {
145+
const { allChains, idToChain } = useAllChainsData().data;
146+
147+
const chainsToShow = useMemo(() => {
148+
let chains = allChains;
149+
150+
if (props.disableTestnets) {
151+
chains = chains.filter((chain) => !chain.testnet);
152+
}
153+
154+
if (props.chainIds) {
155+
const chainIdSet = new Set(props.chainIds);
156+
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
157+
}
158+
159+
return chains;
160+
}, [allChains, props.chainIds, props.disableTestnets]);
161+
162+
const options = useMemo(() => {
163+
return chainsToShow.map((chain) => {
164+
return {
165+
label: cleanChainName(chain.name),
166+
value: String(chain.chainId),
167+
};
168+
});
169+
}, [chainsToShow]);
170+
171+
const searchFn = useCallback(
172+
(option: Option, searchValue: string) => {
173+
const chain = idToChain.get(Number(option.value));
174+
if (!chain) {
175+
return false;
176+
}
177+
178+
if (Number.isInteger(Number.parseInt(searchValue))) {
179+
return String(chain.chainId).startsWith(searchValue);
180+
}
181+
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
182+
},
183+
[idToChain],
184+
);
185+
186+
const renderOption = useCallback(
187+
(option: Option) => {
188+
const chain = idToChain.get(Number(option.value));
189+
if (!chain) {
190+
return option.label;
191+
}
192+
193+
return (
194+
<div className="flex justify-between gap-4">
195+
<span className="flex grow gap-2 truncate text-left">
196+
<ChainIcon
197+
className="size-5"
198+
ipfsSrc={chain.icon?.url}
199+
loading="lazy"
200+
/>
201+
{cleanChainName(chain.name)}
202+
</span>
203+
204+
{!props.disableChainId && (
205+
<Badge className="gap-2 max-sm:hidden" variant="outline">
206+
<span className="text-muted-foreground">Chain ID</span>
207+
{chain.chainId}
208+
</Badge>
209+
)}
210+
</div>
211+
);
212+
},
213+
[idToChain, props.disableChainId],
214+
);
215+
216+
const isLoadingChains = allChains.length === 0;
217+
218+
return (
219+
<SelectWithSearch
220+
align={props.align}
221+
className={props.className}
222+
closeOnSelect={true}
223+
disabled={isLoadingChains}
224+
onValueChange={(chainId) => {
225+
props.onChange(Number(chainId));
226+
}}
227+
options={options}
228+
overrideSearchFn={searchFn}
229+
placeholder={
230+
isLoadingChains
231+
? "Loading Chains..."
232+
: props.placeholder || "Select Chain"
233+
}
234+
popoverContentClassName={props.popoverContentClassName}
235+
renderOption={renderOption}
236+
searchPlaceholder="Search by Name or Chain ID"
237+
showCheck={false}
238+
side={props.side}
239+
value={props.chainId?.toString()}
240+
/>
241+
);
242+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import { CoinsIcon } from "lucide-react";
4+
import { useCallback, useMemo } from "react";
5+
import {
6+
getAddress,
7+
NATIVE_TOKEN_ADDRESS,
8+
type ThirdwebClient,
9+
} from "thirdweb";
10+
import { shortenAddress } from "thirdweb/utils";
11+
import { Badge } from "@/components/ui/badge";
12+
import { Img } from "@/components/ui/Img";
13+
import {
14+
Select,
15+
SelectContent,
16+
SelectItem,
17+
SelectTrigger,
18+
SelectValue,
19+
} from "@/components/ui/select";
20+
import { useTokensData } from "@/hooks/useTokensData";
21+
import type { TokenMetadata } from "@/lib/types";
22+
import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils";
23+
import { useAllChainsData } from "../../app/hooks/chains";
24+
25+
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
26+
27+
export function TokenSelector(props: {
28+
selectedToken: { chainId: number; address: string } | undefined;
29+
onChange: (token: TokenMetadata) => void;
30+
className?: string;
31+
chainId?: number;
32+
disableAddress?: boolean;
33+
placeholder?: string;
34+
client: ThirdwebClient;
35+
disabled?: boolean;
36+
enabled?: boolean;
37+
addNativeTokenIfMissing: boolean;
38+
}) {
39+
const tokensQuery = useTokensData({
40+
chainId: props.chainId,
41+
enabled: props.enabled,
42+
});
43+
44+
const { idToChain } = useAllChainsData().data;
45+
46+
const tokens = useMemo(() => {
47+
if (!tokensQuery.data) {
48+
return [];
49+
}
50+
51+
if (props.addNativeTokenIfMissing) {
52+
const hasNativeToken = tokensQuery.data.some(
53+
(token) => token.address === checksummedNativeTokenAddress,
54+
);
55+
56+
if (!hasNativeToken && props.chainId) {
57+
return [
58+
{
59+
address: checksummedNativeTokenAddress,
60+
chainId: props.chainId,
61+
decimals: 18,
62+
name:
63+
idToChain.get(props.chainId)?.nativeCurrency.name ??
64+
"Native Token",
65+
symbol:
66+
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
67+
} satisfies TokenMetadata,
68+
...tokensQuery.data,
69+
];
70+
}
71+
}
72+
return tokensQuery.data;
73+
}, [
74+
tokensQuery.data,
75+
props.chainId,
76+
props.addNativeTokenIfMissing,
77+
idToChain,
78+
]);
79+
80+
const addressChainToToken = useMemo(() => {
81+
const value = new Map<string, TokenMetadata>();
82+
for (const token of tokens) {
83+
value.set(`${token.chainId}:${token.address}`, token);
84+
}
85+
return value;
86+
}, [tokens]);
87+
88+
const selectedValue = props.selectedToken
89+
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
90+
: undefined;
91+
92+
const renderTokenOption = useCallback(
93+
(token: TokenMetadata) => {
94+
const resolvedSrc = token.iconUri
95+
? replaceIpfsUrl(token.iconUri, props.client)
96+
: fallbackChainIcon;
97+
98+
return (
99+
<div className="flex items-center justify-between gap-4">
100+
<span className="flex grow gap-2 truncate text-left">
101+
<Img
102+
alt=""
103+
className={cn("size-5 rounded-full object-contain")}
104+
fallback={<CoinsIcon className="size-5" />}
105+
key={resolvedSrc}
106+
loading="lazy"
107+
skeleton={
108+
<div className="animate-pulse rounded-full bg-border" />
109+
}
110+
src={resolvedSrc}
111+
/>
112+
{token.symbol}
113+
</span>
114+
115+
{!props.disableAddress && (
116+
<Badge className="gap-2 py-1 max-sm:hidden" variant="outline">
117+
<span className="text-muted-foreground">Address</span>
118+
{shortenAddress(token.address, 4)}
119+
</Badge>
120+
)}
121+
</div>
122+
);
123+
},
124+
[props.disableAddress, props.client],
125+
);
126+
127+
return (
128+
<Select
129+
disabled={tokensQuery.isLoading || props.disabled}
130+
onValueChange={(tokenAddress) => {
131+
const token = addressChainToToken.get(tokenAddress);
132+
if (!token) {
133+
return;
134+
}
135+
props.onChange(token);
136+
}}
137+
value={selectedValue}
138+
>
139+
<SelectTrigger className={cn("w-full", props.className)}>
140+
<SelectValue
141+
placeholder={
142+
tokensQuery.isLoading
143+
? "Loading Tokens..."
144+
: props.placeholder || "Select Token"
145+
}
146+
/>
147+
</SelectTrigger>
148+
<SelectContent>
149+
{tokens.map((token) => {
150+
const value = `${token.chainId}:${token.address}`;
151+
return (
152+
<SelectItem key={value} value={value}>
153+
{renderTokenOption(token)}
154+
</SelectItem>
155+
);
156+
})}
157+
</SelectContent>
158+
</Select>
159+
);
160+
}

0 commit comments

Comments
 (0)