Skip to content

Commit bfaf06b

Browse files
[X402] Improve token selection and UI for payment playground
1 parent 98d8f29 commit bfaf06b

File tree

6 files changed

+281
-38
lines changed

6 files changed

+281
-38
lines changed

apps/playground-web/src/app/x402/components/X402LeftSection.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type React from "react";
44
import { useId, useState } from "react";
55
import { defineChain } from "thirdweb/chains";
6-
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
6+
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
77
import { Input } from "@/components/ui/input";
88
import { Label } from "@/components/ui/label";
99
import {
@@ -106,7 +106,7 @@ export function X402LeftSection(props: {
106106
{/* Chain selection */}
107107
<div className="flex flex-col gap-2">
108108
<Label htmlFor={chainId}>Chain</Label>
109-
<BridgeNetworkSelector
109+
<SingleNetworkSelector
110110
chainId={selectedChain}
111111
onChange={handleChainChange}
112112
placeholder="Select a chain"
@@ -119,6 +119,7 @@ export function X402LeftSection(props: {
119119
<div className="flex flex-col gap-2">
120120
<Label htmlFor={tokenId}>Token</Label>
121121
<TokenSelector
122+
includeNativeToken={false}
122123
chainId={selectedChain}
123124
client={THIRDWEB_CLIENT}
124125
enabled={true}

apps/playground-web/src/app/x402/components/X402RightSection.tsx

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Badge } from "@workspace/ui/components/badge";
44
import { CodeClient } from "@workspace/ui/components/code/code.client";
5-
import { CodeIcon, LockIcon } from "lucide-react";
5+
import { CircleDollarSignIcon, CodeIcon, LockIcon } from "lucide-react";
66
import { usePathname } from "next/navigation";
77
import { useState } from "react";
88
import { ConnectButton, useFetchWithPayment } from "thirdweb/react";
@@ -28,6 +28,11 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) {
2828
const { fetchWithPayment, isPending, data, error, isError } =
2929
useFetchWithPayment(THIRDWEB_CLIENT);
3030

31+
const isTokenSelected =
32+
props.options.tokenAddress !==
33+
"0x0000000000000000000000000000000000000000" &&
34+
props.options.tokenSymbol !== "";
35+
3136
const handlePayClick = async () => {
3237
const searchParams = new URLSearchParams();
3338
searchParams.set("chainId", props.options.chain.id.toString());
@@ -140,43 +145,44 @@ export async function POST(request: Request) {
140145
>
141146
<BackgroundPattern />
142147

143-
{previewTab === "ui" && (
148+
{previewTab === "ui" && !isTokenSelected && (
149+
<Card className="p-8 text-center max-w-md mx-auto my-auto">
150+
<CircleDollarSignIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
151+
<h3 className="text-lg font-medium mb-2">Select Payment Token</h3>
152+
<p className="text-sm text-muted-foreground">
153+
Please select a chain and payment token from the configuration
154+
panel to continue.
155+
</p>
156+
</Card>
157+
)}
158+
159+
{previewTab === "ui" && isTokenSelected && (
144160
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
145161
<ConnectButton
146162
client={THIRDWEB_CLIENT}
147163
chain={props.options.chain}
148164
detailsButton={{
149-
displayBalanceToken:
150-
props.options.tokenAddress !==
151-
"0x0000000000000000000000000000000000000000"
152-
? {
153-
[props.options.chain.id]: props.options.tokenAddress,
154-
}
155-
: undefined,
165+
displayBalanceToken: {
166+
[props.options.chain.id]: props.options.tokenAddress,
167+
},
168+
}}
169+
supportedTokens={{
170+
[props.options.chain.id]: [
171+
{
172+
address: props.options.tokenAddress,
173+
symbol: props.options.tokenSymbol,
174+
name: props.options.tokenSymbol,
175+
},
176+
],
156177
}}
157-
supportedTokens={
158-
props.options.tokenAddress !==
159-
"0x0000000000000000000000000000000000000000"
160-
? {
161-
[props.options.chain.id]: [
162-
{
163-
address: props.options.tokenAddress,
164-
symbol: props.options.tokenSymbol,
165-
name: props.options.tokenSymbol,
166-
},
167-
],
168-
}
169-
: undefined
170-
}
171178
/>
172179
<Card className="p-6">
173180
<div className="flex items-center gap-3 mb-4">
174181
<LockIcon className="w-5 h-5 text-muted-foreground" />
175182
<span className="text-lg font-medium">Paid API Call</span>
176183
<Badge variant="success">
177184
<span className="text-xl font-bold">
178-
{props.options.amount}{" "}
179-
{props.options.tokenSymbol || "tokens"}
185+
{props.options.amount} {props.options.tokenSymbol}
180186
</span>
181187
</Badge>
182188
</div>
@@ -190,7 +196,7 @@ export async function POST(request: Request) {
190196
Access Premium Content
191197
</Button>
192198
<p className="text-sm text-muted-foreground">
193-
Pay for access with {props.options.tokenSymbol || "tokens"} on{" "}
199+
Pay for access with {props.options.tokenSymbol} on{" "}
194200
{props.options.chain.name || `chain ${props.options.chain.id}`}
195201
</p>
196202
</Card>

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,144 @@
11
"use client";
22

3+
import { Badge } from "@workspace/ui/components/badge";
34
import { useCallback, useMemo } from "react";
45
import { ChainIcon } from "@/components/blocks/ChainIcon";
56
import { SelectWithSearch } from "@/components/ui/select-with-search";
67
import { useBridgeSupportedChains } from "@/hooks/chains";
8+
import { useAllChainsData } from "../../hooks/allChains";
79

810
function cleanChainName(chainName: string) {
911
return chainName.replace("Mainnet", "");
1012
}
1113

1214
type Option = { label: string; value: string };
1315

16+
export function SingleNetworkSelector(props: {
17+
chainId: number | undefined;
18+
onChange: (chainId: number) => void;
19+
className?: string;
20+
popoverContentClassName?: string;
21+
// if specified - only these chains will be shown
22+
chainIds?: number[];
23+
side?: "left" | "right" | "top" | "bottom";
24+
disableChainId?: boolean;
25+
align?: "center" | "start" | "end";
26+
disableTestnets?: boolean;
27+
disableDeprecated?: boolean;
28+
placeholder?: string;
29+
}) {
30+
const allChains = useAllChainsData();
31+
32+
const chainsToShow = useMemo(() => {
33+
let chains = allChains || [];
34+
35+
if (props.disableTestnets) {
36+
chains = chains.filter((chain) => !chain.testnet);
37+
}
38+
39+
if (props.chainIds) {
40+
const chainIdSet = new Set(props.chainIds);
41+
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
42+
}
43+
44+
if (props.disableDeprecated) {
45+
chains = chains.filter((chain) => chain.status !== "deprecated");
46+
}
47+
48+
return chains;
49+
}, [
50+
allChains,
51+
props.chainIds,
52+
props.disableTestnets,
53+
props.disableDeprecated,
54+
]);
55+
56+
const options = useMemo(() => {
57+
return chainsToShow.map((chain) => {
58+
return {
59+
label: chain.name,
60+
value: String(chain.chainId),
61+
};
62+
});
63+
}, [chainsToShow]);
64+
65+
const searchFn = useCallback(
66+
(option: Option, searchValue: string) => {
67+
const chain = chainsToShow.find(
68+
(chain) => chain.chainId === Number(option.value),
69+
);
70+
if (!chain) {
71+
return false;
72+
}
73+
74+
if (Number.isInteger(Number(searchValue))) {
75+
return String(chain.chainId).startsWith(searchValue);
76+
}
77+
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
78+
},
79+
[chainsToShow],
80+
);
81+
82+
const renderOption = useCallback(
83+
(option: Option) => {
84+
const chain = chainsToShow.find(
85+
(chain) => chain.chainId === Number(option.value),
86+
);
87+
if (!chain) {
88+
return option.label;
89+
}
90+
91+
return (
92+
<div className="flex justify-between gap-4">
93+
<span className="flex grow gap-2 truncate text-left">
94+
<ChainIcon
95+
className="size-5"
96+
ipfsSrc={chain.icon?.url}
97+
loading="lazy"
98+
/>
99+
{cleanChainName(chain.name)}
100+
</span>
101+
102+
{!props.disableChainId && (
103+
<Badge className="gap-2 max-sm:hidden" variant="outline">
104+
<span className="text-muted-foreground">Chain ID</span>
105+
{chain.chainId}
106+
</Badge>
107+
)}
108+
</div>
109+
);
110+
},
111+
[chainsToShow, props.disableChainId],
112+
);
113+
114+
const isLoadingChains = !allChains || allChains.length === 0;
115+
116+
return (
117+
<SelectWithSearch
118+
align={props.align}
119+
className={props.className}
120+
closeOnSelect={true}
121+
disabled={isLoadingChains}
122+
onValueChange={(chainId) => {
123+
props.onChange(Number(chainId));
124+
}}
125+
options={options}
126+
overrideSearchFn={searchFn}
127+
placeholder={
128+
isLoadingChains
129+
? "Loading Chains..."
130+
: props.placeholder || "Select Chain"
131+
}
132+
popoverContentClassName={props.popoverContentClassName}
133+
renderOption={renderOption}
134+
searchPlaceholder="Search by Name or Chain ID"
135+
showCheck={false}
136+
side={props.side}
137+
value={props.chainId ? String(props.chainId) : undefined}
138+
/>
139+
);
140+
}
141+
14142
export function BridgeNetworkSelector(props: {
15143
chainId: number | undefined;
16144
onChange: (chainId: number) => void;

apps/playground-web/src/components/ui/TokenSelector.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { CoinsIcon } from "lucide-react";
44
import { useCallback, useMemo } from "react";
5-
import type { ThirdwebClient } from "thirdweb";
5+
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
66
import { shortenAddress } from "thirdweb/utils";
77
import { Badge } from "@/components/ui/badge";
88
import { Img } from "@/components/ui/Img";
@@ -21,13 +21,24 @@ export function TokenSelector(props: {
2121
client: ThirdwebClient;
2222
disabled?: boolean;
2323
enabled?: boolean;
24+
includeNativeToken?: boolean;
2425
}) {
2526
const tokensQuery = useTokensData({
2627
chainId: props.chainId,
2728
enabled: props.enabled,
2829
});
2930

30-
const tokens = tokensQuery.data || [];
31+
const tokens = useMemo(() => {
32+
if (props.includeNativeToken === false) {
33+
return (
34+
tokensQuery.data?.filter(
35+
(token) =>
36+
token.address.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase(),
37+
) || []
38+
);
39+
}
40+
return tokensQuery.data || [];
41+
}, [tokensQuery.data, props.includeNativeToken]);
3142
const addressChainToToken = useMemo(() => {
3243
const value = new Map<string, TokenMetadata>();
3344
for (const token of tokens) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import type { ChainMetadata } from "thirdweb/chains";
5+
6+
async function fetchChainsFromApi() {
7+
// always fetch from prod for chains for now
8+
// TODO: re-visit this
9+
const res = await fetch("https://api.thirdweb.com/v1/chains");
10+
const json = await res.json();
11+
12+
if (json.error || !res.ok) {
13+
throw new Error(
14+
json.error?.message || `Failed to fetch chains: ${res.status}`,
15+
);
16+
}
17+
18+
return json.data as ChainMetadata[];
19+
}
20+
21+
export function useAllChainsData() {
22+
// trigger fetching all chains if this hook is used instead of putting this on root
23+
// so we can avoid fetching all chains if it's not required
24+
const allChainsQuery = useQuery({
25+
queryFn: () => fetchChainsFromApi(),
26+
queryKey: ["all-chains"],
27+
});
28+
29+
return allChainsQuery.data;
30+
}

0 commit comments

Comments
 (0)