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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/metal-bats-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"thirdweb": patch
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bump should be minor (new public API)

This PR adds a new public export (BridgeWidget) and a new script bundle entry; per repo rules, that requires a minor bump.

-"thirdweb": patch
+"thirdweb": minor
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"thirdweb": patch
"thirdweb": minor
🤖 Prompt for AI Agents
.changeset/metal-bats-speak.md around lines 2 to 2: the changeset currently
lists "thirdweb": patch but the PR adds a new public export and a new script
bundle entry which requires a minor bump per repo rules; update the changeset to
change the version bump for "thirdweb" from patch to minor (e.g., "thirdweb":
minor) so the release tooling will produce a minor version.

---

Add `BridgeWidget` component.

Generate a browser script in `dist/scripts/bridge-widget.js` that can be used to render the `BridgeWidget` component in a browser with a script
5 changes: 4 additions & 1 deletion packages/thirdweb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@types/prompts": "2.4.9",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@viem/anvil": "0.0.10",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "3.2.4",
Expand All @@ -89,6 +90,7 @@
"sharp": "^0.34.2",
"size-limit": "11.2.0",
"storybook": "9.0.15",
"tsup": "^8.5.0",
"typedoc": "0.27.9",
"typedoc-better-json": "0.9.4",
"typescript": "5.8.3",
Expand Down Expand Up @@ -323,7 +325,8 @@
"scripts": {
"bench": "vitest -c ./test/vitest.config.ts bench",
"bench:compare": "bun run ./benchmarks/run.ts",
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm",
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm && pnpm build:tsup",
"build:tsup": "tsup",
"build-storybook": "storybook build",
"build:cjs": "tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json",
"build:esm": "tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json",
Expand Down
4 changes: 4 additions & 0 deletions packages/thirdweb/src/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ export {
BuyWidget,
type BuyWidgetProps,
} from "../react/web/ui/Bridge/BuyWidget.js";
export {
BridgeWidget,
type BridgeWidgetProps,
} from "../react/web/ui/Bridge/bridge-widget/bridge-widget.js";
export {
CheckoutWidget,
type CheckoutWidgetProps,
Expand Down
2 changes: 1 addition & 1 deletion packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import type { LocaleId } from "../types.js";
import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js";
import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js";

type BuyOrOnrampPrepareResult = Extract<
export type BuyOrOnrampPrepareResult = Extract<
BridgePrepareResult,
{ type: "buy" | "onramp" }
>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
import { useState } from "react";
import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import type { PurchaseData } from "../../../../../pay/types.js";
import {
CustomThemeProvider,
useCustomTheme,
} from "../../../../core/design-system/CustomThemeProvider.js";
import {
fontSize,
radius,
spacing,
type Theme,
} from "../../../../core/design-system/index.js";
import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js";
import { Container } from "../../components/basic.js";
import { Button } from "../../components/buttons.js";
import { type BuyOrOnrampPrepareResult, BuyWidget } from "../BuyWidget.js";
import { SwapWidget } from "../swap-widget/SwapWidget.js";
import type { SwapPreparedQuote } from "../swap-widget/types.js";

/**
* Props for the `BridgeWidget` component.
*/
export type BridgeWidgetProps = {
/**
* 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` for client-side usage and `secretKey` for server-side usage.
*
* @example
* ```ts
* import { createThirdwebClient } from "thirdweb";
*
* const client = createThirdwebClient({
* clientId: "<your_client_id>",
* });
* ```
*/
client: ThirdwebClient;

/**
* Set the theme for the widget. 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) from `thirdweb/react`
* to use the default themes as base and override parts of it.
*
* @example
* ```ts
* import { lightTheme } from "thirdweb/react";
*
* const customTheme = lightTheme({
* colors: { modalBg: "red" },
* });
* ```
*/
theme?: "light" | "dark" | Theme;

/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;

/**
* The currency to use for fiat pricing in the widget.
* @default "USD"
*/
currency?: SupportedFiatCurrency;

/**
* Configuration for the Swap tab. This mirrors {@link SwapWidget} options where applicable.
*/
swap?: {
/** Optional class name applied to the Swap tab content container. */
className?: string;
/** Optional style overrides applied to the Swap tab content container. */
style?: React.CSSProperties;
/** Callback invoked when a swap is successful. */
onSuccess?: (quote: SwapPreparedQuote) => void;
/** Callback invoked when an error occurs during swapping. */
onError?: (error: Error, quote: SwapPreparedQuote) => void;
/** Callback invoked when the user cancels the swap. */
onCancel?: (quote: SwapPreparedQuote) => void;
/** Callback invoked when the user disconnects the active wallet. */
onDisconnect?: () => void;
/**
* Whether to persist token selections to localStorage so that revisits pre-select last used tokens.
* Prefill values take precedence over persisted selections.
* @default true
*/
persistTokenSelections?: boolean;
/**
* Prefill initial buy/sell token selections. If `tokenAddress` is not provided, the native token will be used.
*
* @example
* ### Set an ERC20 token as the buy token
* ```tsx
* <BridgeWidget
* client={client}
* buy={{ amount: "0.1", chainId: 8453 }}
* swap={{
* prefill: {
* buyToken: {
* chainId: 8453,
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
* },
* },
* }}
* />
* ```
*
* ### Set a native token as the sell token
* ```tsx
* <BridgeWidget
* client={client}
* buy={{ amount: "0.1", chainId: 8453 }}
* swap={{
* prefill: {
* sellToken: { chainId: 8453 },
* },
* }}
* />
* ```
*/
prefill?: {
/** Buy token selection. If `tokenAddress` is omitted, the native token will be used. */
buyToken?: {
tokenAddress?: string;
chainId: number;
/** Optional human-readable amount to prefill for buy. */
amount?: string;
};
/** Sell token selection. If `tokenAddress` is omitted, the native token will be used. */
sellToken?: {
tokenAddress?: string;
chainId: number;
/** Optional human-readable amount to prefill for sell. */
amount?: string;
};
};
};

/**
* Configuration for the Buy tab. This mirrors {@link BuyWidget} options where applicable.
*/
buy: {
/**
* The amount to buy (as a decimal string), e.g. "1.5" for 1.5 tokens.
*/
amount: string; // TODO - make it optional
/**
* The chain the accepted token is on.
*/
chainId: number; // TODO - make it optional
/**
* Address of the token to buy. Leave undefined for the native token, or use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.
*/
tokenAddress?: string;
/** Custom label for the main action button. */
buttonLabel?: string;
/** Callback triggered when the user cancels the purchase. */
onCancel?: (quote: BuyOrOnrampPrepareResult | undefined) => void;
/** Callback triggered when the purchase encounters an error. */
onError?: (
error: Error,
quote: BuyOrOnrampPrepareResult | undefined,
) => void;
/** Callback triggered when the purchase is successful. */
onSuccess?: (quote: BuyOrOnrampPrepareResult) => void;
/** Optional class name applied to the Buy tab content container. */
className?: string;
/** The user's ISO 3166 alpha-2 country code. Used to determine onramp provider support. */
country?: string;
/** Preset fiat amounts to display in the UI. Defaults to [5, 10, 20]. */
presetOptions?: [number, number, number];
/** Arbitrary data to be included in returned status and webhook events. */
purchaseData?: PurchaseData;
};
};

/**
* A combined widget for swapping or buying tokens with cross-chain support.
*
* This component renders two tabs – "Swap" and "Buy" – and orchestrates the appropriate flow
* by composing {@link SwapWidget} and {@link BuyWidget} under the hood.
*
* - The Swap tab enables token-to-token swaps (including cross-chain).
* - The Buy tab enables purchasing a specific token; by default, it uses card onramp in this widget.
*
* @param props - Props of type {@link BridgeWidgetProps} to configure the BridgeWidget component.
*
* @example
* ### Basic usage
* ```tsx
* <BridgeWidget
* client={client}
* currency="USD"
* theme="dark"
* showThirdwebBranding
* buy={{
* // Buy 0.1 native tokens on Base
* amount: "0.1",
* chainId: 8453,
* }}
* />
* ```
*
* ### Prefill swap tokens and configure buy
* ```tsx
* <BridgeWidget
* client={client}
* swap={{
* prefill: {
* buyToken: {
* // Base USDC
* chainId: 8453,
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
* },
* sellToken: {
* // Polygon native token (MATIC)
* chainId: 137,
* },
* },
* }}
* buy={{
* amount: "100",
* chainId: 8453,
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
* buttonLabel: "Buy USDC",
* }}
* />
* ```
*
* @bridge
*/
export function BridgeWidget(props: BridgeWidgetProps) {
const [tab, setTab] = useState<"swap" | "buy">("swap");

return (
<CustomThemeProvider theme={props.theme}>
<EmbedContainer
modalSize="compact"
style={{
borderRadius: radius.xl,
}}
>
<Container
px="md"
py="md"
flex="row"
gap="xs"
borderColor="borderColor"
style={{
borderBottomWidth: 1,
borderBottomStyle: "dashed",
}}
>
<TabButton isActive={tab === "swap"} onClick={() => setTab("swap")}>
Swap
</TabButton>
<TabButton isActive={tab === "buy"} onClick={() => setTab("buy")}>
Buy
</TabButton>
</Container>

{tab === "swap" && (
<SwapWidget
client={props.client}
prefill={props.swap?.prefill}
className={props.swap?.className}
showThirdwebBranding={props.showThirdwebBranding}
currency={props.currency}
theme={props.theme}
onSuccess={props.swap?.onSuccess}
onError={props.swap?.onError}
onCancel={props.swap?.onCancel}
onDisconnect={props.swap?.onDisconnect}
persistTokenSelections={props.swap?.persistTokenSelections}
style={{
border: "none",
...props.swap?.style,
}}
/>
)}
{tab === "buy" && (
<BuyWidget
client={props.client}
amount={props.buy.amount}
showThirdwebBranding={props.showThirdwebBranding}
chain={defineChain(props.buy.chainId)}
currency={props.currency}
theme={props.theme}
title="" // Keep it empty string to hide the title
tokenAddress={props.buy.tokenAddress as `0x${string}` | undefined}
buttonLabel={props.buy.buttonLabel}
className={props.buy.className}
country={props.buy.country}
onCancel={props.buy.onCancel}
onError={props.buy.onError}
onSuccess={props.buy.onSuccess}
presetOptions={props.buy.presetOptions}
purchaseData={props.buy.purchaseData}
paymentMethods={["card"]}
style={{
border: "none",
}}
/>
)}
</EmbedContainer>
</CustomThemeProvider>
);
}

function TabButton(props: {
isActive: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
const theme = useCustomTheme();
return (
<Button
variant="secondary"
onClick={props.onClick}
style={{
borderRadius: radius.full,
fontSize: fontSize.sm,
fontWeight: 500,
paddingInline: spacing["md+"],
paddingBlock: spacing.sm,
border: `1px solid ${
props.isActive ? theme.colors.secondaryText : theme.colors.borderColor
}`,
}}
>
{props.children}
</Button>
);
}
Loading
Loading