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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type React from "react";
import { useId, useState } from "react";
import { defineChain } from "thirdweb/chains";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Expand Down Expand Up @@ -106,7 +106,7 @@ export function X402LeftSection(props: {
{/* Chain selection */}
<div className="flex flex-col gap-2">
<Label htmlFor={chainId}>Chain</Label>
<BridgeNetworkSelector
<SingleNetworkSelector
chainId={selectedChain}
onChange={handleChainChange}
placeholder="Select a chain"
Expand All @@ -119,6 +119,7 @@ export function X402LeftSection(props: {
<div className="flex flex-col gap-2">
<Label htmlFor={tokenId}>Token</Label>
<TokenSelector
includeNativeToken={false}
chainId={selectedChain}
client={THIRDWEB_CLIENT}
enabled={true}
Expand Down
58 changes: 32 additions & 26 deletions apps/playground-web/src/app/x402/components/X402RightSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

const isTokenSelected =
props.options.tokenAddress !==
"0x0000000000000000000000000000000000000000" &&
props.options.tokenSymbol !== "";

const handlePayClick = async () => {
const searchParams = new URLSearchParams();
searchParams.set("chainId", props.options.chain.id.toString());
Expand Down Expand Up @@ -140,43 +145,44 @@ export async function POST(request: Request) {
>
<BackgroundPattern />

{previewTab === "ui" && (
{previewTab === "ui" && !isTokenSelected && (
<Card className="p-8 text-center max-w-md mx-auto my-auto">
<CircleDollarSignIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Select Payment Token</h3>
<p className="text-sm text-muted-foreground">
Please select a chain and payment token from the configuration
panel to continue.
</p>
</Card>
)}

{previewTab === "ui" && isTokenSelected && (
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={props.options.chain}
detailsButton={{
displayBalanceToken:
props.options.tokenAddress !==
"0x0000000000000000000000000000000000000000"
? {
[props.options.chain.id]: props.options.tokenAddress,
}
: undefined,
displayBalanceToken: {
[props.options.chain.id]: props.options.tokenAddress,
},
}}
supportedTokens={{
[props.options.chain.id]: [
{
address: props.options.tokenAddress,
symbol: props.options.tokenSymbol,
name: props.options.tokenSymbol,
},
],
}}
supportedTokens={
props.options.tokenAddress !==
"0x0000000000000000000000000000000000000000"
? {
[props.options.chain.id]: [
{
address: props.options.tokenAddress,
symbol: props.options.tokenSymbol,
name: props.options.tokenSymbol,
},
],
}
: undefined
}
/>
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<LockIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">Paid API Call</span>
<Badge variant="success">
<span className="text-xl font-bold">
{props.options.amount}{" "}
{props.options.tokenSymbol || "tokens"}
{props.options.amount} {props.options.tokenSymbol}
</span>
</Badge>
</div>
Expand All @@ -190,7 +196,7 @@ export async function POST(request: Request) {
Access Premium Content
</Button>
<p className="text-sm text-muted-foreground">
Pay for access with {props.options.tokenSymbol || "tokens"} on{" "}
Pay for access with {props.options.tokenSymbol} on{" "}
{props.options.chain.name || `chain ${props.options.chain.id}`}
</p>
</Card>
Expand Down
128 changes: 128 additions & 0 deletions apps/playground-web/src/components/blocks/NetworkSelectors.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,144 @@
"use client";

import { Badge } from "@workspace/ui/components/badge";
import { useCallback, useMemo } from "react";
import { ChainIcon } from "@/components/blocks/ChainIcon";
import { SelectWithSearch } from "@/components/ui/select-with-search";
import { useBridgeSupportedChains } from "@/hooks/chains";
import { useAllChainsData } from "../../hooks/allChains";

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

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

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;
disableDeprecated?: boolean;
placeholder?: string;
}) {
const allChains = useAllChainsData();

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));
}

if (props.disableDeprecated) {
chains = chains.filter((chain) => chain.status !== "deprecated");
}

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

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

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

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

const renderOption = useCallback(
(option: Option) => {
const chain = chainsToShow.find(
(chain) => chain.chainId === 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>
);
},
[chainsToShow, props.disableChainId],
);

const isLoadingChains = !allChains || 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 ? String(props.chainId) : undefined}
/>
);
}

export function BridgeNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
Expand Down
15 changes: 13 additions & 2 deletions apps/playground-web/src/components/ui/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { CoinsIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { Badge } from "@/components/ui/badge";
import { Img } from "@/components/ui/Img";
Expand All @@ -21,13 +21,24 @@ export function TokenSelector(props: {
client: ThirdwebClient;
disabled?: boolean;
enabled?: boolean;
includeNativeToken?: boolean;
}) {
const tokensQuery = useTokensData({
chainId: props.chainId,
enabled: props.enabled,
});

const tokens = tokensQuery.data || [];
const tokens = useMemo(() => {
if (props.includeNativeToken === false) {
return (
tokensQuery.data?.filter(
(token) =>
token.address.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase(),
) || []
);
}
return tokensQuery.data || [];
}, [tokensQuery.data, props.includeNativeToken]);
const addressChainToToken = useMemo(() => {
const value = new Map<string, TokenMetadata>();
for (const token of tokens) {
Expand Down
30 changes: 30 additions & 0 deletions apps/playground-web/src/hooks/allChains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import type { ChainMetadata } from "thirdweb/chains";

async function fetchChainsFromApi() {
// always fetch from prod for chains for now
// TODO: re-visit this
const res = await fetch("https://api.thirdweb.com/v1/chains");
const json = await res.json();

if (json.error || !res.ok) {
throw new Error(
json.error?.message || `Failed to fetch chains: ${res.status}`,
);
}

return json.data as ChainMetadata[];
}

export function useAllChainsData() {
// trigger fetching all chains if this hook is used instead of putting this on root
// so we can avoid fetching all chains if it's not required
const allChainsQuery = useQuery({
queryFn: () => fetchChainsFromApi(),
queryKey: ["all-chains"],
});

return allChainsQuery.data;
}
Loading
Loading