Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
460 changes: 219 additions & 241 deletions apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions apps/playground-web/src/app/token-selector-demo/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { useState } from "react";
import { arbitrum, base, ethereum } from "thirdweb/chains";
import { PageLayout } from "@/components/blocks/APIHeader";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import type { TokenMetadata } from "@/lib/types";

export default function TokenSelectorDemo() {
const [selectedToken, setSelectedToken] = useState<
{ chainId: number; address: string } | undefined
>(undefined);

const [selectedChain, setSelectedChain] = useState<number>(ethereum.id);

const chains = [
{ id: ethereum.id, name: "Ethereum" },
{ id: base.id, name: "Base" },
{ id: arbitrum.id, name: "Arbitrum" },
];

return (
<ThirdwebProvider>
<PageLayout
description="Demo of the TokenSelector component ported from dashboard"
docsLink="https://portal.thirdweb.com/react/v5/components/onchain?utm_source=playground"
title="Token Selector Demo"
>
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-xl font-semibold">Select a Chain</h2>
<select
className="rounded border border-border bg-background p-2"
onChange={(e) => {
setSelectedChain(Number(e.target.value));
setSelectedToken(undefined); // Reset token when chain changes
}}
value={selectedChain}
>
{chains.map((chain) => (
<option key={chain.id} value={chain.id}>
{chain.name}
</option>
))}
</select>
</div>

<div className="space-y-4">
<h2 className="text-xl font-semibold">Select a Token</h2>
<div className="max-w-md">
<TokenSelector
addNativeTokenIfMissing={true}
chainId={selectedChain}
client={THIRDWEB_CLIENT}
enabled={true}
onChange={(token: TokenMetadata) => {
setSelectedToken({
address: token.address,
chainId: token.chainId,
});
}}
placeholder="Select a token"
selectedToken={selectedToken}
/>
</div>
</div>

{selectedToken && (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Selected Token</h2>
<div className="rounded border border-border bg-muted p-4">
<p>
<strong>Chain ID:</strong> {selectedToken.chainId}
</p>
<p>
<strong>Address:</strong> {selectedToken.address}
</p>
</div>
</div>
)}
</div>
</PageLayout>
</ThirdwebProvider>
);
}
113 changes: 113 additions & 0 deletions apps/playground-web/src/components/blocks/NetworkSelectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { useAllChainsData } from "../../app/hooks/chains";
import { SelectWithSearch } from "../ui/select-with-search";
import { ChainIcon } from "./ChainIcon";
import { MultiSelect } from "./multi-select";

Expand Down Expand Up @@ -127,3 +128,115 @@ export function MultiNetworkSelector(props: {
/>
);
}

export function SingleNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
disableTestnets?: boolean;
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData().data;

const chainsToShow = useMemo(() => {
let chains = allChains;

if (props.disableTestnets) {
chains = chains.filter((chain) => !chain.testnet);
}

if (props.chainIds) {
const chainIdSet = new Set(props.chainIds);
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
}

return chains;
}, [allChains, props.chainIds, props.disableTestnets]);

const options = useMemo(() => {
return chainsToShow.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [chainsToShow]);

const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return false;
}

if (Number.isInteger(Number.parseInt(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[idToChain],
);

const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return option.label;
}

return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>

{!props.disableChainId && (
<Badge className="gap-2 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
);

const isLoadingChains = allChains.length === 0;

return (
<SelectWithSearch
align={props.align}
className={props.className}
closeOnSelect={true}
disabled={isLoadingChains}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={
isLoadingChains
? "Loading Chains..."
: props.placeholder || "Select Chain"
}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain ID"
showCheck={false}
side={props.side}
value={props.chainId?.toString()}
/>
);
}
160 changes: 160 additions & 0 deletions apps/playground-web/src/components/ui/TokenSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import { CoinsIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { Badge } from "@/components/ui/badge";
import { Img } from "@/components/ui/Img";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTokensData } from "@/hooks/useTokensData";
import type { TokenMetadata } from "@/lib/types";
import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils";
import { useAllChainsData } from "../../app/hooks/chains";

const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);

export function TokenSelector(props: {
selectedToken: { chainId: number; address: string } | undefined;
onChange: (token: TokenMetadata) => void;
className?: string;
chainId?: number;
disableAddress?: boolean;
placeholder?: string;
client: ThirdwebClient;
disabled?: boolean;
enabled?: boolean;
addNativeTokenIfMissing: boolean;
}) {
const tokensQuery = useTokensData({
chainId: props.chainId,
enabled: props.enabled,
});

const { idToChain } = useAllChainsData().data;

const tokens = useMemo(() => {
if (!tokensQuery.data) {
return [];
}

if (props.addNativeTokenIfMissing) {
const hasNativeToken = tokensQuery.data.some(
(token) => token.address === checksummedNativeTokenAddress,
);

if (!hasNativeToken && props.chainId) {
return [
{
address: checksummedNativeTokenAddress,
chainId: props.chainId,
decimals: 18,
name:
idToChain.get(props.chainId)?.nativeCurrency.name ??
"Native Token",
symbol:
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
} satisfies TokenMetadata,
...tokensQuery.data,
];
}
}
return tokensQuery.data;
}, [
tokensQuery.data,
props.chainId,
props.addNativeTokenIfMissing,
idToChain,
]);

const addressChainToToken = useMemo(() => {
const value = new Map<string, TokenMetadata>();
for (const token of tokens) {
value.set(`${token.chainId}:${token.address}`, token);
}
return value;
}, [tokens]);

const selectedValue = props.selectedToken
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
: undefined;

const renderTokenOption = useCallback(
(token: TokenMetadata) => {
const resolvedSrc = token.iconUri
? replaceIpfsUrl(token.iconUri, props.client)
: fallbackChainIcon;

return (
<div className="flex items-center justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<Img
alt=""
className={cn("size-5 rounded-full object-contain")}
fallback={<CoinsIcon className="size-5" />}
key={resolvedSrc}
loading="lazy"
skeleton={
<div className="animate-pulse rounded-full bg-border" />
}
src={resolvedSrc}
/>
{token.symbol}
</span>

{!props.disableAddress && (
<Badge className="gap-2 py-1 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Address</span>
{shortenAddress(token.address, 4)}
</Badge>
)}
</div>
);
},
[props.disableAddress, props.client],
);

return (
<Select
disabled={tokensQuery.isLoading || props.disabled}
onValueChange={(tokenAddress) => {
const token = addressChainToToken.get(tokenAddress);
if (!token) {
return;
}
props.onChange(token);
}}
value={selectedValue}
>
<SelectTrigger className={cn("w-full", props.className)}>
<SelectValue
placeholder={
tokensQuery.isLoading
? "Loading Tokens..."
: props.placeholder || "Select Token"
}
/>
</SelectTrigger>
<SelectContent>
{tokens.map((token) => {
const value = `${token.chainId}:${token.address}`;
return (
<SelectItem key={value} value={value}>
{renderTokenOption(token)}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
Loading
Loading