Skip to content

Commit 5b4a189

Browse files
committed
[MNY-210] SDK: export a script to render BridgeEmbed
1 parent c26fc67 commit 5b4a189

File tree

15 files changed

+835
-243
lines changed

15 files changed

+835
-243
lines changed

.changeset/metal-bats-speak.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Add `BridgeWidget` component.
6+
7+
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

packages/thirdweb/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@types/prompts": "2.4.9",
6666
"@types/qrcode": "1.5.5",
6767
"@types/react": "19.1.8",
68+
"@types/react-dom": "19.1.6",
6869
"@viem/anvil": "0.0.10",
6970
"@vitejs/plugin-react": "^4.6.0",
7071
"@vitest/coverage-v8": "3.2.4",
@@ -89,6 +90,7 @@
8990
"sharp": "^0.34.2",
9091
"size-limit": "11.2.0",
9192
"storybook": "9.0.15",
93+
"tsup": "^8.5.0",
9294
"typedoc": "0.27.9",
9395
"typedoc-better-json": "0.9.4",
9496
"typescript": "5.8.3",
@@ -323,7 +325,8 @@
323325
"scripts": {
324326
"bench": "vitest -c ./test/vitest.config.ts bench",
325327
"bench:compare": "bun run ./benchmarks/run.ts",
326-
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm",
328+
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm && pnpm build:tsup",
329+
"build:tsup": "tsup",
327330
"build-storybook": "storybook build",
328331
"build:cjs": "tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json",
329332
"build:esm": "tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json",

packages/thirdweb/src/exports/react.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ export {
137137
BuyWidget,
138138
type BuyWidgetProps,
139139
} from "../react/web/ui/Bridge/BuyWidget.js";
140+
export {
141+
BridgeWidget,
142+
type BridgeWidgetProps,
143+
} from "../react/web/ui/Bridge/bridge-widget/bridge-widget.js";
140144
export {
141145
CheckoutWidget,
142146
type CheckoutWidgetProps,

packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import type { LocaleId } from "../types.js";
3333
import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js";
3434
import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js";
3535

36-
type BuyOrOnrampPrepareResult = Extract<
36+
export type BuyOrOnrampPrepareResult = Extract<
3737
BridgePrepareResult,
3838
{ type: "buy" | "onramp" }
3939
>;
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import { useState } from "react";
2+
import { defineChain } from "../../../../../chains/utils.js";
3+
import type { ThirdwebClient } from "../../../../../client/client.js";
4+
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
5+
import type { PurchaseData } from "../../../../../pay/types.js";
6+
import {
7+
CustomThemeProvider,
8+
useCustomTheme,
9+
} from "../../../../core/design-system/CustomThemeProvider.js";
10+
import {
11+
fontSize,
12+
radius,
13+
spacing,
14+
type Theme,
15+
} from "../../../../core/design-system/index.js";
16+
import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js";
17+
import { Container } from "../../components/basic.js";
18+
import { Button } from "../../components/buttons.js";
19+
import { type BuyOrOnrampPrepareResult, BuyWidget } from "../BuyWidget.js";
20+
import { SwapWidget } from "../swap-widget/SwapWidget.js";
21+
import type { SwapPreparedQuote } from "../swap-widget/types.js";
22+
23+
/**
24+
* Props for the `BridgeWidget` component.
25+
*/
26+
export type BridgeWidgetProps = {
27+
/**
28+
* A client is the entry point to the thirdweb SDK. It is required for all other actions.
29+
* You can create a client using the `createThirdwebClient` function. Refer to the
30+
* [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information.
31+
*
32+
* 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.
33+
*
34+
* @example
35+
* ```ts
36+
* import { createThirdwebClient } from "thirdweb";
37+
*
38+
* const client = createThirdwebClient({
39+
* clientId: "<your_client_id>",
40+
* });
41+
* ```
42+
*/
43+
client: ThirdwebClient;
44+
45+
/**
46+
* Set the theme for the widget. By default it is set to `"dark"`.
47+
*
48+
* Theme can be set to either `"dark"`, `"light"` or a custom theme object.
49+
* You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme)
50+
* or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) from `thirdweb/react`
51+
* to use the default themes as base and override parts of it.
52+
*
53+
* @example
54+
* ```ts
55+
* import { lightTheme } from "thirdweb/react";
56+
*
57+
* const customTheme = lightTheme({
58+
* colors: { modalBg: "red" },
59+
* });
60+
* ```
61+
*/
62+
theme?: "light" | "dark" | Theme;
63+
64+
/**
65+
* Whether to show thirdweb branding in the widget.
66+
* @default true
67+
*/
68+
showThirdwebBranding?: boolean;
69+
70+
/**
71+
* The currency to use for fiat pricing in the widget.
72+
* @default "USD"
73+
*/
74+
currency?: SupportedFiatCurrency;
75+
76+
/**
77+
* Configuration for the Swap tab. This mirrors {@link SwapWidget} options where applicable.
78+
*/
79+
swap?: {
80+
/** Optional class name applied to the Swap tab content container. */
81+
className?: string;
82+
/** Optional style overrides applied to the Swap tab content container. */
83+
style?: React.CSSProperties;
84+
/** Callback invoked when a swap is successful. */
85+
onSuccess?: (quote: SwapPreparedQuote) => void;
86+
/** Callback invoked when an error occurs during swapping. */
87+
onError?: (error: Error, quote: SwapPreparedQuote) => void;
88+
/** Callback invoked when the user cancels the swap. */
89+
onCancel?: (quote: SwapPreparedQuote) => void;
90+
/** Callback invoked when the user disconnects the active wallet. */
91+
onDisconnect?: () => void;
92+
/**
93+
* Whether to persist token selections to localStorage so that revisits pre-select last used tokens.
94+
* Prefill values take precedence over persisted selections.
95+
* @default true
96+
*/
97+
persistTokenSelections?: boolean;
98+
/**
99+
* Prefill initial buy/sell token selections. If `tokenAddress` is not provided, the native token will be used.
100+
*
101+
* @example
102+
* ### Set an ERC20 token as the buy token
103+
* ```tsx
104+
* <BridgeWidget
105+
* client={client}
106+
* buy={{ amount: "0.1", chainId: 8453 }}
107+
* swap={{
108+
* prefill: {
109+
* buyToken: {
110+
* chainId: 8453,
111+
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
112+
* },
113+
* },
114+
* }}
115+
* />
116+
* ```
117+
*
118+
* ### Set a native token as the sell token
119+
* ```tsx
120+
* <BridgeWidget
121+
* client={client}
122+
* buy={{ amount: "0.1", chainId: 8453 }}
123+
* swap={{
124+
* prefill: {
125+
* sellToken: { chainId: 8453 },
126+
* },
127+
* }}
128+
* />
129+
* ```
130+
*/
131+
prefill?: {
132+
/** Buy token selection. If `tokenAddress` is omitted, the native token will be used. */
133+
buyToken?: {
134+
tokenAddress?: string;
135+
chainId: number;
136+
/** Optional human-readable amount to prefill for buy. */
137+
amount?: string;
138+
};
139+
/** Sell token selection. If `tokenAddress` is omitted, the native token will be used. */
140+
sellToken?: {
141+
tokenAddress?: string;
142+
chainId: number;
143+
/** Optional human-readable amount to prefill for sell. */
144+
amount?: string;
145+
};
146+
};
147+
};
148+
149+
/**
150+
* Configuration for the Buy tab. This mirrors {@link BuyWidget} options where applicable.
151+
*/
152+
buy: {
153+
/**
154+
* The amount to buy (as a decimal string), e.g. "1.5" for 1.5 tokens.
155+
*/
156+
amount: string; // TODO - make it optional
157+
/**
158+
* The chain the accepted token is on.
159+
*/
160+
chainId: number; // TODO - make it optional
161+
/**
162+
* Address of the token to buy. Leave undefined for the native token, or use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.
163+
*/
164+
tokenAddress?: string;
165+
/** Custom label for the main action button. */
166+
buttonLabel?: string;
167+
/** Callback triggered when the user cancels the purchase. */
168+
onCancel?: (quote: BuyOrOnrampPrepareResult | undefined) => void;
169+
/** Callback triggered when the purchase encounters an error. */
170+
onError?: (
171+
error: Error,
172+
quote: BuyOrOnrampPrepareResult | undefined,
173+
) => void;
174+
/** Callback triggered when the purchase is successful. */
175+
onSuccess?: (quote: BuyOrOnrampPrepareResult) => void;
176+
/** Optional class name applied to the Buy tab content container. */
177+
className?: string;
178+
/** The user's ISO 3166 alpha-2 country code. Used to determine onramp provider support. */
179+
country?: string;
180+
/** Preset fiat amounts to display in the UI. Defaults to [5, 10, 20]. */
181+
presetOptions?: [number, number, number];
182+
/** Arbitrary data to be included in returned status and webhook events. */
183+
purchaseData?: PurchaseData;
184+
};
185+
};
186+
187+
/**
188+
* A combined widget for swapping or buying tokens with cross-chain support.
189+
*
190+
* This component renders two tabs – "Swap" and "Buy" – and orchestrates the appropriate flow
191+
* by composing {@link SwapWidget} and {@link BuyWidget} under the hood.
192+
*
193+
* - The Swap tab enables token-to-token swaps (including cross-chain).
194+
* - The Buy tab enables purchasing a specific token; by default, it uses card onramp in this widget.
195+
*
196+
* @param props - Props of type {@link BridgeWidgetProps} to configure the BridgeWidget component.
197+
*
198+
* @example
199+
* ### Basic usage
200+
* ```tsx
201+
* <BridgeWidget
202+
* client={client}
203+
* currency="USD"
204+
* theme="dark"
205+
* showThirdwebBranding
206+
* buy={{
207+
* // Buy 0.1 native tokens on Base
208+
* amount: "0.1",
209+
* chainId: 8453,
210+
* }}
211+
* />
212+
* ```
213+
*
214+
* ### Prefill swap tokens and configure buy
215+
* ```tsx
216+
* <BridgeWidget
217+
* client={client}
218+
* swap={{
219+
* prefill: {
220+
* buyToken: {
221+
* // Base USDC
222+
* chainId: 8453,
223+
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
224+
* },
225+
* sellToken: {
226+
* // Polygon native token (MATIC)
227+
* chainId: 137,
228+
* },
229+
* },
230+
* }}
231+
* buy={{
232+
* amount: "100",
233+
* chainId: 8453,
234+
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
235+
* buttonLabel: "Buy USDC",
236+
* }}
237+
* />
238+
* ```
239+
*
240+
* @bridge
241+
*/
242+
export function BridgeWidget(props: BridgeWidgetProps) {
243+
const [tab, setTab] = useState<"swap" | "buy">("swap");
244+
245+
return (
246+
<CustomThemeProvider theme={props.theme}>
247+
<EmbedContainer
248+
modalSize="compact"
249+
style={{
250+
borderRadius: radius.xl,
251+
}}
252+
>
253+
<Container
254+
px="md"
255+
py="md"
256+
flex="row"
257+
gap="xs"
258+
borderColor="borderColor"
259+
style={{
260+
borderBottomWidth: 1,
261+
borderBottomStyle: "dashed",
262+
}}
263+
>
264+
<TabButton isActive={tab === "swap"} onClick={() => setTab("swap")}>
265+
Swap
266+
</TabButton>
267+
<TabButton isActive={tab === "buy"} onClick={() => setTab("buy")}>
268+
Buy
269+
</TabButton>
270+
</Container>
271+
272+
{tab === "swap" && (
273+
<SwapWidget
274+
client={props.client}
275+
prefill={props.swap?.prefill}
276+
className={props.swap?.className}
277+
showThirdwebBranding={props.showThirdwebBranding}
278+
currency={props.currency}
279+
theme={props.theme}
280+
onSuccess={props.swap?.onSuccess}
281+
onError={props.swap?.onError}
282+
onCancel={props.swap?.onCancel}
283+
onDisconnect={props.swap?.onDisconnect}
284+
persistTokenSelections={props.swap?.persistTokenSelections}
285+
style={{
286+
border: "none",
287+
...props.swap?.style,
288+
}}
289+
/>
290+
)}
291+
{tab === "buy" && (
292+
<BuyWidget
293+
client={props.client}
294+
amount={props.buy.amount}
295+
showThirdwebBranding={props.showThirdwebBranding}
296+
chain={defineChain(props.buy.chainId)}
297+
currency={props.currency}
298+
theme={props.theme}
299+
title="" // Keep it empty string to hide the title
300+
tokenAddress={props.buy.tokenAddress as `0x${string}` | undefined}
301+
buttonLabel={props.buy.buttonLabel}
302+
className={props.buy.className}
303+
country={props.buy.country}
304+
onCancel={props.buy.onCancel}
305+
onError={props.buy.onError}
306+
onSuccess={props.buy.onSuccess}
307+
presetOptions={props.buy.presetOptions}
308+
purchaseData={props.buy.purchaseData}
309+
paymentMethods={["card"]}
310+
style={{
311+
border: "none",
312+
}}
313+
/>
314+
)}
315+
</EmbedContainer>
316+
</CustomThemeProvider>
317+
);
318+
}
319+
320+
function TabButton(props: {
321+
isActive: boolean;
322+
onClick: () => void;
323+
children: React.ReactNode;
324+
}) {
325+
const theme = useCustomTheme();
326+
return (
327+
<Button
328+
variant="secondary"
329+
onClick={props.onClick}
330+
style={{
331+
borderRadius: radius.full,
332+
fontSize: fontSize.sm,
333+
fontWeight: 500,
334+
paddingInline: spacing["md+"],
335+
paddingBlock: spacing.sm,
336+
border: `1px solid ${
337+
props.isActive ? theme.colors.secondaryText : theme.colors.borderColor
338+
}`,
339+
}}
340+
>
341+
{props.children}
342+
</Button>
343+
);
344+
}

0 commit comments

Comments
 (0)