From 32980f854b9d53f0039d7bf913a2e5d8ba03704e Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 19 Sep 2025 00:17:37 +0000 Subject: [PATCH] [MNY-190] Playground: Add SwapWidget (#8069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR introduces significant updates to the `SwapWidget` component, including a new `persistTokenSelections` prop for managing token selection persistence. It also enhances the UI components and integrates a new bridge feature. ### Detailed summary - Added `persistTokenSelections` prop to `SwapWidget`. - Updated `SwapWidgetPlayground` for better token management. - Introduced `BridgeNetworkSelector` and `CurrencySelector` components. - Added `bridgeFeatureCards` to the feature metadata. - Enhanced `LeftSection` for improved token and currency selection. - Updated the sidebar to include the bridge section. - Refactored network selection logic to use the bridge API. - Improved code formatting and organization in multiple files. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit - New Features - SwapWidget gains a persistTokenSelections prop to opt out of saving token choices. - Added a Swap Widget Playground with live preview, generated code view, theme/currency/prefill/branding controls, and UI/Code tabs. - Playground is integrated into site navigation and feature cards under a new Bridge section. - Chores - Playground now uses bridge-supported chains and improved TypeScript formatting for generated snippets. - Added a reusable currency selector for widget configuration. --- .changeset/honest-hands-clap.md | 5 + .../bridge/swap-widget/components/code.tsx | 105 ++++++ .../swap-widget/components/left-section.tsx | 226 ++++++++++++ .../swap-widget/components/right-section.tsx | 116 ++++++ .../components/swap-widget-playground.tsx | 45 +++ .../bridge/swap-widget/components/types.ts | 12 + .../src/app/bridge/swap-widget/page.tsx | 35 ++ .../src/app/data/pages-metadata.ts | 10 + apps/playground-web/src/app/navLinks.ts | 21 +- apps/playground-web/src/app/page.tsx | 2 + .../src/app/payments/components/CodeGen.tsx | 2 +- .../app/payments/components/LeftSection.tsx | 85 +---- .../sign-in/button/connect-button-page.tsx | 8 +- .../components/blocks/CurrencySelector.tsx | 53 +++ .../components/blocks/NetworkSelectors.tsx | 174 +-------- .../src/components/blocks/multi-select.tsx | 332 ------------------ .../src/components/ui/TokenSelector.tsx | 47 +-- apps/playground-web/src/hooks/chains.ts | 52 +-- apps/playground-web/src/lib/env.ts | 6 +- apps/playground-web/src/lib/useShowMore.ts | 37 -- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 26 +- .../ui/src/components/code/getCodeHtml.tsx | 8 +- 22 files changed, 716 insertions(+), 691 deletions(-) create mode 100644 .changeset/honest-hands-clap.md create mode 100644 apps/playground-web/src/app/bridge/swap-widget/components/code.tsx create mode 100644 apps/playground-web/src/app/bridge/swap-widget/components/left-section.tsx create mode 100644 apps/playground-web/src/app/bridge/swap-widget/components/right-section.tsx create mode 100644 apps/playground-web/src/app/bridge/swap-widget/components/swap-widget-playground.tsx create mode 100644 apps/playground-web/src/app/bridge/swap-widget/components/types.ts create mode 100644 apps/playground-web/src/app/bridge/swap-widget/page.tsx create mode 100644 apps/playground-web/src/components/blocks/CurrencySelector.tsx delete mode 100644 apps/playground-web/src/components/blocks/multi-select.tsx delete mode 100644 apps/playground-web/src/lib/useShowMore.ts diff --git a/.changeset/honest-hands-clap.md b/.changeset/honest-hands-clap.md new file mode 100644 index 00000000000..0dfc2e7bcee --- /dev/null +++ b/.changeset/honest-hands-clap.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Add `persistTokenSelections` prop on `SwapWidget` to allow disabling token selection persistence to local storage diff --git a/apps/playground-web/src/app/bridge/swap-widget/components/code.tsx b/apps/playground-web/src/app/bridge/swap-widget/components/code.tsx new file mode 100644 index 00000000000..b0a2358dbbe --- /dev/null +++ b/apps/playground-web/src/app/bridge/swap-widget/components/code.tsx @@ -0,0 +1,105 @@ +import { lazy, Suspense } from "react"; +import { LoadingDots } from "@/components/ui/LoadingDots"; +import type { SwapWidgetPlaygroundOptions } from "./types"; + +const CodeClient = lazy(() => + import("../../../../components/code/code.client").then((m) => ({ + default: m.CodeClient, + })), +); + +function CodeLoading() { + return ( +
+ +
+ ); +} + +export function CodeGen(props: { options: SwapWidgetPlaygroundOptions }) { + return ( +
+ }> + + +
+ ); +} + +function getCode(options: SwapWidgetPlaygroundOptions) { + const imports = { + react: ["SwapWidget"] as string[], + }; + + let themeProp: string | undefined; + if ( + options.theme.type === "dark" && + Object.keys(options.theme.darkColorOverrides || {}).length > 0 + ) { + themeProp = `darkTheme({ + colors: ${JSON.stringify(options.theme.darkColorOverrides)}, + })`; + imports.react.push("darkTheme"); + } + + if (options.theme.type === "light") { + if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) { + themeProp = `lightTheme({ + colors: ${JSON.stringify(options.theme.lightColorOverrides)}, + })`; + imports.react.push("lightTheme"); + } else { + themeProp = quotes("light"); + } + } + + const props: Record = { + theme: themeProp, + prefill: + options.prefill?.buyToken || options.prefill?.sellToken + ? JSON.stringify(options.prefill, null, 2) + : undefined, + currency: + options.currency !== "USD" && options.currency + ? quotes(options.currency) + : undefined, + showThirdwebBranding: + options.showThirdwebBranding === false ? false : undefined, + client: "client", + }; + + return `\ +import { createThirdwebClient } from "thirdweb"; +import { ${imports.react.join(", ")} } from "thirdweb/react"; + +const client = createThirdwebClient({ + clientId: "....", +}); + + +function Example() { + return ( + + ); +}`; +} + +function quotes(value: string) { + return `"${value}"`; +} + +function stringifyProps(props: Record) { + const _props: Record = {}; + + for (const key in props) { + if (props[key] !== undefined && props[key] !== "") { + _props[key] = props[key]; + } + } + + return Object.entries(_props) + .map(([key, value]) => `${key}={${value}}`) + .join("\n\t "); +} diff --git a/apps/playground-web/src/app/bridge/swap-widget/components/left-section.tsx b/apps/playground-web/src/app/bridge/swap-widget/components/left-section.tsx new file mode 100644 index 00000000000..28677b19515 --- /dev/null +++ b/apps/playground-web/src/app/bridge/swap-widget/components/left-section.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { CoinsIcon, PaletteIcon } from "lucide-react"; +import type React from "react"; +import { useId } from "react"; +import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb"; +import { CollapsibleSection } from "@/app/wallets/sign-in/components/CollapsibleSection"; +import { ColorFormGroup } from "@/app/wallets/sign-in/components/ColorFormGroup"; +import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +import { TokenSelector } from "@/components/ui/TokenSelector"; +import { THIRDWEB_CLIENT } from "@/lib/client"; +import { CurrencySelector } from "../../../../components/blocks/CurrencySelector"; +import type { SwapWidgetPlaygroundOptions } from "./types"; + +export function LeftSection(props: { + options: SwapWidgetPlaygroundOptions; + setOptions: React.Dispatch>; +}) { + const { options, setOptions } = props; + const setThemeType = (themeType: "dark" | "light") => { + setOptions((v) => ({ + ...v, + theme: { + ...v.theme, + type: themeType, + }, + })); + }; + + const themeId = useId(); + + return ( +
+ +
+
+ + { + setOptions((v) => ({ ...v, currency })); + }} + /> +
+ +
+ + + +
+ + +
+ + + + {/* Theme */} +
+ + +
+ +
+ + {/* Colors */} + { + setOptions((v) => ({ + ...v, + theme: newTheme, + })); + }} + theme={options.theme} + /> + +
+ { + setOptions((v) => ({ + ...v, + showThirdwebBranding: checked === true, + })); + }} + /> + +
+ +
+ ); +} + +function TokenFieldset(props: { + type: "buyToken" | "sellToken"; + title: string; + options: SwapWidgetPlaygroundOptions; + setOptions: React.Dispatch>; +}) { + const { options, setOptions } = props; + + const chainId = options.prefill?.[props.type]?.chainId; + const tokenAddress = options.prefill?.[props.type]?.tokenAddress; + + return ( +
+

{props.title}

+

+ Sets the default token and amount to{" "} + {props.type === "buyToken" ? "buy" : "sell"} in the widget,
+ User can change this default selection in the widget +

+
+ {/* Chain selection */} +
+ + { + setOptions((v) => ({ + ...v, + prefill: { + ...v.prefill, + [props.type]: { + ...v.prefill?.[props.type], + chainId, + tokenAddress: undefined, // clear token selection + }, + }, + })); + }} + placeholder="Select a chain" + className="bg-card" + /> +
+ + {/* Token selection - only show if chain is selected */} +
+ + { + setOptions((v) => ({ + ...v, + prefill: { + ...v.prefill, + [props.type]: { + chainId: token.chainId, + tokenAddress: token.address, + }, + }, + })); + }} + placeholder="Select a token" + selectedToken={ + tokenAddress && chainId + ? { + address: tokenAddress, + chainId: chainId, + } + : chainId + ? { + address: getAddress(NATIVE_TOKEN_ADDRESS), + chainId: chainId, + } + : undefined + } + className="bg-card" + /> +
+ + {chainId && ( +
+ + { + setOptions((v) => { + return { + ...v, + prefill: { + ...v.prefill, + [props.type]: { + ...v.prefill?.[props.type], + amount: e.target.value, + chainId: chainId, + }, + }, + }; + }); + }} + placeholder="0.01" + /> +
+ )} +
+
+ ); +} diff --git a/apps/playground-web/src/app/bridge/swap-widget/components/right-section.tsx b/apps/playground-web/src/app/bridge/swap-widget/components/right-section.tsx new file mode 100644 index 00000000000..97431df203b --- /dev/null +++ b/apps/playground-web/src/app/bridge/swap-widget/components/right-section.tsx @@ -0,0 +1,116 @@ +"use client"; +import { useState } from "react"; +import { darkTheme, lightTheme, SwapWidget } from "thirdweb/react"; +import { Button } from "@/components/ui/button"; +import { THIRDWEB_CLIENT } from "@/lib/client"; +import { cn } from "@/lib/utils"; +import { CodeGen } from "./code"; +import type { SwapWidgetPlaygroundOptions } from "./types"; + +type Tab = "ui" | "code"; + +export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) { + const [previewTab, _setPreviewTab] = useState(() => { + return "ui"; + }); + + function setPreviewTab(tab: "ui" | "code") { + _setPreviewTab(tab); + } + + const themeObj = + props.options.theme.type === "dark" + ? darkTheme({ + colors: props.options.theme.darkColorOverrides, + }) + : lightTheme({ + colors: props.options.theme.lightColorOverrides, + }); + + return ( +
+ setPreviewTab("ui"), + }, + { + isActive: previewTab === "code", + name: "Code", + onClick: () => setPreviewTab("code"), + }, + ]} + /> + +
+ + + {previewTab === "ui" && ( + + )} + + {previewTab === "code" && } +
+
+ ); +} + +function BackgroundPattern() { + const color = "hsl(var(--foreground)/15%)"; + return ( +
+ ); +} + +function TabButtons(props: { + tabs: Array<{ + name: string; + isActive: boolean; + onClick: () => void; + }>; +}) { + return ( +
+
+ {props.tabs.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/apps/playground-web/src/app/bridge/swap-widget/components/swap-widget-playground.tsx b/apps/playground-web/src/app/bridge/swap-widget/components/swap-widget-playground.tsx new file mode 100644 index 00000000000..920d33fc7c7 --- /dev/null +++ b/apps/playground-web/src/app/bridge/swap-widget/components/swap-widget-playground.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { LeftSection } from "./left-section"; +import { RightSection } from "./right-section"; +import type { SwapWidgetPlaygroundOptions } from "./types"; + +const defaultOptions: SwapWidgetPlaygroundOptions = { + prefill: undefined, + currency: "USD", + showThirdwebBranding: true, + theme: { + darkColorOverrides: {}, + lightColorOverrides: {}, + type: "dark", + }, +}; + +export function SwapWidgetPlayground() { + const { theme } = useTheme(); + + const [options, setOptions] = + useState(defaultOptions); + + // change theme on global theme change + useEffect(() => { + setOptions((prev) => ({ + ...prev, + theme: { + ...prev.theme, + type: theme === "dark" ? "dark" : "light", + }, + })); + }, [theme]); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/playground-web/src/app/bridge/swap-widget/components/types.ts b/apps/playground-web/src/app/bridge/swap-widget/components/types.ts new file mode 100644 index 00000000000..2dedd030a9b --- /dev/null +++ b/apps/playground-web/src/app/bridge/swap-widget/components/types.ts @@ -0,0 +1,12 @@ +import type { SwapWidgetProps, ThemeOverrides } from "thirdweb/react"; + +export type SwapWidgetPlaygroundOptions = { + theme: { + type: "dark" | "light"; + darkColorOverrides: ThemeOverrides["colors"]; + lightColorOverrides: ThemeOverrides["colors"]; + }; + currency?: SwapWidgetProps["currency"]; + prefill?: SwapWidgetProps["prefill"]; + showThirdwebBranding?: SwapWidgetProps["showThirdwebBranding"]; +}; diff --git a/apps/playground-web/src/app/bridge/swap-widget/page.tsx b/apps/playground-web/src/app/bridge/swap-widget/page.tsx new file mode 100644 index 00000000000..1ab129450fa --- /dev/null +++ b/apps/playground-web/src/app/bridge/swap-widget/page.tsx @@ -0,0 +1,35 @@ +import { BringToFrontIcon } from "lucide-react"; +import { PageLayout } from "@/components/blocks/APIHeader"; +import ThirdwebProvider from "@/components/thirdweb-provider"; +import { createMetadata } from "@/lib/metadata"; +import { SwapWidgetPlayground } from "./components/swap-widget-playground"; + +const title = "Swap Widget Component"; +const description = + "Embeddable component for users to swap any cryptocurrency with cross-chain support"; +const ogDescription = + "Configure a component to swap cryptocurrency with specified amounts, customization, and more. This interactive playground shows how to customize the component."; + +export const metadata = createMetadata({ + description: ogDescription, + title, + image: { + icon: "payments", + title, + }, +}); + +export default function Page() { + return ( + + + + + + ); +} diff --git a/apps/playground-web/src/app/data/pages-metadata.ts b/apps/playground-web/src/app/data/pages-metadata.ts index efe9e9e8874..70ab97af026 100644 --- a/apps/playground-web/src/app/data/pages-metadata.ts +++ b/apps/playground-web/src/app/data/pages-metadata.ts @@ -3,6 +3,7 @@ import { BlocksIcon, BotIcon, BoxIcon, + BringToFrontIcon, CircleUserIcon, CreditCardIcon, DollarSignIcon, @@ -247,3 +248,12 @@ export const aiFeatureCards: FeatureCardMetadata[] = [ description: "Use the thirdweb blockchain models with the Vercel AI SDK", }, ]; + +export const bridgeFeatureCards: FeatureCardMetadata[] = [ + { + icon: BringToFrontIcon, + title: "Swap Widget", + link: "/bridge/swap-widget", + description: "A widget for swapping tokens with cross-chain support", + }, +]; diff --git a/apps/playground-web/src/app/navLinks.ts b/apps/playground-web/src/app/navLinks.ts index 16a283a54c1..2c6640e9b02 100644 --- a/apps/playground-web/src/app/navLinks.ts +++ b/apps/playground-web/src/app/navLinks.ts @@ -1,6 +1,11 @@ "use client"; -import { ArrowLeftRightIcon, BotIcon, Code2Icon } from "lucide-react"; +import { + ArrowLeftRightIcon, + BotIcon, + BringToFrontIcon, + Code2Icon, +} from "lucide-react"; import type { ShadcnSidebarLink } from "@/components/blocks/full-width-sidebar-layout"; import { ContractIcon } from "../icons/ContractIcon"; import { PayIcon } from "../icons/PayIcon"; @@ -204,6 +209,19 @@ const payments: ShadcnSidebarLink = { ], }; +const bridge: ShadcnSidebarLink = { + subMenu: { + label: "Bridge", + icon: BringToFrontIcon, + }, + links: [ + { + href: "/bridge/swap-widget", + label: "Swap Widget", + }, + ], +}; + const transactions: ShadcnSidebarLink = { subMenu: { label: "Transactions", @@ -240,6 +258,7 @@ export const sidebarLinks: ShadcnSidebarLink[] = [ transactions, contracts, payments, + bridge, tokens, accountAbstractions, { diff --git a/apps/playground-web/src/app/page.tsx b/apps/playground-web/src/app/page.tsx index fcdb61f1037..250fd71ac51 100644 --- a/apps/playground-web/src/app/page.tsx +++ b/apps/playground-web/src/app/page.tsx @@ -3,6 +3,7 @@ import { ThirdwebIcon } from "../icons/ThirdwebMiniLogo"; import { accountAbstractionsFeatureCards, aiFeatureCards, + bridgeFeatureCards, contractsFeatureCards, type FeatureCardMetadata, paymentsFeatureCards, @@ -47,6 +48,7 @@ export default function Page() { title="Contracts" /> + diff --git a/apps/playground-web/src/app/payments/components/LeftSection.tsx b/apps/playground-web/src/app/payments/components/LeftSection.tsx index 0bcc4dc72e3..f72a84ff13b 100644 --- a/apps/playground-web/src/app/payments/components/LeftSection.tsx +++ b/apps/playground-web/src/app/payments/components/LeftSection.tsx @@ -12,22 +12,15 @@ import type React from "react"; import { useId, useState } from "react"; import type { Address } from "thirdweb"; import { defineChain } from "thirdweb/chains"; -import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { TokenSelector } from "@/components/ui/TokenSelector"; import { THIRDWEB_CLIENT } from "@/lib/client"; import type { TokenMetadata } from "@/lib/types"; -import type { SupportedFiatCurrency } from "../../../../../../packages/thirdweb/dist/types/pay/convert/type"; +import { CurrencySelector } from "../../../components/blocks/CurrencySelector"; import { CollapsibleSection } from "../../wallets/sign-in/components/CollapsibleSection"; import { ColorFormGroup } from "../../wallets/sign-in/components/ColorFormGroup"; import type { BridgeComponentsPlaygroundOptions } from "./types"; @@ -37,7 +30,7 @@ export function LeftSection(props: { setOptions: React.Dispatch< React.SetStateAction >; - lockedWidget?: "buy" | "checkout" | "transaction"; + lockedWidget: "buy" | "checkout" | "transaction"; }) { const { options, setOptions } = props; const { theme, payOptions } = options; @@ -69,7 +62,7 @@ export function LeftSection(props: { return undefined; }); - const payModeId = useId(); + const _payModeId = useId(); const buyTokenAmountId = useId(); const sellerAddressId = useId(); const paymentAmountId = useId(); @@ -120,76 +113,20 @@ export function LeftSection(props: { title="Payment Options" >
- {props.lockedWidget === undefined && ( -
- - { - setOptions( - (v) => - ({ - ...v, - payOptions: { - ...v.payOptions, - widget: value as "buy" | "checkout" | "transaction", - }, - }) satisfies BridgeComponentsPlaygroundOptions, - ); - }} - options={[ - { label: "Buy", value: "buy" }, - { label: "Checkout", value: "checkout" }, - { label: "Transaction", value: "transaction" }, - ]} - value={payOptions.widget || "buy"} - /> -
- )} -
- + />
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */} @@ -198,9 +135,8 @@ export function LeftSection(props: { {/* Chain selection */}
- (() => ({ - ...defaultConnectOptions, - theme: { - ...defaultConnectOptions.theme, - type: theme === "dark" ? "dark" : "light", - }, - })); + useState(defaultConnectOptions); // change theme on global theme change useEffect(() => { diff --git a/apps/playground-web/src/components/blocks/CurrencySelector.tsx b/apps/playground-web/src/components/blocks/CurrencySelector.tsx new file mode 100644 index 00000000000..3aea2302a45 --- /dev/null +++ b/apps/playground-web/src/components/blocks/CurrencySelector.tsx @@ -0,0 +1,53 @@ +"use client"; + +import type { SwapWidgetProps } from "thirdweb/react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function CurrencySelector(props: { + value: SwapWidgetProps["currency"]; + onChange: (value: SwapWidgetProps["currency"]) => void; +}) { + return ( + + ); +} diff --git a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx index 7bdeabb87cb..8fa6e142979 100644 --- a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx +++ b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx @@ -2,10 +2,8 @@ import { useCallback, useMemo } from "react"; import { ChainIcon } from "@/components/blocks/ChainIcon"; -import { MultiSelect } from "@/components/blocks/multi-select"; -import { Badge } from "@/components/ui/badge"; import { SelectWithSearch } from "@/components/ui/select-with-search"; -import { useAllChainsData } from "@/hooks/chains"; +import { useBridgeSupportedChains } from "@/hooks/chains"; function cleanChainName(chainName: string) { return chainName.replace("Mainnet", ""); @@ -13,164 +11,31 @@ function cleanChainName(chainName: string) { type Option = { label: string; value: string }; -export function MultiNetworkSelector(props: { - selectedChainIds: number[]; - onChange: (chainIds: number[]) => void; - disableChainId?: boolean; - className?: string; - priorityChains?: number[]; - popoverContentClassName?: string; - chainIds?: number[]; - selectedBadgeClassName?: string; -}) { - const { allChains, idToChain } = useAllChainsData().data; - - const chainsToShow = useMemo(() => { - if (!props.chainIds) { - return allChains; - } - const chainIdSet = new Set(props.chainIds); - return allChains.filter((chain) => chainIdSet.has(chain.chainId)); - }, [allChains, props.chainIds]); - - const options = useMemo(() => { - let sortedChains = chainsToShow; - - if (props.priorityChains) { - const priorityChainsSet = new Set(); - for (const chainId of props.priorityChains || []) { - priorityChainsSet.add(chainId); - } - - const priorityChains = (props.priorityChains || []) - .map((chainId) => { - return idToChain.get(chainId); - }) - .filter((v) => !!v); - - const otherChains = chainsToShow.filter( - (chain) => !priorityChainsSet.has(chain.chainId), - ); - - sortedChains = [...priorityChains, ...otherChains]; - } - - return sortedChains.map((chain) => { - return { - label: cleanChainName(chain.name), - value: String(chain.chainId), - }; - }); - }, [chainsToShow, props.priorityChains, idToChain]); - - 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 ( -
- - - {cleanChainName(chain.name)} - - - {!props.disableChainId && ( - - Chain ID - {chain.chainId} - - )} -
- ); - }, - [idToChain, props.disableChainId], - ); - - return ( - { - props.onChange(chainIds.map(Number)); - }} - options={options} - overrideSearchFn={searchFn} - placeholder={ - allChains.length === 0 ? "Loading Chains..." : "Select Chains" - } - popoverContentClassName={props.popoverContentClassName} - renderOption={renderOption} - searchPlaceholder="Search by Name or Chain Id" - selectedBadgeClassName={props.selectedBadgeClassName} - selectedValues={props.selectedChainIds.map(String)} - /> - ); -} - -export function SingleNetworkSelector(props: { +export function BridgeNetworkSelector(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 chainsQuery = useBridgeSupportedChains(); const options = useMemo(() => { - return chainsToShow.map((chain) => { + return (chainsQuery.data || [])?.map((chain) => { return { label: cleanChainName(chain.name), value: String(chain.chainId), }; }); - }, [chainsToShow]); + }, [chainsQuery.data]); const searchFn = useCallback( (option: Option, searchValue: string) => { - const chain = idToChain.get(Number(option.value)); + const chain = chainsQuery.data?.find( + (chain) => chain.chainId === Number(option.value), + ); if (!chain) { return false; } @@ -180,12 +45,14 @@ export function SingleNetworkSelector(props: { } return chain.name.toLowerCase().includes(searchValue.toLowerCase()); }, - [idToChain], + [chainsQuery.data], ); const renderOption = useCallback( (option: Option) => { - const chain = idToChain.get(Number(option.value)); + const chain = chainsQuery.data?.find( + (chain) => chain.chainId === Number(option.value), + ); if (!chain) { return option.label; } @@ -193,27 +60,16 @@ export function SingleNetworkSelector(props: { return (
- + {cleanChainName(chain.name)} - - {!props.disableChainId && ( - - Chain ID - {chain.chainId} - - )}
); }, - [idToChain, props.disableChainId], + [chainsQuery.data], ); - const isLoadingChains = allChains.length === 0; + const isLoadingChains = chainsQuery.isPending; return ( { - options: { - label: string; - value: string; - }[]; - - selectedValues: string[]; - onSelectedValuesChange: (value: string[]) => void; - placeholder: string; - searchPlaceholder?: string; - popoverContentClassName?: string; - selectedBadgeClassName?: string; - /** - * Maximum number of items to display. Extra selected items will be summarized. - * Optional, defaults to 3. - */ - maxCount?: number; - - className?: string; - - overrideSearchFn?: ( - option: { value: string; label: string }, - searchTerm: string, - ) => boolean; - - renderOption?: (option: { value: string; label: string }) => React.ReactNode; -} - -export const MultiSelect = forwardRef( - ( - { - options, - onSelectedValuesChange, - placeholder, - maxCount = Number.POSITIVE_INFINITY, - className, - selectedValues, - overrideSearchFn, - renderOption, - popoverContentClassName, - selectedBadgeClassName, - searchPlaceholder, - ...props - }, - ref, - ) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [searchValue, setSearchValue] = useState(""); - - const handleInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - setIsPopoverOpen(true); - } else if (event.key === "Backspace" && !event.currentTarget.value) { - const newSelectedValues = [...selectedValues]; - newSelectedValues.pop(); - onSelectedValuesChange(newSelectedValues); - } - }, - [selectedValues, onSelectedValuesChange], - ); - - const toggleOption = useCallback( - (option: string) => { - const newSelectedValues = selectedValues.includes(option) - ? selectedValues.filter((value) => value !== option) - : [...selectedValues, option]; - onSelectedValuesChange(newSelectedValues); - }, - [selectedValues, onSelectedValuesChange], - ); - - const handleClear = useCallback(() => { - onSelectedValuesChange([]); - }, [onSelectedValuesChange]); - - const handleTogglePopover = () => { - setIsPopoverOpen((prev) => !prev); - }; - - const clearExtraOptions = useCallback(() => { - const newSelectedValues = selectedValues.slice(0, maxCount); - onSelectedValuesChange(newSelectedValues); - }, [selectedValues, onSelectedValuesChange, maxCount]); - - // show 50 initially and then 20 more when reaching the end - const { itemsToShow, lastItemRef } = useShowMore(50, 20); - - const optionsToShow = useMemo(() => { - const filteredOptions: { - label: string; - value: string; - }[] = []; - - const searchValLowercase = searchValue.toLowerCase(); - - for (let i = 0; i <= options.length - 1; i++) { - if (filteredOptions.length >= itemsToShow) { - break; - } - const option = options[i]; - if (!option) { - continue; - } - - if (overrideSearchFn) { - if (overrideSearchFn(option, searchValLowercase)) { - filteredOptions.push(option); - } - } else { - if (option.label.toLowerCase().includes(searchValLowercase)) { - filteredOptions.push(option); - } - } - } - - return filteredOptions; - }, [options, searchValue, itemsToShow, overrideSearchFn]); - - // scroll to top when options change - const popoverElRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: ok - useEffect(() => { - const scrollContainer = - popoverElRef.current?.querySelector("[data-scrollable]"); - if (scrollContainer) { - scrollContainer.scrollTo({ - top: 0, - }); - } - }, [searchValue]); - - return ( - - - - - setIsPopoverOpen(false)} - ref={popoverElRef} - sideOffset={10} - style={{ - maxHeight: "var(--radix-popover-content-available-height)", - width: "var(--radix-popover-trigger-width)", - }} - > -
- {/* Search */} -
- setSearchValue(e.target.value)} - onKeyDown={handleInputKeyDown} - placeholder={searchPlaceholder || "Search"} - value={searchValue} - /> - -
- - - {/* List */} -
- {optionsToShow.length === 0 && ( -
- No results found -
- )} - - {optionsToShow.map((option, i) => { - const isSelected = selectedValues.includes(option.value); - return ( - - ); - })} -
-
-
-
-
- ); - }, -); - -function ClosableBadge(props: { - label: string; - onClose: () => void; - className?: string; -}) { - return ( - - {props.label} - { - e.stopPropagation(); - props.onClose(); - }} - /> - - ); -} - -MultiSelect.displayName = "MultiSelect"; diff --git a/apps/playground-web/src/components/ui/TokenSelector.tsx b/apps/playground-web/src/components/ui/TokenSelector.tsx index 8d9266c4826..6ed20a844be 100644 --- a/apps/playground-web/src/components/ui/TokenSelector.tsx +++ b/apps/playground-web/src/components/ui/TokenSelector.tsx @@ -2,22 +2,15 @@ import { CoinsIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; -import { - getAddress, - NATIVE_TOKEN_ADDRESS, - type ThirdwebClient, -} from "thirdweb"; +import type { ThirdwebClient } from "thirdweb"; import { shortenAddress } from "thirdweb/utils"; import { Badge } from "@/components/ui/badge"; import { Img } from "@/components/ui/Img"; import { SelectWithSearch } from "@/components/ui/select-with-search"; -import { useAllChainsData } from "@/hooks/chains"; import { useTokensData } from "@/hooks/useTokensData"; import type { TokenMetadata } from "@/lib/types"; import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils"; -const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); - export function TokenSelector(props: { selectedToken: { chainId: number; address: string } | undefined; onChange: (token: TokenMetadata) => void; @@ -28,49 +21,13 @@ export function TokenSelector(props: { 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 tokens = tokensQuery.data || []; const addressChainToToken = useMemo(() => { const value = new Map(); for (const token of tokens) { diff --git a/apps/playground-web/src/hooks/chains.ts b/apps/playground-web/src/hooks/chains.ts index abb7ca51506..0a8fe88a7b0 100644 --- a/apps/playground-web/src/hooks/chains.ts +++ b/apps/playground-web/src/hooks/chains.ts @@ -1,39 +1,43 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { ChainMetadata } from "thirdweb/chains"; - -async function fetchChainsFromApi() { - const res = await fetch("https://api.thirdweb.com/v1/chains"); +import { THIRDWEB_CLIENT } from "../lib/client"; +import { isProd } from "../lib/env"; + +type BridgeChain = { + chainId: number; + name: string; + icon: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + }; +}; + +async function fetchBridgeSupportedChainsFromApi() { + const res = await fetch( + `https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/chains`, + { + headers: { + "x-client-id": THIRDWEB_CLIENT.clientId, + }, + }, + ); const json = await res.json(); if (json.error) { throw new Error(json.error.message); } - return json.data as ChainMetadata[]; + return json.data as BridgeChain[]; } -export function useAllChainsData() { - const query = useQuery({ +export function useBridgeSupportedChains() { + return useQuery({ queryFn: async () => { - const idToChain = new Map(); - const chains = await fetchChainsFromApi(); - - for (const c of chains) { - idToChain.set(c.chainId, c); - } - - return { - allChains: chains, - idToChain, - }; + return fetchBridgeSupportedChainsFromApi(); }, - queryKey: ["all-chains"], + queryKey: ["bridge-supported-chains"], }); - - return { - data: query.data || { allChains: [], idToChain: new Map() }, - isPending: query.isLoading, - }; } diff --git a/apps/playground-web/src/lib/env.ts b/apps/playground-web/src/lib/env.ts index 694da750e5e..71bd0710767 100644 --- a/apps/playground-web/src/lib/env.ts +++ b/apps/playground-web/src/lib/env.ts @@ -1,6 +1,6 @@ -// export const isProd = -// (process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) === -// "production"; +export const isProd = + (process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) === + "production"; function getVercelEnv(): "production" | "preview" | "development" { const onVercel = process.env.VERCEL || process.env.NEXT_PUBLIC_VERCEL_ENV; diff --git a/apps/playground-web/src/lib/useShowMore.ts b/apps/playground-web/src/lib/useShowMore.ts deleted file mode 100644 index 052037fb172..00000000000 --- a/apps/playground-web/src/lib/useShowMore.ts +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; - -/** - * - * @internal - */ -export function useShowMore( - initialItemsToShow: number, - itemsToAdd: number, -) { - // start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more - const [itemsToShow, setItemsToShow] = useState(initialItemsToShow); - const lastItemRef = useCallback( - (node: T) => { - if (!node) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items - } - }, - { threshold: 1 }, - ); - - observer.observe(node); - // when the node is removed from the DOM, observer will be disconnected automatically by the browser - }, - [itemsToAdd], - ); - - return { itemsToShow, lastItemRef }; -} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 00d05687289..5f05e4adbef 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -154,6 +154,14 @@ export type SwapWidgetProps = { onCancel?: (quote: SwapPreparedQuote) => void; style?: React.CSSProperties; className?: string; + + /** + * Whether to persist the token selections to localStorage so that if the user revisits the widget, the last used tokens are pre-selected. + * The last used tokens do not override the tokens specified in the `prefill` prop + * + * @default true + */ + persistTokenSelections?: boolean; }; /** @@ -291,6 +299,7 @@ type SwapWidgetScreen = function SwapWidgetContent(props: SwapWidgetProps) { const [screen, setScreen] = useState({ id: "1:swap-ui" }); const activeWalletInfo = useActiveWalletInfo(); + const isPersistEnabled = props.persistTokenSelections !== false; const [amountSelection, setAmountSelection] = useState<{ type: "buy" | "sell"; @@ -323,6 +332,11 @@ function SwapWidgetContent(props: SwapWidgetProps) { chainId: props.prefill.buyToken.chainId, }; } + + if (!isPersistEnabled) { + return undefined; + } + const lastUsedBuyToken = getLastUsedTokens()?.buyToken; // the token that will be set as initial value of sell token @@ -346,13 +360,18 @@ function SwapWidgetContent(props: SwapWidgetProps) { }); const [sellToken, setSellToken] = useState(() => { - return getInitialSellToken(props.prefill, getLastUsedTokens()?.sellToken); + return getInitialSellToken( + props.prefill, + isPersistEnabled ? getLastUsedTokens()?.sellToken : undefined, + ); }); // persist selections to localStorage whenever they change useEffect(() => { - setLastUsedTokens({ buyToken, sellToken }); - }, [buyToken, sellToken]); + if (isPersistEnabled) { + setLastUsedTokens({ buyToken, sellToken }); + } + }, [buyToken, sellToken, isPersistEnabled]); // preload requests useBridgeChains(props.client); @@ -524,5 +543,6 @@ function getInitialSellToken( chainId: prefill.sellToken.chainId, }; } + return lastUsedSellToken; } diff --git a/packages/ui/src/components/code/getCodeHtml.tsx b/packages/ui/src/components/code/getCodeHtml.tsx index 24e45e077e2..768e1e2934e 100644 --- a/packages/ui/src/components/code/getCodeHtml.tsx +++ b/packages/ui/src/components/code/getCodeHtml.tsx @@ -1,4 +1,5 @@ -import * as parserBabel from "prettier/plugins/babel"; +import estreePlugin from "prettier/plugins/estree"; +import typescriptPlugin from "prettier/plugins/typescript"; import { format } from "prettier/standalone"; import { type BundledLanguage, codeToHtml } from "shiki"; @@ -20,11 +21,10 @@ export async function getCodeHtml( ignoreFormattingErrors?: boolean; }, ) { - const estreePlugin = await import("prettier/plugins/estree"); const formattedCode = isPrettierSupportedLang(lang) ? await format(code, { - parser: "babel-ts", - plugins: [parserBabel, estreePlugin.default], + parser: "typescript", + plugins: [estreePlugin, typescriptPlugin], printWidth: 60, }).catch((e) => { if (!options?.ignoreFormattingErrors) {