diff --git a/.changeset/lucky-turtles-smell.md b/.changeset/lucky-turtles-smell.md new file mode 100644 index 00000000000..c41721882da --- /dev/null +++ b/.changeset/lucky-turtles-smell.md @@ -0,0 +1,9 @@ +--- +"thirdweb": minor +--- + +Add `SwapWidget` component for swapping tokens using thirdweb Bridge + +```tsx + +``` diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index b847063b594..07792e92dc0 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -250,6 +250,54 @@ export function reportAssetBuySuccessful(properties: { }); } +type TokenSwapParams = { + buyTokenChainId: number; + buyTokenAddress: string; + sellTokenChainId: number; + sellTokenAddress: string; + pageType: "asset" | "bridge" | "chain"; +}; + +/** + * ### Why do we need to report this event? + * - To track number of successful token swaps from the token page + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapSuccessful(properties: TokenSwapParams) { + posthog.capture("token swap successful", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of failed token swaps from the token page + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapFailed( + properties: TokenSwapParams & { + errorMessage: string; + }, +) { + posthog.capture("token swap failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of cancelled token swaps from the token page + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapCancelled(properties: TokenSwapParams) { + posthog.capture("token swap cancelled", properties); +} + /** * ### Why do we need to report this event? * - To track number of failed asset purchases from the token page @@ -272,6 +320,26 @@ export function reportAssetBuyFailed(properties: { }); } +/** + * ### Why do we need to report this event? + * - To track number of cancelled asset purchases from the token page + * - To track the errors that users encounter when trying to purchase an asset + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetBuyCancelled(properties: { + chainId: number; + contractType: AssetContractType; + assetType: "nft" | "coin"; +}) { + posthog.capture("asset buy cancelled", { + assetType: properties.assetType, + chainId: properties.chainId, + contractType: properties.contractType, + }); +} + // Assets Landing Page ---------------------------- /** diff --git a/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx new file mode 100644 index 00000000000..55f2e9603e0 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useState } from "react"; +import type { Chain, ThirdwebClient } from "thirdweb"; +import { BuyWidget, SwapWidget } from "thirdweb/react"; +import { + reportAssetBuyCancelled, + reportAssetBuyFailed, + reportAssetBuySuccessful, + reportTokenSwapCancelled, + reportTokenSwapFailed, + reportTokenSwapSuccessful, +} from "@/analytics/report"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { parseError } from "@/utils/errorParser"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; + +export function BuyAndSwapEmbed(props: { + client: ThirdwebClient; + chain: Chain; + tokenAddress: string | undefined; + buyAmount: string | undefined; + pageType: "asset" | "bridge" | "chain"; +}) { + const { theme } = useTheme(); + const [tab, setTab] = useState<"buy" | "swap">("swap"); + const themeObj = getSDKTheme(theme === "light" ? "light" : "dark"); + return ( +
+
+ setTab("swap")} + isActive={tab === "swap"} + /> + setTab("buy")} + isActive={tab === "buy"} + /> +
+ + {tab === "buy" && ( + { + const errorMessage = parseError(e); + if (props.pageType === "asset") { + reportAssetBuyFailed({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + error: errorMessage, + }); + } + }} + onCancel={() => { + if (props.pageType === "asset") { + reportAssetBuyCancelled({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + }); + } + }} + onSuccess={() => { + if (props.pageType === "asset") { + reportAssetBuySuccessful({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + }); + } + }} + theme={themeObj} + tokenAddress={props.tokenAddress as `0x${string}`} + paymentMethods={["card"]} + /> + )} + + {tab === "swap" && ( + { + const errorMessage = parseError(error); + reportTokenSwapFailed({ + errorMessage: errorMessage, + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, + }); + }} + onSuccess={(quote) => { + reportTokenSwapSuccessful({ + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, + }); + }} + onCancel={(quote) => { + reportTokenSwapCancelled({ + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, + }); + }} + /> + )} +
+ ); +} + +function TabButton(props: { + label: string; + onClick: () => void; + isActive: boolean; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx new file mode 100644 index 00000000000..a7fbce837ba --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx @@ -0,0 +1,23 @@ +import { GridPattern } from "@/components/ui/background-patterns"; + +export function GridPatternEmbedContainer(props: { + children: React.ReactNode; +}) { + return ( +
+ +
{props.children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx index 97eae2dd01d..373594418a7 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx @@ -1,24 +1,24 @@ "use client"; -import { useTheme } from "next-themes"; -import { defineChain, type ThirdwebClient } from "thirdweb"; +import type { ThirdwebClient } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; -import { BuyWidget } from "thirdweb/react"; -import { getSDKTheme } from "@/utils/sdk-component-theme"; +import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed"; +import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container"; +import { defineDashboardChain } from "@/lib/defineDashboardChain"; export function BuyFundsSection(props: { chain: ChainMetadata; client: ThirdwebClient; }) { - const { theme } = useTheme(); return ( -
- + -
+ ); } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx deleted file mode 100644 index b4156ca0430..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useTheme } from "next-themes"; -import type { Chain, ThirdwebClient } from "thirdweb"; -import { BuyWidget } from "thirdweb/react"; -import { - reportAssetBuyFailed, - reportAssetBuySuccessful, -} from "@/analytics/report"; -import { parseError } from "@/utils/errorParser"; -import { getSDKTheme } from "@/utils/sdk-component-theme"; - -export function BuyTokenEmbed(props: { - client: ThirdwebClient; - chain: Chain; - tokenAddress: string; -}) { - const { theme } = useTheme(); - return ( - { - const errorMessage = parseError(e); - reportAssetBuyFailed({ - assetType: "coin", - chainId: props.chain.id, - contractType: "DropERC20", - error: errorMessage, - }); - }} - onSuccess={() => { - reportAssetBuySuccessful({ - assetType: "coin", - chainId: props.chain.id, - contractType: "DropERC20", - }); - }} - theme={getSDKTheme(theme === "light" ? "light" : "dark")} - tokenAddress={props.tokenAddress as `0x${string}`} - /> - ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx index 82ddcda6d0b..11b8da95484 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx @@ -3,7 +3,8 @@ import type { ThirdwebContract } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; import { getContractMetadata } from "thirdweb/extensions/common"; import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20"; -import { GridPattern } from "@/components/ui/background-patterns"; +import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed"; +import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container"; import { HAS_USED_DASHBOARD } from "@/constants/cookie"; import { resolveFunctionSelectors } from "@/lib/selectors"; import { AssetPageView } from "../_components/asset-page-view"; @@ -14,7 +15,6 @@ import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui"; import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics"; import { DexScreener } from "./_components/dex-screener"; import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains"; -import { BuyTokenEmbed } from "./_components/PayEmbedSection"; import { RecentTransfers } from "./_components/RecentTransfers"; import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info"; import { getCurrencyMeta } from "./_utils/getCurrencyMeta"; @@ -104,31 +104,17 @@ export async function ERC20PublicPage(props: { {showBuyEmbed && (
-
- + -
- -
-
{" "} +
)} @@ -185,10 +171,12 @@ function BuyEmbed(props: { }) { if (!props.claimConditionMeta) { return ( - ); } diff --git a/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx b/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx index 366b4d400d4..fe333175adb 100644 --- a/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx +++ b/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx @@ -1,10 +1,8 @@ "use client"; -import { useTheme } from "next-themes"; -import type { Address } from "thirdweb"; -import { BuyWidget, type TokenInfo } from "thirdweb/react"; +import type { TokenInfo } from "thirdweb/react"; +import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed"; import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; -import { getSDKTheme } from "@/utils/sdk-component-theme"; import { bridgeAppThirdwebClient } from "../../constants"; export function UniversalBridgeEmbed({ @@ -16,16 +14,17 @@ export function UniversalBridgeEmbed({ token: TokenInfo | undefined; amount?: string; }) { - const { theme } = useTheme(); const chain = useV5DashboardChain(chainId || 1); return ( - +
+ +
); } diff --git a/apps/dashboard/src/app/bridge/page.tsx b/apps/dashboard/src/app/bridge/page.tsx index 12eddf3ce08..6930e98679c 100644 --- a/apps/dashboard/src/app/bridge/page.tsx +++ b/apps/dashboard/src/app/bridge/page.tsx @@ -49,7 +49,7 @@ export default async function BridgePage({ } return ( -
+
@@ -79,17 +79,17 @@ export default async function BridgePage({
-

- Get Started with thirdweb Payments +

+ Get Started with thirdweb Bridge

- Simple, instant, and secure payments across any token and - chain. + thirdweb Bridge allows developers to easily bridge and swap + tokens between chains and wallets.

diff --git a/apps/playground-web/src/app/payments/components/LeftSection.tsx b/apps/playground-web/src/app/payments/components/LeftSection.tsx index f61fb4ca55f..0bcc4dc72e3 100644 --- a/apps/playground-web/src/app/payments/components/LeftSection.tsx +++ b/apps/playground-web/src/app/payments/components/LeftSection.tsx @@ -27,6 +27,7 @@ import { 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 { CollapsibleSection } from "../../wallets/sign-in/components/CollapsibleSection"; import { ColorFormGroup } from "../../wallets/sign-in/components/ColorFormGroup"; import type { BridgeComponentsPlaygroundOptions } from "./types"; @@ -155,7 +156,7 @@ export function LeftSection(props: { ...v, payOptions: { ...v.payOptions, - currency: value, + currency: value as SupportedFiatCurrency, }, })); }} diff --git a/apps/playground-web/src/app/payments/components/types.ts b/apps/playground-web/src/app/payments/components/types.ts index c509a255042..6b8f6b57137 100644 --- a/apps/playground-web/src/app/payments/components/types.ts +++ b/apps/playground-web/src/app/payments/components/types.ts @@ -28,7 +28,7 @@ const CURRENCIES = [ "ISK", ] as const; -type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {}); +type SupportedFiatCurrency = (typeof CURRENCIES)[number]; export type BridgeComponentsPlaygroundOptions = { theme: { diff --git a/packages/thirdweb/package.json b/packages/thirdweb/package.json index 8811e4ee079..e11805dba72 100644 --- a/packages/thirdweb/package.json +++ b/packages/thirdweb/package.json @@ -341,6 +341,7 @@ "test:ui": "NODE_OPTIONS=--max-old-space-size=8192 vitest dev -c ./test/vitest.config.ts --coverage --ui", "test:watch": "vitest -c ./test/vitest.config.ts dev", "typedoc": "node scripts/typedoc.mjs && node scripts/parse.mjs", + "typecheck": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --noEmit", "update-version": "node scripts/version.mjs" }, "sideEffects": false, diff --git a/packages/thirdweb/src/bridge/types/Chain.ts b/packages/thirdweb/src/bridge/types/Chain.ts index a3208126f8b..6e013f1e4bb 100644 --- a/packages/thirdweb/src/bridge/types/Chain.ts +++ b/packages/thirdweb/src/bridge/types/Chain.ts @@ -38,3 +38,5 @@ export interface Chain { decimals: number; }; } + +export type BridgeChain = Chain; diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index c267cd95716..fdd9b8bfb1f 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -141,6 +141,10 @@ export { CheckoutWidget, type CheckoutWidgetProps, } from "../react/web/ui/Bridge/CheckoutWidget.js"; +export { + SwapWidget, + type SwapWidgetProps, +} from "../react/web/ui/Bridge/swap-widget/SwapWidget.js"; export { TransactionWidget, type TransactionWidgetProps, diff --git a/packages/thirdweb/src/pay/convert/type.ts b/packages/thirdweb/src/pay/convert/type.ts index 62dd76cc0e0..7db0394ed72 100644 --- a/packages/thirdweb/src/pay/convert/type.ts +++ b/packages/thirdweb/src/pay/convert/type.ts @@ -1,48 +1,31 @@ -const CURRENCIES = [ - "USD", - "EUR", - "GBP", - "JPY", - "KRW", - "CNY", - "INR", - "NOK", - "SEK", - "CHF", - "AUD", - "CAD", - "NZD", - "MXN", - "BRL", - "CLP", - "CZK", - "DKK", - "HKD", - "HUF", - "IDR", - "ILS", - "ISK", -] as const; +const currencySymbol = { + USD: "$", + EUR: "€", + GBP: "£", + JPY: "¥", + KRW: "₩", + CNY: "¥", + INR: "₹", + NOK: "kr", + SEK: "kr", + CHF: "CHF", + AUD: "$", + CAD: "$", + NZD: "$", + MXN: "$", + BRL: "R$", + CLP: "$", + CZK: "Kč", + DKK: "kr", + HKD: "$", + HUF: "Ft", + IDR: "Rp", + ILS: "₪", + ISK: "kr", +} as const; -export type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {}); +export type SupportedFiatCurrency = keyof typeof currencySymbol; export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) { - switch (showBalanceInFiat) { - case "USD": - return "$"; - case "CAD": - return "$"; - case "GBP": - return "£"; - case "EUR": - return "€"; - case "JPY": - return "¥"; - case "AUD": - return "$"; - case "NZD": - return "$"; - default: - return "$"; - } + return currencySymbol[showBalanceInFiat] || "$"; } diff --git a/packages/thirdweb/src/react/core/design-system/index.ts b/packages/thirdweb/src/react/core/design-system/index.ts index a0484032317..023270dde16 100644 --- a/packages/thirdweb/src/react/core/design-system/index.ts +++ b/packages/thirdweb/src/react/core/design-system/index.ts @@ -136,7 +136,7 @@ function createThemeObj(type: "dark" | "light", colors: ThemeColors): Theme { selectedTextColor: colors.base1, separatorLine: colors.base4, - skeletonBg: colors.base3, + skeletonBg: colors.base4, success: colors.success, tertiaryBg: colors.base2, @@ -177,6 +177,7 @@ export const spacing = { "4xs": "2px", lg: "24px", md: "16px", + "md+": "20px", sm: "12px", xl: "32px", xs: "8px", @@ -191,6 +192,7 @@ export const radius = { xl: "20px", xs: "4px", xxl: "32px", + full: "9999px", }; export const iconSize = { diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts index 9ba1fe64dbd..f84b92d9b9d 100644 --- a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts +++ b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts @@ -124,7 +124,10 @@ export function usePaymentMethods(options: { ), ) : sufficientBalanceQuotes; - return finalQuotes; + return finalQuotes.map((x) => ({ + ...x, + action: "buy", + })); }, queryKey: [ "payment-methods", diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.ts index 624cef48f0f..30c0c2c4b5e 100644 --- a/packages/thirdweb/src/react/core/machines/paymentMachine.ts +++ b/packages/thirdweb/src/react/core/machines/paymentMachine.ts @@ -22,6 +22,7 @@ type PaymentMode = "fund_wallet" | "direct_payment" | "transaction"; export type PaymentMethod = | { type: "wallet"; + action: "buy" | "sell"; payerWallet: Wallet; originToken: TokenWithPrices; balance: bigint; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx index 11fedee7610..5b90dcd6a26 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx @@ -128,7 +128,7 @@ export type BuyWidgetProps = { amount: string; /** - * The title to display in the widget. + * The title to display in the widget. If `title` is explicity set to an empty string, the title will not be displayed. */ title?: string; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx index 21725384b6f..3cfd3ff82fa 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx @@ -100,7 +100,7 @@ export function DirectPayment({ size="xl" token={uiOptions.paymentInfo.token} tokenAmount={uiOptions.paymentInfo.amount} - weight={700} + weight={600} />
) : null} - + ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx index 1b32798f8a7..cd7ab17e505 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx @@ -51,7 +51,7 @@ export function ErrorBanner({ }); return ( - + {/* Error Icon and Message */} @@ -251,11 +251,11 @@ export function FundWallet({ {/* Quick Amount Buttons */} {presetOptions && ( <> - + )} - + {receiver ? ( @@ -336,7 +337,7 @@ export function FundWallet({ }} style={{ fontSize: fontSize.md, - padding: `${spacing.sm} ${spacing.md}`, + borderRadius: radius.lg, }} variant="primary" > @@ -359,10 +360,10 @@ export function FundWallet({ {showThirdwebBranding ? (
- +
) : null} - + ); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/QuoteLoader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/QuoteLoader.tsx index 6186c9526dc..c3f67c6b9db 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/QuoteLoader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/QuoteLoader.tsx @@ -163,7 +163,7 @@ export function QuoteLoader({ - Finding the best route... + Finding the best route @@ -229,7 +229,10 @@ function getBridgeParams(args: { } return { - amount: toUnits(amount, destinationToken.decimals), + amount: + paymentMethod.action === "buy" + ? toUnits(amount, destinationToken.decimals) + : toUnits(amount, paymentMethod.originToken.decimals), client, destinationChainId: destinationToken.chainId, destinationTokenAddress: destinationToken.address, @@ -241,7 +244,7 @@ function getBridgeParams(args: { receiver, sender: sender || paymentMethod.payerWallet.getAccount()?.address || receiver, - type: "buy", + type: paymentMethod.action, }; } } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index fa8a9c6c8bb..0bc9057ab30 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -25,6 +25,7 @@ import { Spinner } from "../components/Spinner.js"; import { Text } from "../components/text.js"; interface StepRunnerProps { + title?: string; request: BridgePrepareRequest; /** @@ -64,6 +65,7 @@ interface StepRunnerProps { } export function StepRunner({ + title, request, wallet, client, @@ -247,8 +249,8 @@ export function StepRunner({ }; return ( - - + + @@ -374,7 +376,7 @@ export function StepRunner({ - + Keep this window open until all
transactions are complete.
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx index a2ed5950409..3474ac665d0 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx @@ -194,7 +194,7 @@ export function TransactionPayment({ }} > {/* USD Value */} - + {transactionDataQuery.data?.usdValueDisplay || transactionDataQuery.data?.txCostDisplay} @@ -431,7 +431,7 @@ export function TransactionPayment({
) : null} - + ); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx b/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx index 260f4a171a2..ada040f9eab 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx @@ -64,7 +64,7 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) { size="sm" style={{ lineHeight: 1.5, maxWidth: "280px" }} > - The Universal Bridge does not support testnets at this time. + Bridge does not support testnets at this time. ); @@ -94,7 +94,7 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) { size="sm" style={{ lineHeight: 1.5, maxWidth: "280px" }} > - This token or chain is not supported by the Universal Bridge. + This token or chain is not supported by the Bridge ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx index a8b7b1e2de1..c36dad4e7ec 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx @@ -60,7 +60,7 @@ export function TokenAndChain({ height: size === "lg" || size === "xl" ? iconSize.sm : iconSize.xs, position: "absolute", - right: "-6px", + right: "-2px", width: size === "lg" || size === "xl" ? iconSize.sm : iconSize.xs, }} > @@ -73,7 +73,7 @@ export function TokenAndChain({ )} - + {token.name} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx index 85cb46fdd06..e93a4c871cd 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx @@ -19,6 +19,8 @@ export function WithHeader({ client: ThirdwebClient; }) { const theme = useCustomTheme(); + const showTitle = uiOptions.metadata?.title !== ""; + return ( {/* image */} @@ -36,25 +38,33 @@ export function WithHeader({ }} /> )} - - - {/* title */} - - {uiOptions.metadata?.title || defaultTitle} - + + - {/* Description */} - {uiOptions.metadata?.description && ( + {(showTitle || uiOptions.metadata?.description) && ( <> - - - {uiOptions.metadata?.description} - + {/* title */} + {showTitle && ( + + {uiOptions.metadata?.title || defaultTitle} + + )} + + {/* Description */} + {uiOptions.metadata?.description && ( + <> + + + {uiOptions.metadata?.description} + + + )} + + )} - {children} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index 1ae362cafcc..cd48413414f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -21,6 +21,9 @@ import type { UIOptions } from "../BridgeOrchestrator.js"; import { PaymentOverview } from "./PaymentOverview.js"; export interface PaymentDetailsProps { + title?: string; + confirmButtonLabel?: string; + /** * The UI mode to use */ @@ -55,6 +58,8 @@ export interface PaymentDetailsProps { } export function PaymentDetails({ + title, + confirmButtonLabel, uiOptions, client, paymentMethod, @@ -185,6 +190,43 @@ export function PaymentDetails({ : undefined, }; } + + case "sell": { + const method = + paymentMethod.type === "wallet" ? paymentMethod : undefined; + if (!method) { + // can never happen + onError(new Error("Invalid payment method")); + return { + destinationAmount: "0", + destinationToken: undefined, + estimatedTime: 0, + originAmount: "0", + originToken: undefined, + }; + } + + return { + destinationAmount: formatTokenAmount( + preparedQuote.destinationAmount, + preparedQuote.steps[preparedQuote.steps.length - 1] + ?.destinationToken?.decimals ?? 18, + ), + destinationToken: + preparedQuote.steps[preparedQuote.steps.length - 1] + ?.destinationToken, + estimatedTime: preparedQuote.estimatedExecutionTimeMs, + originAmount: formatTokenAmount( + preparedQuote.originAmount, + method.originToken.decimals, + ), + originToken: + paymentMethod.type === "wallet" + ? paymentMethod.originToken + : undefined, + }; + } + case "onramp": { const method = paymentMethod.type === "fiat" ? paymentMethod : undefined; @@ -215,7 +257,7 @@ export function PaymentDetails({ } default: { throw new Error( - `Unsupported bridge prepare type: ${preparedQuote.type}`, + `Unsupported bridge prepare type: ${(preparedQuote as unknown as { type: string }).type}`, ); } } @@ -224,10 +266,10 @@ export function PaymentDetails({ const displayData = getDisplayData(); return ( - - + + - + {/* Quote Summary */} @@ -390,7 +432,7 @@ export function PaymentDetails({ {/* Action Buttons */} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx index 1269a2a6bfd..84e763f986e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx @@ -79,9 +79,15 @@ export function FiatProviderSelection({ return quoteQueries.map((q) => q.data).filter((q) => !!q); }, [quoteQueries]); + const isPending = quoteQueries.some((q) => q.isLoading); + if (quoteQueries.every((q) => q.isError)) { return ( - + No quotes available @@ -92,11 +98,17 @@ export function FiatProviderSelection({ // TODO: add a "remember my choice" checkbox return ( - - {quotes.length > 0 ? ( + + {!isPending ? ( quotes .sort((a, b) => a.currencyAmount - b.currencyAmount) - .map((quote, index) => { + .map((quote) => { const provider = PROVIDERS.find( (p) => p.id === quote.intent.onramp, ); @@ -105,98 +117,92 @@ export function FiatProviderSelection({ } return ( - onProviderSelected(provider.id)} style={{ - animationDelay: `${index * 100}ms`, + border: `1px solid ${theme.colors.borderColor}`, + borderRadius: radius.md, + textAlign: "left", }} + variant="secondary" > - - + + ); }) ) : ( - - - - - Generating quotes... + + + + + Searching Providers + + + + Searching for the best providers for this payment )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx index 37e420659f7..db86c04755e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx @@ -125,9 +125,16 @@ export function PaymentSelection({ const connectedWallets = useConnectedWallets(); const activeWallet = useActiveWallet(); - const [currentStep, setCurrentStep] = useState({ - type: "walletSelection", - }); + const initialStep = + paymentMethods.length === 1 && paymentMethods[0] === "card" + ? { + type: "fiatProviderSelection" as const, + } + : { + type: "walletSelection" as const, + }; + + const [currentStep, setCurrentStep] = useState(initialStep); useQuery({ queryFn: () => { @@ -226,6 +233,9 @@ export function PaymentSelection({ }; const getBackHandler = () => { + if (paymentMethods.length === 1 && paymentMethods[0] === "card") { + return onBack; + } switch (currentStep.type) { case "walletSelection": return onBack; @@ -270,12 +280,11 @@ export function PaymentSelection({ } return ( - + + - - - + {currentStep.type === "walletSelection" && ( {paymentMethods.includes("crypto") && ( <> - - Pay with Crypto - - + {paymentMethods.length > 1 && ( + <> + + Pay with Crypto + + + + )} + {/* Connected Wallets */} {connectedWallets.length > 0 && ( <> @@ -134,13 +139,13 @@ export function WalletFiatSelection({ {paymentMethods.includes("card") && ( <> - + - + Pay with Card - + + ); +} 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 new file mode 100644 index 00000000000..cd9989c91bc --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -0,0 +1,517 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { Buy, Sell } from "../../../../../bridge/index.js"; +import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; +import { getAddress } from "../../../../../utils/address.js"; +import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js"; +import type { Theme } from "../../../../core/design-system/index.js"; +import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js"; +import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecutor.js"; +import { webWindowAdapter } from "../../../adapters/WindowAdapter.js"; +import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js"; +import { DynamicHeight } from "../../components/DynamicHeight.js"; +import { ErrorBanner } from "../ErrorBanner.js"; +import { PaymentDetails } from "../payment-details/PaymentDetails.js"; +import { SuccessScreen } from "../payment-success/SuccessScreen.js"; +import { StepRunner } from "../StepRunner.js"; +import { useActiveWalletInfo } from "./hooks.js"; +import { getLastUsedTokens, setLastUsedTokens } from "./storage.js"; +import { SwapUI } from "./swap-ui.js"; +import type { + SwapPreparedQuote, + SwapWidgetConnectOptions, + TokenSelection, +} from "./types.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; + +export type SwapWidgetProps = { + /** + * A client is the entry point to the thirdweb SDK. + * It is required for all other actions. + * You can create a client using the `createThirdwebClient` function. Refer to the [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information. + * + * You must provide a `clientId` or `secretKey` in order to initialize a client. Pass `clientId` if you want for client-side usage and `secretKey` for server-side usage. + * + * ```tsx + * import { createThirdwebClient } from "thirdweb"; + * + * const client = createThirdwebClient({ + * clientId: "", + * }) + * ``` + */ + client: ThirdwebClient; + /** + * The prefill Buy and/or Sell tokens for the swap widget. If `tokenAddress` is not provided, the native token will be used + * + * @example + * + * ### Set an ERC20 token as the buy token + * ```ts + * + * ``` + * + * ### Set a native token as the sell token + * + * ```ts + * + * ``` + * + * ### Set 0.1 Base USDC as the buy token + * ```ts + * + * ``` + * + * ### Set Base USDC as the buy token and Base native token as the sell token + * ```ts + * + * ``` + */ + prefill?: { + buyToken?: { + tokenAddress?: string; + chainId: number; + amount?: string; + }; + sellToken?: { + tokenAddress?: string; + chainId: number; + amount?: string; + }; + }; + /** + * Set the theme for the `SwapWidget` component. By default it is set to `"dark"` + * + * theme can be set to either `"dark"`, `"light"` or a custom theme object. + * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme) + * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) + * functions from `thirdweb/react` to use the default themes as base and overrides parts of it. + * @example + * ```ts + * import { lightTheme } from "thirdweb/react"; + * + * const customTheme = lightTheme({ + * colors: { + * modalBg: 'red' + * } + * }) + * + * function Example() { + * return + * } + * ``` + */ + theme?: "light" | "dark" | Theme; + /** + * The currency to use for the payment. + * @default "USD" + */ + currency?: SupportedFiatCurrency; + connectOptions?: SwapWidgetConnectOptions; + /** + * Whether to show thirdweb branding in the widget. + * @default true + */ + showThirdwebBranding?: boolean; + /** + * Callback to be called when the swap is successful. + */ + onSuccess?: (quote: SwapPreparedQuote) => void; + /** + * Callback to be called when user encounters an error when swapping. + */ + onError?: (error: Error, quote: SwapPreparedQuote) => void; + /** + * Callback to be called when the user cancels the purchase. + */ + onCancel?: (quote: SwapPreparedQuote) => void; + style?: React.CSSProperties; + className?: string; +}; + +/** + * A widget for swapping tokens with cross-chain support + * + * @param props - Props of type [`SwapWidgetProps`](https://portal.thirdweb.com/references/typescript/v5/SwapWidgetProps) to configure the SwapWidget component. + * + * @example + * ### Basic usage + * + * By default, no tokens are selected in the widget UI. + * + * You can set specific tokens to buy or sell by default by passing the `prefill` prop. User can change these selections in the widget UI. + * + * ```tsx + * + * ``` + * + * ### Set an ERC20 token to Buy by default + * + * ```tsx + * + * ``` + * + * ### Set a native token to Sell by default + * + * By not specifying a `tokenAddress`, the native token will be used. + * + * ```tsx + * + * ``` + * + * ### Set amount and token to Buy by default + * + * ```tsx + * + * ``` + * + * ### Set both buy and sell tokens by default + * + * ```tsx + * + * ``` + * + */ +export function SwapWidget(props: SwapWidgetProps) { + return ( + + + + ); +} + +/** + * @internal + */ +export function SwapWidgetContainer(props: { + theme: SwapWidgetProps["theme"]; + className: string | undefined; + style?: React.CSSProperties | undefined; + children: React.ReactNode; +}) { + return ( + + + {props.children} + + + ); +} + +type SelectionInfo = { + preparedQuote: SwapPreparedQuote; + request: BridgePrepareRequest; + quote: Buy.quote.Result | Sell.quote.Result; + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; + sellTokenBalance: bigint; + mode: "buy" | "sell"; +}; + +type Join = T & U; + +type SwapWidgetScreen = + | { id: "1:swap-ui" } + | Join<{ id: "2:preview" }, SelectionInfo> + | Join<{ id: "3:execute" }, SelectionInfo> + | Join< + { + id: "4:success"; + completedStatuses: CompletedStatusResult[]; + }, + SelectionInfo + > + | { id: "error"; error: Error; preparedQuote: SwapPreparedQuote }; + +function SwapWidgetContent(props: SwapWidgetProps) { + const [screen, setScreen] = useState({ id: "1:swap-ui" }); + const activeWalletInfo = useActiveWalletInfo(); + + const [amountSelection, setAmountSelection] = useState<{ + type: "buy" | "sell"; + amount: string; + }>(() => { + if (props.prefill?.buyToken?.amount) { + return { + type: "buy", + amount: props.prefill.buyToken.amount, + }; + } + if (props.prefill?.sellToken?.amount) { + return { + type: "sell", + amount: props.prefill.sellToken.amount, + }; + } + return { + type: "buy", + amount: "", + }; + }); + + const [buyToken, setBuyToken] = useState(() => { + if (props.prefill?.buyToken) { + return { + tokenAddress: + props.prefill.buyToken.tokenAddress || + getAddress(NATIVE_TOKEN_ADDRESS), + chainId: props.prefill.buyToken.chainId, + }; + } + const last = getLastUsedTokens()?.buyToken; + if (last) { + return { + tokenAddress: getAddress(last.tokenAddress), + chainId: last.chainId, + }; + } + return undefined; + }); + + const [sellToken, setSellToken] = useState(() => { + if (props.prefill?.sellToken) { + return { + tokenAddress: + props.prefill.sellToken.tokenAddress || + getAddress(NATIVE_TOKEN_ADDRESS), + chainId: props.prefill.sellToken.chainId, + }; + } + const last = getLastUsedTokens()?.sellToken; + if (last) { + return { + tokenAddress: getAddress(last.tokenAddress), + chainId: last.chainId, + }; + } + return undefined; + }); + + // persist selections to localStorage whenever they change + useEffect(() => { + setLastUsedTokens({ buyToken, sellToken }); + }, [buyToken, sellToken]); + + // preload requests + useBridgeChains(props.client); + + const handleError = useCallback( + (error: Error, quote: SwapPreparedQuote) => { + console.error(error); + props.onError?.(error, quote); + setScreen({ + id: "error", + preparedQuote: quote, + error, + }); + }, + [props.onError], + ); + + // if wallet suddenly disconnects, show screen 1 + if (screen.id === "1:swap-ui" || !activeWalletInfo) { + return ( + { + setScreen({ + id: "2:preview", + buyToken: data.buyToken, + sellToken: data.sellToken, + sellTokenBalance: data.sellTokenBalance, + mode: data.mode, + preparedQuote: data.result, + request: data.request, + quote: data.result, + }); + }} + /> + ); + } + + if (screen.id === "2:preview") { + return ( + { + setScreen({ id: "1:swap-ui" }); + }} + onConfirm={() => { + setScreen({ + ...screen, + id: "3:execute", + }); + }} + onError={(error) => handleError(error, screen.preparedQuote)} + paymentMethod={{ + quote: screen.quote, + type: "wallet", + payerWallet: activeWalletInfo.activeWallet, + balance: screen.sellTokenBalance, + originToken: screen.sellToken, + action: screen.mode, + }} + preparedQuote={screen.preparedQuote} + uiOptions={{ + destinationToken: screen.buyToken, + mode: "fund_wallet", + currency: props.currency, + }} + /> + ); + } + + if (screen.id === "3:execute") { + return ( + { + setScreen({ + ...screen, + id: "2:preview", + sellTokenBalance: screen.sellTokenBalance, + }); + }} + onCancel={() => props.onCancel?.(screen.preparedQuote)} + onComplete={(completedStatuses) => { + props.onSuccess?.(screen.preparedQuote); + setScreen({ + ...screen, + id: "4:success", + completedStatuses, + }); + }} + request={screen.request} + wallet={activeWalletInfo.activeWallet} + windowAdapter={webWindowAdapter} + /> + ); + } + + if (screen.id === "4:success") { + return ( + { + setScreen({ id: "1:swap-ui" }); + // clear amounts + setAmountSelection({ + type: "buy", + amount: "", + }); + }} + preparedQuote={screen.preparedQuote} + uiOptions={{ + destinationToken: screen.buyToken, + mode: "fund_wallet", + currency: props.currency, + }} + windowAdapter={webWindowAdapter} + hasPaymentId={false} // TODO Question: Do we need to expose this as prop? + /> + ); + } + + if (screen.id === "error") { + return ( + { + setScreen({ id: "1:swap-ui" }); + props.onCancel?.(screen.preparedQuote); + }} + onRetry={() => { + setScreen({ id: "1:swap-ui" }); + }} + /> + ); + } + + return null; +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/common.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/common.tsx new file mode 100644 index 00000000000..1828c91a808 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/common.tsx @@ -0,0 +1,35 @@ +import type { Theme } from "../../../../core/design-system/index.js"; +import { Text } from "../../components/text.js"; + +export function DecimalRenderer(props: { + value: string; + color: keyof Theme["colors"]; + weight: 400 | 500 | 600 | 700; + integerSize: "md" | "sm"; + fractionSize: "sm" | "xs"; +}) { + if (Number(props.value) > 1000) { + return ( + + {compactFormatter.format(Number(props.value))} + + ); + } + const [integerPart, fractionPart] = props.value.split("."); + + return ( +
+ + {integerPart} + + + .{fractionPart || "00"} + +
+ ); +} + +const compactFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 2, +}); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts new file mode 100644 index 00000000000..5555f3e3dd8 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js"; +import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js"; +import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js"; +import type { ActiveWalletInfo } from "./types.js"; + +export function useActiveWalletInfo(): ActiveWalletInfo | undefined { + const activeAccount = useActiveAccount(); + const activeWallet = useActiveWallet(); + const activeChain = useActiveWalletChain(); + + return useMemo(() => { + return activeAccount && activeWallet && activeChain + ? { + activeChain, + activeWallet, + activeAccount, + } + : undefined; + }, [activeAccount, activeWallet, activeChain]); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx new file mode 100644 index 00000000000..270b5e8e927 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import type { Chain as BridgeChain } from "../../../../../bridge/index.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { + fontSize, + iconSize, + spacing, +} from "../../../../core/design-system/index.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Img } from "../../components/Img.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Text } from "../../components/text.js"; +import { SearchInput } from "./SearchInput.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { cleanedChainName } from "./utils.js"; + +type SelectBuyTokenProps = { + onBack: () => void; + client: ThirdwebClient; + onSelectChain: (chain: BridgeChain) => void; + selectedChain: BridgeChain | undefined; +}; + +/** + * @internal + */ +export function SelectBridgeChain(props: SelectBuyTokenProps) { + const chainQuery = useBridgeChains(props.client); + + return ( + + ); +} + +/** + * @internal + */ +export function SelectBridgeChainUI( + props: SelectBuyTokenProps & { + isPending: boolean; + chains: BridgeChain[]; + onSelectChain: (chain: BridgeChain) => void; + selectedChain: BridgeChain | undefined; + }, +) { + const [search, setSearch] = useState(""); + const filteredChains = props.chains.filter((chain) => { + return chain.name.toLowerCase().includes(search.toLowerCase()); + }); + + return ( +
+ + + + + + + + + + + + + + {filteredChains.map((chain) => ( + props.onSelectChain(chain)} + isSelected={chain.chainId === props.selectedChain?.chainId} + /> + ))} + + {props.isPending && + new Array(20).fill(0).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {filteredChains.length === 0 && !props.isPending && ( +
+ + No chains found for "{search}" + +
+ )} +
+
+ ); +} + +function ChainButtonSkeleton() { + return ( +
+ + +
+ ); +} + +function ChainButton(props: { + chain: BridgeChain; + client: ThirdwebClient; + onClick: () => void; + isSelected: boolean; +}) { + return ( + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx new file mode 100644 index 00000000000..a96362495c8 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx @@ -0,0 +1,528 @@ +import { DiscIcon } from "@radix-ui/react-icons"; +import { useMemo, useState } from "react"; +import type { Token } from "../../../../../bridge/index.js"; +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { toTokens } from "../../../../../utils/units.js"; +import { + fontSize, + iconSize, + radius, + spacing, +} from "../../../../core/design-system/index.js"; +import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js"; +import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; +import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Img } from "../../components/Img.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Spinner } from "../../components/Spinner.js"; +import { Text } from "../../components/text.js"; +import { DecimalRenderer } from "./common.js"; +import { SearchInput } from "./SearchInput.js"; +import { SelectChainButton } from "./SelectChainButton.js"; +import { SelectBridgeChain } from "./select-chain.js"; +import type { ActiveWalletInfo, TokenSelection } from "./types.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { + type TokenBalance, + useTokenBalances, + useTokens, +} from "./use-tokens.js"; + +/** + * @internal + */ +type SelectTokenUIProps = { + onBack: () => void; + client: ThirdwebClient; + selectedToken: TokenSelection | undefined; + setSelectedToken: (token: TokenSelection) => void; + activeWalletInfo: ActiveWalletInfo | undefined; +}; + +function getDefaultSelectedChain( + chains: BridgeChain[], + activeChainId: number | undefined, +) { + return chains.find((chain) => chain.chainId === (activeChainId || 1)); +} + +/** + * @internal + */ +export function SelectToken(props: SelectTokenUIProps) { + const chainQuery = useBridgeChains(props.client); + const [search, setSearch] = useState(""); + const [limit, setLimit] = useState(1000); + + const [_selectedChain, setSelectedChain] = useState( + undefined, + ); + const selectedChain = + _selectedChain || + (chainQuery.data + ? getDefaultSelectedChain( + chainQuery.data, + props.selectedToken?.chainId || + props.activeWalletInfo?.activeChain.id, + ) + : undefined); + + // all tokens + const tokensQuery = useTokens({ + client: props.client, + chainId: selectedChain?.chainId, + search, + limit, + offset: 0, + }); + + // owned tokens + const ownedTokensQuery = useTokenBalances({ + clientId: props.client.clientId, + chainId: selectedChain?.chainId, + limit, + page: 1, + walletAddress: props.activeWalletInfo?.activeAccount.address, + }); + + const filteredOwnedTokens = useMemo(() => { + return ownedTokensQuery.data?.tokens?.filter((token) => { + return ( + token.symbol.toLowerCase().includes(search.toLowerCase()) || + token.name.toLowerCase().includes(search.toLowerCase()) || + token.token_address.toLowerCase().includes(search.toLowerCase()) + ); + }); + }, [ownedTokensQuery.data?.tokens, search]); + + const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching; + + return ( + { + setLimit(limit * 2); + } + : undefined + } + /> + ); +} + +function SelectTokenUI( + props: SelectTokenUIProps & { + ownedTokens: TokenBalance[]; + allTokens: Token[]; + isFetching: boolean; + selectedChain: BridgeChain | undefined; + setSelectedChain: (chain: BridgeChain) => void; + search: string; + setSearch: (search: string) => void; + selectedToken: TokenSelection | undefined; + setSelectedToken: (token: TokenSelection) => void; + showMore: (() => void) | undefined; + }, +) { + const [screen, setScreen] = useState<"select-chain" | "select-token">( + "select-token", + ); + + // show tokens with icons first + const sortedOwnedTokens = useMemo(() => { + return props.ownedTokens.sort((a, b) => { + if (a.icon_uri && !b.icon_uri) { + return -1; + } + if (!a.icon_uri && b.icon_uri) { + return 1; + } + return 0; + }); + }, [props.ownedTokens]); + + const otherTokens = useMemo(() => { + const ownedTokenSet = new Set( + sortedOwnedTokens.map((t) => + `${t.token_address}-${t.chain_id}`.toLowerCase(), + ), + ); + return props.allTokens.filter( + (token) => + !ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()), + ); + }, [props.allTokens, sortedOwnedTokens]); + + // show tokens with icons first + const sortedOtherTokens = useMemo(() => { + return otherTokens.sort((a, b) => { + if (a.iconUri && !b.iconUri) { + return -1; + } + if (!a.iconUri && b.iconUri) { + return 1; + } + return 0; + }); + }, [otherTokens]); + + const noTokensFound = + !props.isFetching && + sortedOtherTokens.length === 0 && + props.ownedTokens.length === 0; + + if (screen === "select-token") { + return ( + + + + + + + {!props.selectedChain && ( +
+ +
+ )} + + {props.selectedChain && ( + <> + + setScreen("select-chain")} + selectedChain={props.selectedChain} + client={props.client} + /> + + + {/* search */} + + + + + + + + {props.isFetching && + new Array(20).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} + + {!props.isFetching && sortedOwnedTokens.length > 0 && ( + + + + Your Tokens + + + )} + + {!props.isFetching && + sortedOwnedTokens.map((token) => ( + + ))} + + {!props.isFetching && sortedOwnedTokens.length > 0 && ( + + + + Other Tokens + + + )} + + {!props.isFetching && + sortedOtherTokens.map((token) => ( + + ))} + + {props.showMore && ( + + )} + + {noTokensFound && ( +
+ + No Tokens Found + +
+ )} +
+
+ + )} +
+ ); + } + + if (screen === "select-chain") { + return ( + setScreen("select-token")} + client={props.client} + onSelectChain={(chain) => { + props.setSelectedChain(chain); + setScreen("select-token"); + }} + selectedChain={props.selectedChain} + /> + ); + } + + return null; +} + +function TokenButtonSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +function TokenButton(props: { + token: TokenBalance | Token; + client: ThirdwebClient; + onSelect: (tokenWithPrices: TokenSelection) => void; + isSelected: boolean; +}) { + const tokenBalanceInUnits = + "balance" in props.token + ? toTokens(BigInt(props.token.balance), props.token.decimals) + : undefined; + const usdValue = + "balance" in props.token + ? props.token.price_data.price_usd * Number(tokenBalanceInUnits) + : undefined; + + return ( + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts new file mode 100644 index 00000000000..28bce3ad55a --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +const tokenSelectionSchema = z.object({ + tokenAddress: z.string().min(1), + chainId: z.number().int().positive(), +}); + +const lastUsedTokensSchema = z.object({ + buyToken: tokenSelectionSchema.optional(), + sellToken: tokenSelectionSchema.optional(), +}); + +type LastUsedTokens = z.infer; + +const STORAGE_KEY = "tw.swap.lastUsedTokens"; + +function isBrowser() { + return ( + typeof window !== "undefined" && typeof window.localStorage !== "undefined" + ); +} + +export function getLastUsedTokens(): LastUsedTokens | undefined { + if (!isBrowser()) { + return undefined; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return undefined; + } + const parsed = JSON.parse(raw); + const result = lastUsedTokensSchema.safeParse(parsed); + if (!result.success) { + return undefined; + } + return result.data; + } catch { + return undefined; + } +} + +export function setLastUsedTokens(update: LastUsedTokens): void { + if (!isBrowser()) { + return; + } + try { + const result = lastUsedTokensSchema.safeParse(update); + if (!result.success) { + return; + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(result.data)); + } catch { + // ignore write errors + } +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx new file mode 100644 index 00000000000..8212e230a74 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -0,0 +1,949 @@ +import styled from "@emotion/styled"; +import { + ChevronDownIcon, + ChevronRightIcon, + DiscIcon, +} from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import type { prepare as BuyPrepare } from "../../../../../bridge/Buy.js"; +import { Buy, Sell } from "../../../../../bridge/index.js"; +import type { prepare as SellPrepare } from "../../../../../bridge/Sell.js"; +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; +import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; +import { defineChain } from "../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getToken } from "../../../../../pay/convert/get-token.js"; +import { + getFiatSymbol, + type SupportedFiatCurrency, +} from "../../../../../pay/convert/type.js"; +import { getAddress } from "../../../../../utils/address.js"; +import { toTokens, toUnits } from "../../../../../utils/units.js"; +import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; +import { + fontSize, + iconSize, + radius, + spacing, + type Theme, +} from "../../../../core/design-system/index.js"; +import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; +import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js"; +import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; +import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js"; +import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; +import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js"; +import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; +import { Container } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Input } from "../../components/formElements.js"; +import { Img } from "../../components/Img.js"; +import { Modal } from "../../components/Modal.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Text } from "../../components/text.js"; +import { DecimalRenderer } from "./common.js"; +import { SelectToken } from "./select-token-ui.js"; +import type { + ActiveWalletInfo, + SwapPreparedQuote, + SwapWidgetConnectOptions, + TokenSelection, +} from "./types.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { cleanedChainName } from "./utils.js"; + +type SwapUIProps = { + activeWalletInfo: ActiveWalletInfo | undefined; + client: ThirdwebClient; + theme: Theme | "light" | "dark"; + connectOptions: SwapWidgetConnectOptions | undefined; + currency: SupportedFiatCurrency; + showThirdwebBranding: boolean; + onSwap: (data: { + result: SwapPreparedQuote; + request: BridgePrepareRequest; + buyToken: TokenWithPrices; + sellTokenBalance: bigint; + sellToken: TokenWithPrices; + mode: "buy" | "sell"; + }) => void; + buyToken: TokenSelection | undefined; + sellToken: TokenSelection | undefined; + setBuyToken: (token: TokenSelection | undefined) => void; + setSellToken: (token: TokenSelection | undefined) => void; + amountSelection: { + type: "buy" | "sell"; + amount: string; + }; + setAmountSelection: (amountSelection: { + type: "buy" | "sell"; + amount: string; + }) => void; +}; + +function useTokenPrice(options: { + token: TokenSelection | undefined; + client: ThirdwebClient; +}) { + return useQuery({ + queryKey: ["token-price", options.token], + enabled: !!options.token, + queryFn: () => { + if (!options.token) { + throw new Error("Token is required"); + } + return getToken( + options.client, + options.token.tokenAddress, + options.token.chainId, + ); + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} + +/** + * @internal + */ +export function SwapUI(props: SwapUIProps) { + const [modalState, setModalState] = useState< + "select-buy-token" | "select-sell-token" | undefined + >(undefined); + + // Token Prices ---------------------------------------------------------------------------- + const buyTokenQuery = useTokenPrice({ + token: props.buyToken, + client: props.client, + }); + const sellTokenQuery = useTokenPrice({ + token: props.sellToken, + client: props.client, + }); + + const buyTokenWithPrices = buyTokenQuery.data; + const sellTokenWithPrices = sellTokenQuery.data; + + // Swap Quote ---------------------------------------------------------------------------- + const preparedResultQuery = useSwapQuote({ + amountSelection: props.amountSelection, + buyTokenWithPrices: buyTokenWithPrices, + sellTokenWithPrices: sellTokenWithPrices, + activeWalletInfo: props.activeWalletInfo, + client: props.client, + }); + + // Amount and Amount.fetching ------------------------------------------------------------ + + const sellTokenAmount = + props.amountSelection.type === "sell" + ? props.amountSelection.amount + : preparedResultQuery.data && + props.amountSelection.type === "buy" && + sellTokenWithPrices + ? toTokens( + preparedResultQuery.data.result.originAmount, + sellTokenWithPrices.decimals, + ) + : ""; + + const buyTokenAmount = + props.amountSelection.type === "buy" + ? props.amountSelection.amount + : preparedResultQuery.data && + props.amountSelection.type === "sell" && + buyTokenWithPrices + ? toTokens( + preparedResultQuery.data.result.destinationAmount, + buyTokenWithPrices.decimals, + ) + : ""; + + // when buy amount is set, the sell amount is fetched + const isBuyAmountFetching = + props.amountSelection.type === "sell" && preparedResultQuery.isFetching; + const isSellAmountFetching = + props.amountSelection.type === "buy" && preparedResultQuery.isFetching; + + // token balances ------------------------------------------------------------ + const sellTokenBalanceQuery = useTokenBalance({ + chainId: sellTokenWithPrices?.chainId, + tokenAddress: sellTokenWithPrices?.address, + client: props.client, + walletAddress: props.activeWalletInfo?.activeAccount.address, + }); + + const buyTokenBalanceQuery = useTokenBalance({ + chainId: buyTokenWithPrices?.chainId, + tokenAddress: buyTokenWithPrices?.address, + client: props.client, + walletAddress: props.activeWalletInfo?.activeAccount.address, + }); + + const notEnoughBalance = !!( + sellTokenBalanceQuery.data && + sellTokenWithPrices && + props.amountSelection.amount && + !!sellTokenAmount && + sellTokenBalanceQuery.data.value < + Number(toUnits(sellTokenAmount, sellTokenWithPrices.decimals)) + ); + + // ---------------------------------------------------------------------------- + + return ( + + { + if (!v) { + setModalState(undefined); + } + }} + > + {modalState === "select-buy-token" && ( + setModalState(undefined)} + client={props.client} + selectedToken={props.buyToken} + setSelectedToken={(token) => { + props.setBuyToken(token); + setModalState(undefined); + // if buy token is same as sell token, unset sell token + if ( + props.sellToken && + token.tokenAddress.toLowerCase() === + props.sellToken.tokenAddress.toLowerCase() && + token.chainId === props.sellToken.chainId + ) { + props.setSellToken(undefined); + } + }} + /> + )} + + {modalState === "select-sell-token" && ( + setModalState(undefined)} + client={props.client} + selectedToken={props.sellToken} + setSelectedToken={(token) => { + props.setSellToken(token); + setModalState(undefined); + // if sell token is same as buy token, unset buy token + if ( + props.buyToken && + token.tokenAddress.toLowerCase() === + props.buyToken.tokenAddress.toLowerCase() && + token.chainId === props.buyToken.chainId + ) { + props.setBuyToken(undefined); + } + }} + activeWalletInfo={props.activeWalletInfo} + /> + )} + + + {/* Sell */} + { + props.setAmountSelection({ type: "sell", amount: value }); + }} + selectedToken={ + props.sellToken + ? { + data: sellTokenQuery.data, + isFetching: sellTokenQuery.isFetching, + } + : undefined + } + client={props.client} + currency={props.currency} + onSelectToken={() => setModalState("select-sell-token")} + /> + + {/* Switch */} + { + // switch tokens + const temp = props.sellToken; + props.setSellToken(props.buyToken); + props.setBuyToken(temp); + props.setAmountSelection({ + type: props.amountSelection.type === "buy" ? "sell" : "buy", + amount: props.amountSelection.amount, + }); + }} + /> + + {/* Buy */} + { + props.setAmountSelection({ type: "buy", amount: value }); + }} + client={props.client} + currency={props.currency} + onSelectToken={() => setModalState("select-buy-token")} + /> + + {/* error message */} + {preparedResultQuery.error ? ( + + Failed to get a quote + + ) : ( + + )} + + {/* Button */} + {!props.activeWalletInfo ? ( + + ) : ( + + )} + + {props.showThirdwebBranding ? ( +
+ + +
+ ) : null} +
+ ); +} + +function useSwapQuote(params: { + amountSelection: { + type: "buy" | "sell"; + amount: string; + }; + buyTokenWithPrices: TokenWithPrices | undefined; + sellTokenWithPrices: TokenWithPrices | undefined; + activeWalletInfo: ActiveWalletInfo | undefined; + client: ThirdwebClient; +}) { + const { + amountSelection, + buyTokenWithPrices, + sellTokenWithPrices, + activeWalletInfo, + client, + } = params; + + return useQuery({ + queryKey: [ + "swap-quote", + amountSelection, + buyTokenWithPrices, + sellTokenWithPrices, + activeWalletInfo?.activeAccount.address, + ], + retry: false, + enabled: + !!buyTokenWithPrices && !!sellTokenWithPrices && !!amountSelection.amount, + queryFn: async (): Promise< + | { + type: "preparedResult"; + result: SwapPreparedQuote; + request: Extract; + } + | { + type: "quote"; + result: Buy.quote.Result | Sell.quote.Result; + } + > => { + if ( + !buyTokenWithPrices || + !sellTokenWithPrices || + !amountSelection.amount + ) { + throw new Error("Invalid state"); + } + + if (!activeWalletInfo) { + if (amountSelection.type === "buy") { + const res = await Buy.quote({ + amount: toUnits( + amountSelection.amount, + buyTokenWithPrices.decimals, + ), + // origin = sell + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, + // destination = buy + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, + client: client, + }); + + return { + type: "quote", + result: res, + }; + } + + const res = await Sell.quote({ + amount: toUnits(amountSelection.amount, sellTokenWithPrices.decimals), + // origin = sell + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, + // destination = buy + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, + client: client, + }); + + return { + type: "quote", + result: res, + }; + } + + if (amountSelection.type === "buy") { + const buyRequestOptions: BuyPrepare.Options = { + amount: toUnits(amountSelection.amount, buyTokenWithPrices.decimals), + // origin = sell + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, + // destination = buy + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, + client: client, + receiver: activeWalletInfo.activeAccount.address, + sender: activeWalletInfo.activeAccount.address, + }; + + const buyRequest: BridgePrepareRequest = { + type: "buy", + ...buyRequestOptions, + }; + + const res = await Buy.prepare(buyRequest); + + return { + type: "preparedResult", + result: { type: "buy", ...res }, + request: buyRequest, + }; + } else if (amountSelection.type === "sell") { + const sellRequestOptions: SellPrepare.Options = { + amount: toUnits(amountSelection.amount, sellTokenWithPrices.decimals), + // origin = sell + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, + // destination = buy + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, + client: client, + receiver: activeWalletInfo.activeAccount.address, + sender: activeWalletInfo.activeAccount.address, + }; + + const res = await Sell.prepare(sellRequestOptions); + + const sellRequest: BridgePrepareRequest = { + type: "sell", + ...sellRequestOptions, + }; + + return { + type: "preparedResult", + result: { type: "sell", ...res }, + request: sellRequest, + }; + } + + throw new Error("Invalid amount selection type"); + }, + refetchInterval: 20000, + }); +} + +function DecimalInput(props: { + value: string; + setValue: (value: string) => void; +}) { + const handleAmountChange = (inputValue: string) => { + let processedValue = inputValue; + + // Replace comma with period if it exists + processedValue = processedValue.replace(",", "."); + + if (processedValue.startsWith(".")) { + processedValue = `0${processedValue}`; + } + + const numValue = Number(processedValue); + if (Number.isNaN(numValue)) { + return; + } + + if (processedValue.startsWith("0") && !processedValue.startsWith("0.")) { + props.setValue(processedValue.slice(1)); + } else { + props.setValue(processedValue); + } + }; + + return ( + { + handleAmountChange(e.target.value); + }} + onClick={(e) => { + // put cursor at the end of the input + if (props.value === "") { + e.currentTarget.setSelectionRange( + e.currentTarget.value.length, + e.currentTarget.value.length, + ); + } + }} + pattern="^[0-9]*[.,]?[0-9]*$" + placeholder="0.0" + style={{ + border: "none", + boxShadow: "none", + fontSize: fontSize.xxl, + fontWeight: 500, + paddingInline: 0, + paddingBlock: 0, + }} + type="text" + value={props.value} + variant="transparent" + /> + ); +} + +function TokenSection(props: { + label: string; + notEnoughBalance: boolean; + amount: { + data: string; + isFetching: boolean; + }; + setAmount: (amount: string) => void; + selectedToken: + | { + data: TokenWithPrices | undefined; + isFetching: boolean; + } + | undefined; + currency: SupportedFiatCurrency; + onSelectToken: () => void; + client: ThirdwebClient; + isConnected: boolean; + balance: { + data: bigint | undefined; + isFetching: boolean; + }; +}) { + const chainQuery = useBridgeChains(props.client); + const chain = chainQuery.data?.find( + (chain) => chain.chainId === props.selectedToken?.data?.chainId, + ); + + const fiatPricePerToken = props.selectedToken?.data?.prices[props.currency]; + const totalFiatValue = !props.amount.data + ? undefined + : fiatPricePerToken + ? fiatPricePerToken * Number(props.amount.data) + : undefined; + + return ( + + {/* row1 : label */} + + {props.label} + + + {/* row2 : amount and select token */} +
+ {props.amount.isFetching ? ( + + ) : ( + + )} + + {!props.selectedToken ? ( + + ) : ( + + )} +
+ + {/* row3 : fiat value/error and balance */} +
+ {/* Exceeds Balance / Fiat Value */} + {props.notEnoughBalance ? ( + + {" "} + Exceeds Balance{" "} + + ) : ( +
+ + {getFiatSymbol(props.currency)} + + {props.amount.isFetching ? ( + + ) : ( +
+ +
+ )} +
+ )} + + {/* Balance */} + {props.isConnected && props.selectedToken && ( +
+ {props.balance.data === undefined || + props.selectedToken.data === undefined ? ( + + ) : ( + + + + + )} +
+ )} +
+
+ ); +} + +function SelectedTokenButton(props: { + selectedToken: + | { + data: TokenWithPrices | undefined; + isFetching: boolean; + } + | undefined; + client: ThirdwebClient; + onSelectToken: () => void; + chain: BridgeChain | undefined; +}) { + return ( + + ); +} + +function SwitchButton(props: { onClick: () => void }) { + return ( +
+ { + props.onClick(); + const node = e.currentTarget.querySelector("svg"); + if (node) { + node.style.transform = "rotate(180deg)"; + node.style.transition = "transform 300ms ease"; + setTimeout(() => { + node.style.transition = ""; + node.style.transform = "rotate(0deg)"; + }, 300); + } + }} + > + + +
+ ); +} + +const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => { + const theme = useCustomTheme(); + return { + "&:hover": { + background: theme.colors.modalBg, + }, + borderRadius: radius.lg, + padding: spacing.xs, + background: theme.colors.modalBg, + border: `1px solid ${theme.colors.borderColor}`, + }; +}); + +function useTokenBalance(props: { + chainId: number | undefined; + tokenAddress: string | undefined; + client: ThirdwebClient; + walletAddress: string | undefined; +}) { + return useWalletBalance({ + address: props.walletAddress, + chain: props.chainId ? defineChain(props.chainId) : undefined, + client: props.client, + tokenAddress: props.tokenAddress + ? getAddress(props.tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS) + ? undefined + : getAddress(props.tokenAddress) + : undefined, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts new file mode 100644 index 00000000000..3012a5916b8 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts @@ -0,0 +1,157 @@ +import type { Chain } from "../../../../../chains/types.js"; +import type { + Account, + Wallet, +} from "../../../../../wallets/interfaces/wallet.js"; +import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js"; +import type { AppMetadata } from "../../../../../wallets/types.js"; +import type { SiweAuthOptions } from "../../../../core/hooks/auth/useSiweAuth.js"; +import type { ConnectButton_connectModalOptions } from "../../../../core/hooks/connection/ConnectButtonProps.js"; +import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js"; + +/** + * Connection options for the `SwapWidget` component + * + * @example + * ```tsx + * + * ``` + */ +export type SwapWidgetConnectOptions = { + /** + * Configurations for the `ConnectButton`'s Modal that is shown for connecting a wallet + * Refer to the [`ConnectButton_connectModalOptions`](https://portal.thirdweb.com/references/typescript/v5/ConnectButton_connectModalOptions) type for more details + */ + connectModal?: ConnectButton_connectModalOptions; + + /** + * Configure options for WalletConnect + * + * By default WalletConnect uses the thirdweb's default project id. + * Setting your own project id is recommended. + * + * You can create a project id by signing up on [walletconnect.com](https://walletconnect.com/) + */ + walletConnect?: { + projectId?: string; + }; + + /** + * Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options. + * + * This allows to sponsor gas fees for your user's transaction using the thirdweb account abstraction infrastructure. + * + */ + accountAbstraction?: SmartWalletOptions; + + /** + * Array of wallets to show in Connect Modal. If not provided, default wallets will be used. + */ + wallets?: Wallet[]; + /** + * When the user has connected their wallet to your site, this configuration determines whether or not you want to automatically connect to the last connected wallet when user visits your site again in the future. + * + * By default it is set to `{ timeout: 15000 }` meaning that autoConnect is enabled and if the autoConnection does not succeed within 15 seconds, it will be cancelled. + * + * If you want to disable autoConnect, set this prop to `false`. + * + * If you want to customize the timeout, you can assign an object with a `timeout` key to this prop. + */ + autoConnect?: + | { + timeout: number; + } + | boolean; + + /** + * Metadata of the app that will be passed to connected wallet. Setting this is highly recommended. + */ + appMetadata?: AppMetadata; + + /** + * The [`Chain`](https://portal.thirdweb.com/references/typescript/v5/Chain) object of the blockchain you want the wallet to connect to + * + * If a `chain` is not specified, Wallet will be connected to whatever is the default set in the wallet. + * + * If a `chain` is specified, Wallet will be prompted to switch to given chain after connection if it is not already connected to it. + * This ensures that the wallet is connected to the correct blockchain before interacting with your app. + * + * The `ConnectButton` also shows a "Switch Network" button until the wallet is connected to the specified chain. Clicking on the "Switch Network" button triggers the wallet to switch to the specified chain. + * + * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function. + * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object. + * ``` + */ + chain?: Chain; + + /** + * Array of chains that your app supports. + * + * This is only relevant if your app is a multi-chain app and works across multiple blockchains. + * If your app only works on a single blockchain, you should only specify the `chain` prop. + * + * Given list of chains will used in various ways: + * - They will be displayed in the network selector in the `ConnectButton`'s details modal post connection + * - They will be sent to wallet at the time of connection if the wallet supports requesting multiple chains ( example: WalletConnect ) so that users can switch between the chains post connection easily + * + * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function. + * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object. + * + * ```tsx + * import { defineChain } from "thirdweb/react"; + * + * const polygon = defineChain({ + * id: 137, + * }); + * ``` + */ + chains?: Chain[]; + + /** + * Wallets to show as recommended in the `ConnectButton`'s Modal + */ + recommendedWallets?: Wallet[]; + + /** + * By default, ConnectButton modal shows a "All Wallets" button that shows a list of 500+ wallets. + * + * You can disable this button by setting `showAllWallets` prop to `false` + */ + showAllWallets?: boolean; + + /** + * Enable SIWE (Sign in with Ethererum) by passing an object of type `SiweAuthOptions` to + * enforce the users to sign a message after connecting their wallet to authenticate themselves. + * + * Refer to the [`SiweAuthOptions`](https://portal.thirdweb.com/references/typescript/v5/SiweAuthOptions) for more details + */ + auth?: SiweAuthOptions; +}; + +/** + * @internal + */ +export type ActiveWalletInfo = { + activeChain: Chain; + activeWallet: Wallet; + activeAccount: Account; +}; + +/** + * @internal + */ +export type TokenSelection = { + tokenAddress: string; + chainId: number; +}; + +export type SwapPreparedQuote = Extract< + BridgePrepareResult, + { type: "buy" | "sell" } +>; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts new file mode 100644 index 00000000000..27547d7d35f --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { chains } from "../../../../../bridge/index.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; + +export function useBridgeChains(client: ThirdwebClient) { + return useQuery({ + queryKey: ["bridge-chains"], + queryFn: () => { + return chains({ client }); + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts new file mode 100644 index 00000000000..320b7678fde --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts @@ -0,0 +1,120 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Token } from "../../../../../bridge/index.js"; +import { tokens } from "../../../../../bridge/Token.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { isAddress } from "../../../../../utils/address.js"; +import { getThirdwebBaseUrl } from "../../../../../utils/domains.js"; + +export function useTokens(options: { + client: ThirdwebClient; + chainId?: number; + search?: string; + offset: number; + limit: number; +}) { + return useQuery({ + queryKey: ["tokens", options], + enabled: !!options.chainId, + queryFn: () => { + if (!options.chainId) { + throw new Error("Chain ID is required"); + } + + const isSearchAddress = options.search + ? isAddress(options.search) + : false; + + return tokens({ + chainId: options.chainId, + client: options.client, + offset: options.offset, + limit: options.limit, + includePrices: false, + name: !options.search || isSearchAddress ? undefined : options.search, + tokenAddress: isSearchAddress ? options.search : undefined, + }); + }, + }); +} + +export type TokenBalance = { + balance: string; + chain_id: number; + decimals: number; + name: string; + icon_uri: string; + price_data: { + circulating_supply: number; + market_cap_usd: number; + percent_change_24h: number; + price_timestamp: string; + price_usd: number; + total_supply: number; + usd_value: number; + volume_24h_usd: number; + }; + symbol: string; + token_address: string; +}; + +type TokenBalancesResponse = { + result: { + pagination: { + hasMore: boolean; + limit: number; + page: number; + totalCount: number; + }; + tokens: TokenBalance[]; + }; +}; + +export function useTokenBalances(options: { + clientId: string; + page: number; + limit: number; + walletAddress: string | undefined; + chainId: number | undefined; +}) { + return useQuery({ + queryKey: ["bridge/v1/wallets", options], + enabled: !!options.chainId && !!options.walletAddress, + queryFn: async () => { + if (!options.chainId || !options.walletAddress) { + throw new Error("invalid options"); + } + const baseUrl = getThirdwebBaseUrl("bridge"); + const isDev = baseUrl.includes("thirdweb-dev"); + const url = new URL( + `https://api.${isDev ? "thirdweb-dev" : "thirdweb"}.com/v1/wallets/${options.walletAddress}/tokens`, + ); + url.searchParams.set("chainId", options.chainId.toString()); + url.searchParams.set("limit", options.limit.toString()); + url.searchParams.set("page", options.page.toString()); + url.searchParams.set("metadata", "true"); + url.searchParams.set("resolveMetadataLinks", "true"); + url.searchParams.set("includeSpam", "false"); + url.searchParams.set("includeNative", "true"); + url.searchParams.set("sortBy", "usd_value"); + url.searchParams.set("sortOrder", "desc"); + url.searchParams.set("includeWithoutPrice", "false"); // filter out tokens with no price + + const response = await fetch(url.toString(), { + headers: { + "x-client-id": options.clientId, + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch token balances: ${response.statusText}`, + ); + } + + const json = (await response.json()) as TokenBalancesResponse; + return json.result; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts new file mode 100644 index 00000000000..a92d79e481d --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts @@ -0,0 +1,3 @@ +export function cleanedChainName(name: string) { + return name.replace("Mainnet", ""); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx index f10190f4563..87788ad3dcf 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx @@ -543,6 +543,7 @@ function ConnectButtonInner( )} { if (!_open) { diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx index 953b3a62634..cd9a5568f45 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx @@ -118,6 +118,7 @@ const ConnectModal = (props: ConnectModalOptions) => { return ( { diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx index d401b77d7ee..a0554a2fce7 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx @@ -954,6 +954,7 @@ export function useNetworkSwitcherModal() { setRootEl( { if (!value) { diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx new file mode 100644 index 00000000000..f97ba66fb65 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx @@ -0,0 +1,23 @@ +import type { IconFC } from "./types.js"; + +export const ArrowUpDownIcon: IconFC = (props) => { + return ( + + + + + + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx new file mode 100644 index 00000000000..e67119812cd --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx @@ -0,0 +1,22 @@ +import type { IconFC } from "./types.js"; + +/** + * @internal + */ +export const WalletDotIcon: IconFC = (props) => { + return ( + + + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx index a65c0331879..380b02094f3 100644 --- a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx @@ -68,6 +68,7 @@ export function TransactionModal(props: ModalProps) { return ( { if (!_open) { diff --git a/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx b/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx index aaacbf792ab..a3b82b74f36 100644 --- a/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx +++ b/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx @@ -17,7 +17,7 @@ export function DynamicHeight(props: { boxSizing: "border-box", height: height ? `${height}px` : "auto", overflow: "hidden", - transition: "height 210ms cubic-bezier(0.175, 0.885, 0.32, 1.1)", + transition: "height 210ms ease", }} >
= (props) => { - const [isLoaded, setIsLoaded] = useState(false); + const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">( + "pending", + ); + const imgRef = useRef(null); const propSrc = props.src; const widthPx = `${props.width}px`; const heightPx = `${props.height || props.width}px`; - if (propSrc === undefined) { - return ; - } - const getSrc = () => { + if (propSrc === undefined) { + return undefined; + } try { return resolveScheme({ client: props.client, @@ -42,6 +48,31 @@ export const Img: React.FC<{ const src = getSrc(); + const status = + src === undefined ? "pending" : src === "" ? "fallback" : _status; + + const isLoaded = status === "loaded"; + + useEffect(() => { + const imgEl = imgRef.current; + if (!imgEl) { + return; + } + if (imgEl.complete) { + setStatus("loaded"); + } else { + function handleLoad() { + setStatus("loaded"); + } + imgEl.addEventListener("load", handleLoad); + return () => { + imgEl.removeEventListener("load", handleLoad); + }; + } + + return; + }, []); + return (
- {!isLoaded && } + {status === "pending" && ( + + )} + {status === "fallback" && + (props.fallback || ( + +
+ + ))} + {props.alt { - setIsLoaded(true); + setStatus("loaded"); }} - src={src} + src={src || undefined} style={{ height: !isLoaded ? 0 diff --git a/packages/thirdweb/src/react/web/ui/components/Modal.tsx b/packages/thirdweb/src/react/web/ui/components/Modal.tsx index 6b249dd2fa0..0245dc70084 100644 --- a/packages/thirdweb/src/react/web/ui/components/Modal.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Modal.tsx @@ -36,7 +36,9 @@ export const Modal: React.FC<{ style?: React.CSSProperties; hideCloseIcon?: boolean; size: "wide" | "compact"; + title: string; hide?: boolean; + crossContainerStyles?: React.CSSProperties; }> = (props) => { const [open, setOpen] = useState(props.open); const contentRef = useRef(null); @@ -116,7 +118,7 @@ export const Modal: React.FC<{ width: "1px", }} > - Connect Modal + {props.title} {props.size === "compact" ? ( @@ -128,7 +130,15 @@ export const Modal: React.FC<{ {/* Close Icon */} {!props.hideCloseIcon && ( - +
- +
)} @@ -150,13 +160,6 @@ export const Modal: React.FC<{ ); }; -const CrossContainer = /* @__PURE__ */ StyledDiv({ - position: "absolute", - right: spacing.lg, - top: spacing.lg, - transform: "translateX(6px)", -}); - const modalAnimationDesktop = keyframes` from { opacity: 0; @@ -190,7 +193,7 @@ const DialogContent = /* @__PURE__ */ StyledDiv((_) => { animation: `${modalAnimationDesktop} 300ms ease`, background: theme.colors.modalBg, border: `1px solid ${theme.colors.borderColor}`, - borderRadius: radius.lg, + borderRadius: radius.xl, boxShadow: shadow.lg, boxSizing: "border-box", color: theme.colors.primaryText, @@ -208,7 +211,6 @@ const DialogContent = /* @__PURE__ */ StyledDiv((_) => { animation: `${modalAnimationMobile} 0.35s cubic-bezier(0.15, 1.15, 0.6, 1)`, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, - borderRadius: radius.xl, bottom: 0, left: 0, maxWidth: "none !important", diff --git a/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx b/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx index 89e404deb63..0e2d99ac29e 100644 --- a/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx @@ -12,6 +12,7 @@ export const Skeleton: React.FC<{ width?: string; color?: keyof Theme["colors"]; className?: string; + style?: React.CSSProperties; }> = (props) => { return ( ); diff --git a/packages/thirdweb/src/react/web/ui/components/Spinner.tsx b/packages/thirdweb/src/react/web/ui/components/Spinner.tsx index 11153f1ac75..4a1cd57c3c9 100644 --- a/packages/thirdweb/src/react/web/ui/components/Spinner.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Spinner.tsx @@ -10,6 +10,7 @@ import { StyledCircle, StyledSvg } from "../design-system/elements.js"; */ export const Spinner: React.FC<{ size: keyof typeof iconSize; + style?: React.CSSProperties; color?: keyof Theme["colors"]; }> = (props) => { const theme = useCustomTheme(); @@ -18,6 +19,7 @@ export const Spinner: React.FC<{ style={{ height: `${iconSize[props.size]}px`, width: `${iconSize[props.size]}px`, + ...props.style, }} viewBox="0 0 50 50" > diff --git a/packages/thirdweb/src/react/web/ui/components/basic.tsx b/packages/thirdweb/src/react/web/ui/components/basic.tsx index 4ce5a16fd5d..b3b6ea71ec1 100644 --- a/packages/thirdweb/src/react/web/ui/components/basic.tsx +++ b/packages/thirdweb/src/react/web/ui/components/basic.tsx @@ -64,11 +64,10 @@ export function ModalHeader(props: { ); } -export const Line = /* @__PURE__ */ StyledDiv(() => { +export const Line = /* @__PURE__ */ StyledDiv((props: { dashed?: boolean }) => { const theme = useCustomTheme(); return { - background: theme.colors.separatorLine, - height: "1px", + borderTop: `1px ${props.dashed ? "dashed" : "solid"} ${theme.colors.separatorLine}`, }; }); @@ -87,6 +86,8 @@ export function Container(props: { p?: keyof typeof spacing; px?: keyof typeof spacing; py?: keyof typeof spacing; + pb?: keyof typeof spacing; + pt?: keyof typeof spacing; relative?: boolean; scrollY?: boolean; color?: keyof Theme["colors"]; @@ -156,6 +157,14 @@ export function Container(props: { styles.paddingBottom = spacing[props.py]; } + if (props.pb) { + styles.paddingBottom = spacing[props.pb]; + } + + if (props.pt) { + styles.paddingTop = spacing[props.pt]; + } + if (props.debug) { styles.outline = "1px solid red"; styles.outlineOffset = "-1px"; diff --git a/packages/thirdweb/src/react/web/ui/components/buttons.tsx b/packages/thirdweb/src/react/web/ui/components/buttons.tsx index 41ee93cf868..655d9963204 100644 --- a/packages/thirdweb/src/react/web/ui/components/buttons.tsx +++ b/packages/thirdweb/src/react/web/ui/components/buttons.tsx @@ -9,7 +9,14 @@ import { import { StyledButton } from "../design-system/elements.js"; type ButtonProps = { - variant: "primary" | "secondary" | "link" | "accent" | "outline" | "ghost"; + variant: + | "primary" + | "secondary" + | "link" + | "accent" + | "outline" + | "ghost" + | "ghost-solid"; unstyled?: boolean; fullWidth?: boolean; gap?: keyof typeof spacing; @@ -65,6 +72,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => { case "secondary": return theme.colors.secondaryButtonText; case "ghost": + case "ghost-solid": case "outline": return theme.colors.secondaryButtonText; case "link": @@ -109,6 +117,15 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => { }; } + if (props.variant === "ghost-solid") { + return { + "&:hover": { + background: theme.colors.tertiaryBg, + }, + border: "1px solid transparent", + }; + } + if (props.variant === "accent") { return { "&:hover": { diff --git a/packages/thirdweb/src/react/web/ui/components/formElements.tsx b/packages/thirdweb/src/react/web/ui/components/formElements.tsx index 1758ba9eaca..7b75e0b60b8 100644 --- a/packages/thirdweb/src/react/web/ui/components/formElements.tsx +++ b/packages/thirdweb/src/react/web/ui/components/formElements.tsx @@ -30,6 +30,7 @@ type InputProps = { variant: "outline" | "transparent"; sm?: boolean; theme?: Theme; + bg?: keyof Theme["colors"]; }; export const Input = /* @__PURE__ */ StyledInput((props) => { @@ -86,7 +87,7 @@ export const Input = /* @__PURE__ */ StyledInput((props) => { WebkitAppearance: "none", }, appearance: "none", - background: "transparent", + background: props.bg ? theme.colors[props.bg] : "transparent", border: "none", borderRadius: radius.md, boxShadow: `0 0 0 1.5px ${ diff --git a/packages/thirdweb/src/react/web/ui/components/text.tsx b/packages/thirdweb/src/react/web/ui/components/text.tsx index 8d50fc5ee6a..3194269b0d3 100644 --- a/packages/thirdweb/src/react/web/ui/components/text.tsx +++ b/packages/thirdweb/src/react/web/ui/components/text.tsx @@ -12,6 +12,7 @@ export type TextProps = { weight?: 400 | 500 | 600 | 700; multiline?: boolean; balance?: boolean; + trackingTight?: boolean; }; export const Text = /* @__PURE__ */ StyledSpan((p) => { @@ -20,7 +21,7 @@ export const Text = /* @__PURE__ */ StyledSpan((p) => { color: theme.colors[p.color || "secondaryText"], display: p.inline ? "inline" : "block", fontSize: fontSize[p.size || "md"], - fontWeight: p.weight || 500, + fontWeight: p.weight || 400, lineHeight: p.multiline ? 1.5 : "normal", margin: 0, maxWidth: "100%", @@ -28,6 +29,7 @@ export const Text = /* @__PURE__ */ StyledSpan((p) => { textAlign: p.center ? "center" : "left", textOverflow: "ellipsis", textWrap: p.balance ? "balance" : "inherit", + letterSpacing: p.trackingTight ? "-0.025em" : undefined, }; }); diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx new file mode 100644 index 00000000000..0d5e4508fab --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta } from "@storybook/react-vite"; +import { useState } from "react"; +import type { BridgeChain } from "../../../bridge/types/Chain.js"; +import { SwapWidgetContainer } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { + SelectBridgeChain, + SelectBridgeChainUI, +} from "../../../react/web/ui/Bridge/swap-widget/select-chain.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/screens/SelectChain", +} satisfies Meta; +export default meta; + +export function WithData() { + const [selectedChain, setSelectedChain] = useState( + undefined, + ); + return ( + + {}} + selectedChain={selectedChain} + /> + + ); +} + +export function Loading() { + const [selectedChain, setSelectedChain] = useState( + undefined, + ); + return ( + + {}} + isPending={true} + chains={[]} + selectedChain={selectedChain} + /> + + ); +} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx new file mode 100644 index 00000000000..bf2e071fc58 --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta } from "@storybook/react-vite"; +import { NATIVE_TOKEN_ADDRESS } from "../../../constants/addresses.js"; +import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/SwapWidget/Prefill", +} satisfies Meta; +export default meta; + +export function Buy_NativeToken() { + return ( + + ); +} + +export function Buy_Base_USDC() { + return ( + + ); +} + +export function Buy_NativeToken_With_Amount() { + return ( + + ); +} + +export function Sell_NativeToken() { + return ( + + ); +} + +export function Sell_Base_USDC() { + return ( + + ); +} + +export function Sell_NativeToken_With_Amount() { + return ( + + ); +} + +export function Buy_And_Sell_NativeToken() { + return ( + + ); +} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx new file mode 100644 index 00000000000..03bafc8bb8a --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta } from "@storybook/react"; +import { lightTheme } from "../../../react/core/design-system/index.js"; +import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js"; +import { storyClient } from "../../utils.js"; + +const meta: Meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/SwapWidget", + decorators: [ + (Story) => { + return ( +
+ +
+ +
+
+ ); + }, + ], +}; +export default meta; + +export function BasicUsage() { + return ; +} + +export function CurrencySet() { + return ; +} + +export function LightMode() { + return ; +} + +export function NoThirdwebBranding() { + return ( + + ); +} + +export function CustomTheme() { + return ( + + ); +} diff --git a/packages/thirdweb/src/stories/BuyWidget.stories.tsx b/packages/thirdweb/src/stories/BuyWidget.stories.tsx new file mode 100644 index 00000000000..c4f09399ee0 --- /dev/null +++ b/packages/thirdweb/src/stories/BuyWidget.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta } from "@storybook/react-vite"; +import { base } from "../chains/chain-definitions/base.js"; +import { defineChain } from "../chains/utils.js"; +import { BuyWidget } from "../react/web/ui/Bridge/BuyWidget.js"; +import { storyClient } from "./utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Connect/BuyWidget", +} satisfies Meta; +export default meta; + +export function BasicUsage() { + return ; +} + +export function UnsupportedChain() { + return ( + + ); +} + +export function UnsupportedToken() { + return ( + + ); +} + +export function OnlyCardSupported() { + return ( + + ); +} + +export function OnlyCryptoSupported() { + return ( + + ); +} diff --git a/packages/thirdweb/src/stories/ConnectWallet/useWalletDetailsModal.stories.tsx b/packages/thirdweb/src/stories/ConnectWallet/useWalletDetailsModal.stories.tsx index 94593d9f42e..49d0abfa0db 100644 --- a/packages/thirdweb/src/stories/ConnectWallet/useWalletDetailsModal.stories.tsx +++ b/packages/thirdweb/src/stories/ConnectWallet/useWalletDetailsModal.stories.tsx @@ -96,7 +96,7 @@ export function ConnectedAccountName() { } export function ShowBalanceInFiat() { - return ; + return ; } export function AssetTabs() {