Skip to content

Commit 1308d6d

Browse files
[Playground] Add TokenSelector component to Pay embed configuration
1 parent 7500d87 commit 1308d6d

File tree

11 files changed

+958
-244
lines changed

11 files changed

+958
-244
lines changed

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

Lines changed: 218 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 { TokenSelector } from "@/components/ui/TokenSelector";
7+
import ThirdwebProvider from "@/components/thirdweb-provider";
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+
title="Token Selector Demo"
28+
description="Demo of the TokenSelector component ported from dashboard"
29+
docsLink="https://portal.thirdweb.com/react/v5/components/onchain?utm_source=playground"
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
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
55
import { useAllChainsData } from "../../app/hooks/chains";
66
import { ChainIcon } from "./ChainIcon";
77
import { MultiSelect } from "./multi-select";
8+
import { SelectWithSearch } from "../ui/select-with-search";
89

910
function cleanChainName(chainName: string) {
1011
return chainName.replace("Mainnet", "");
@@ -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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"use client";
2+
3+
import { useCallback, useMemo } from "react";
4+
import {
5+
getAddress,
6+
NATIVE_TOKEN_ADDRESS,
7+
type ThirdwebClient,
8+
} from "thirdweb";
9+
import { shortenAddress } from "thirdweb/utils";
10+
import type { TokenMetadata } from "@/lib/types";
11+
import {
12+
Select,
13+
SelectContent,
14+
SelectItem,
15+
SelectTrigger,
16+
SelectValue,
17+
} from "@/components/ui/select";
18+
import { Badge } from "@/components/ui/badge";
19+
import { Img } from "@/components/ui/Img";
20+
import { useTokensData } from "@/hooks/useTokensData";
21+
import { replaceIpfsUrl, fallbackChainIcon } from "@/lib/utils";
22+
import { cn } from "@/lib/utils";
23+
import { useAllChainsData } from "../../app/hooks/chains";
24+
import { CoinsIcon } from "lucide-react";
25+
26+
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
27+
28+
export function TokenSelector(props: {
29+
selectedToken: { chainId: number; address: string } | undefined;
30+
onChange: (token: TokenMetadata) => void;
31+
className?: string;
32+
chainId?: number;
33+
disableAddress?: boolean;
34+
placeholder?: string;
35+
client: ThirdwebClient;
36+
disabled?: boolean;
37+
enabled?: boolean;
38+
addNativeTokenIfMissing: boolean;
39+
}) {
40+
const tokensQuery = useTokensData({
41+
chainId: props.chainId,
42+
enabled: props.enabled,
43+
});
44+
45+
const { idToChain } = useAllChainsData().data;
46+
47+
const tokens = useMemo(() => {
48+
if (!tokensQuery.data) {
49+
return [];
50+
}
51+
52+
if (props.addNativeTokenIfMissing) {
53+
const hasNativeToken = tokensQuery.data.some(
54+
(token) => token.address === checksummedNativeTokenAddress,
55+
);
56+
57+
if (!hasNativeToken && props.chainId) {
58+
return [
59+
{
60+
address: checksummedNativeTokenAddress,
61+
chainId: props.chainId,
62+
decimals: 18,
63+
name:
64+
idToChain.get(props.chainId)?.nativeCurrency.name ??
65+
"Native Token",
66+
symbol:
67+
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
68+
} satisfies TokenMetadata,
69+
...tokensQuery.data,
70+
];
71+
}
72+
}
73+
return tokensQuery.data;
74+
}, [
75+
tokensQuery.data,
76+
props.chainId,
77+
props.addNativeTokenIfMissing,
78+
idToChain,
79+
]);
80+
81+
const addressChainToToken = useMemo(() => {
82+
const value = new Map<string, TokenMetadata>();
83+
for (const token of tokens) {
84+
value.set(`${token.chainId}:${token.address}`, token);
85+
}
86+
return value;
87+
}, [tokens]);
88+
89+
const selectedValue = props.selectedToken
90+
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
91+
: undefined;
92+
93+
const renderTokenOption = useCallback(
94+
(token: TokenMetadata) => {
95+
const resolvedSrc = token.iconUri
96+
? replaceIpfsUrl(token.iconUri, props.client)
97+
: fallbackChainIcon;
98+
99+
return (
100+
<div className="flex items-center justify-between gap-4">
101+
<span className="flex grow gap-2 truncate text-left">
102+
<Img
103+
alt=""
104+
className={cn("size-5 rounded-full object-contain")}
105+
fallback={<CoinsIcon className="size-5" />}
106+
key={resolvedSrc}
107+
loading="lazy"
108+
skeleton={
109+
<div className="animate-pulse rounded-full bg-border" />
110+
}
111+
src={resolvedSrc}
112+
/>
113+
{token.symbol}
114+
</span>
115+
116+
{!props.disableAddress && (
117+
<Badge className="gap-2 py-1 max-sm:hidden" variant="outline">
118+
<span className="text-muted-foreground">Address</span>
119+
{shortenAddress(token.address, 4)}
120+
</Badge>
121+
)}
122+
</div>
123+
);
124+
},
125+
[props.disableAddress, props.client],
126+
);
127+
128+
return (
129+
<Select
130+
disabled={tokensQuery.isLoading || props.disabled}
131+
onValueChange={(tokenAddress) => {
132+
const token = addressChainToToken.get(tokenAddress);
133+
if (!token) {
134+
return;
135+
}
136+
props.onChange(token);
137+
}}
138+
value={selectedValue}
139+
>
140+
<SelectTrigger className={cn("w-full", props.className)}>
141+
<SelectValue
142+
placeholder={
143+
tokensQuery.isLoading
144+
? "Loading Tokens..."
145+
: props.placeholder || "Select Token"
146+
}
147+
/>
148+
</SelectTrigger>
149+
<SelectContent>
150+
{tokens.map((token) => {
151+
const value = `${token.chainId}:${token.address}`;
152+
return (
153+
<SelectItem key={value} value={value} >
154+
{renderTokenOption(token)}
155+
</SelectItem>
156+
);
157+
})}
158+
</SelectContent>
159+
</Select>
160+
);
161+
}

0 commit comments

Comments
 (0)