Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
01a20cb7d5 Version Packages (#8699)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-06 02:31:37 +07:00
FirekeeperandGitHub e13b9a784a [CLI] Deprecate create (#8700) 2026-03-06 02:27:51 +07:00
da4a4a540a Wallet details export screen (#8698)
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Yash <[email protected]>
2026-03-06 01:40:34 +07:00
5e5c82fb2e Sei chain description (#8651)
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: yashjoisar <[email protected]>
2026-03-05 21:40:45 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1d28988253 Version Packages (#8687)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 22:39:15 +13:00
5352152835 Connect modal approval changeset (#8686)
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Yash <[email protected]>
2026-02-24 22:36:53 +13:00
decbbb8f7a Connect modal require approval (#8683)
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Yash <[email protected]>
2026-02-24 22:01:29 +13:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ecd5ae1feb Version Packages (#8672)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-13 23:39:01 +07:00
FirekeeperandGitHub 1caf6dd460 changeset / fmt (#8671) 2026-02-13 23:34:21 +07:00
19204e4654 [SDK] Fix Safe wallet WalletConnect connection on Berachain (#8670)
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-13 23:24:37 +07:00
Manan TankandGitHub 674c8d5e6c portal: Fix sidebar spacing on top (#8666) 2026-02-11 17:09:29 +13:00
1eb0cd9314 Feat/partner logo branding (#8668)
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-10 14:52:06 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
b7dfde2d68 Version Packages (#8665)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-04 19:54:15 -08:00
Joaquim VergesandGitHub b50e304e0c changeset (#8664) 2026-02-04 17:56:41 -08:00
9baa251bb1 [SDK] Fix SiteLink and SiteEmbed stripping URL hash fragments (#8663)
Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-04 17:55:11 -08:00
Manan TankandGitHub 7f36303b0b portal: OpenApiEndpoint card UI tweaks (#8661) 2026-02-04 17:50:30 -08:00
Manan TankandGitHub 13a46b7ff6 portal: Update SDK Reference breadcrumbs UI (#8662) 2026-02-04 17:50:07 -08:00
Manan TankandGitHub b9d93e44ca portal: landing page ui improvements (#8647) 2026-01-30 12:09:55 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fc9e81f99a Version Packages (#8644)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-20 16:27:12 -08:00
281759f06a Add base account sdk (#8634)
Co-authored-by: Joaquim Verges <[email protected]>
2026-01-20 16:04:13 -08:00
Joaquim VergesandGitHub 2d98fe2799 Support linking accounts with redirect mode (#8643) 2026-01-20 14:40:33 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
93bcdc557c Version Packages (#8640)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-15 17:26:04 -08:00
Joaquim VergesandGitHub d38deecaa1 Fix getCallStatus via EIP1193 provider (#8639) 2026-01-15 17:20:25 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a3ea002afd Version Packages (#8638)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-14 22:56:41 +07:00
FirekeeperandGitHub 6390052f8b Add fallback for gas fee estimation in zkSync (#8637) 2026-01-14 22:54:24 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
621f187a8a Version Packages (#8622)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-13 13:36:58 -08:00
Joaquim VergesandGitHub 36ff327744 support x402 v2 headers (#8623) 2026-01-13 12:59:11 -08:00
Manan TankandGitHub f68c2c62a5 [MNY-233] SDK: Add Last Used badge in Connect UI (#8631) 2026-01-13 10:45:05 -08:00
5766c90fb4 [SDK] Fix: Parse x402 payment header (#8617)
Co-authored-by: Joaquim Verges <[email protected]>
2026-01-12 11:20:43 -08:00
MananTank 8d4b6a1fea [MNY-356] Add tokenEditable and amountEditable props on BuyWidget (#8621)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces two new properties, `amountEditable` and `tokenEditable`, to enhance the `BuyWidget` component, allowing control over editing options for token selection and amount. This improves user experience by providing flexibility in the widget's functionality.

### Detailed summary
- Added `amountEditable` and `tokenEditable` properties to `BuyWidget`.
- Updated `BuyPlayground.tsx`, `CheckoutPlayground.tsx`, and `TransactionPlayground.tsx` to use new props.
- Modified `CodeGen.tsx` to incorporate editability options.
- Updated URL generation in `buildBuyIframeUrl.ts` for new props.
- Added editability options in `LeftSection.tsx`.
- Enhanced `BuyWidgetEmbed` to accept new props.
- Implemented stories in `BuyWidget.stories.tsx` for editable and non-editable states.
- Added documentation in `iframe/page.mdx` for new features.
- Adjusted `FundWallet.tsx` and `TokenSection.tsx` to respect new editability options.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added `amountEditable` and `tokenEditable` props to the BuyWidget component, allowing developers to disable token selection and/or amount editing in the buy flow.
  * Enhanced UI controls to prevent interaction with locked fields when these options are disabled.
  * Updated documentation with examples of how to use the new editability options.

* **Tests**
  * Added story variants demonstrating token and amount lock scenarios.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 18:12:38 +00:00
FirekeeperandGitHub 49b80fe87f Add self-managed vault recovery option for lost secret key (#8619) 2026-01-12 23:16:45 +07:00
FirekeeperandGitHub 5ca4d7497b Preserve wallet address on vault credential rotation (#8618) 2026-01-12 22:27:08 +07:00
kumaryash90 0382d42a99 Enable multiple ref params for bytes input (#8610)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the `Button` component in the `decoded-input-set.tsx` file to enhance its functionality based on certain conditions related to `param.type` and `fields`.

### Detailed summary
- Updated the `Button` component to disable it when `param.type` is "bytes" and `fields.length` is 1 or more.
- Maintained the existing `onClick` functionality to append a new input with a default value of an empty string.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed an issue in the contract publish form where the Add Parameter button was incorrectly disabled when working with bytes parameters. Users can now add multiple bytes parameters without restriction.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-08 15:23:52 +00:00
MananTank d35cad8655 [MNY-355] Playground: Add Buy Widget iframe playground (#8615)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `buy` widget functionality by introducing iframe support and improving the handling of integration types. It also adds new components for rendering iframe previews and updates the URL management for better integration experience.

### Detailed summary
- Updated `LeftSection.tsx` to allow both `checkout` and `buy` widgets for iframe integration.
- Modified `Page` component in `page.tsx` to handle async `searchParams` and pass `defaultTab` to `BuyPlayground`.
- Enhanced `CodeGen` to support widget type in iframe code generation.
- Added `CheckoutIframePreview` and `BuyWidgetIframePreview` components in `RightSection.tsx` for rendering respective iframes.
- Created `buildBuyIframeUrl` function in `buildBuyIframeUrl.ts` for generating URLs for the buy widget.
- Updated `BuyPlayground` to manage state and URL for integration type selection using tabs.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2026-01-07 20:23:15 +00:00
MananTank c1275a5e38 [MNY-354] Portal: Add Buy Widget iframe docs (#8614)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the `Buy Widget`, enhancing the portal with a new iframe component for purchasing crypto. It includes documentation, features, and examples for users to integrate the widget easily.

### Detailed summary
- Added `IframeCodePreview` component in `iframe-code-preview.tsx`.
- Updated sidebar links to reflect the new `Buy Widget` and `Swap Widget`.
- Created documentation for the `Buy Widget` in `page.mdx`, detailing features and integration.
- Added example usage of the `BuyWidget` component in `react/page.mdx`.
- Developed `iframe/page.mdx` with customization options and event listening for the widget.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added an iframe embed preview with code/preview tabs for trying the Buy widget.

* **Documentation**
  * Added comprehensive Buy widget docs (overview, React and iframe guides, examples, customization, event listening, live playgrounds).

* **Chores**
  * Updated site navigation: Widgets section reorganized to list Swap, Buy, and Checkout widgets with updated links.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 20:23:14 +00:00
MananTank 277874594b SDK: token details screen UI tweaks (#8612)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the user interface of the `TokenInfoScreen` component in the `select-token-ui.tsx` file by enhancing loading states, adjusting layout heights, and refining token display logic.

### Detailed summary
- Removed initial loading and token not found states.
- Increased `Container` minHeight from `350px` to `450px`.
- Refactored conditional rendering for token details.
- Maintained structure for displaying token name, symbol, price, market cap, volume, and contract address.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Consolidated rendering logic for the token information view to centralize pending, not-found, and content states.
  * Reorganized layout so detailed token sections render only when token data is present.
  * Adjusted minimum container height for token information screens in the bridge swap widget.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 19:26:05 +00:00
MananTank 4fc02b3618 [MNY-342] Dashboard: Add Buy Widget iframe (#8613)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `BuyWidget` feature, allowing users to purchase crypto tokens using various payment methods. It adds necessary environment variables, updates configuration for security policies, and implements the layout and functionality for the `BuyWidgetEmbed` component.

### Detailed summary
- Added `NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID` to `public-envs.ts`.
- Updated `next.config.ts` with new Content Security Policy headers for `/bridge/buy-widget`.
- Created `BuyWidgetLayout` component in `layout.tsx` for the buy widget page.
- Implemented `BuyWidgetEmbed` component in `BuyWidgetEmbed.client.tsx` for handling crypto purchases.
- Enhanced `page.tsx` to manage search parameters and integrate `BuyWidgetEmbed`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added a new buy widget enabling direct cryptocurrency purchases with configurable payment methods (crypto and card) and multi-currency support
  * Implemented personalization options including custom titles, descriptions, images, and button labels
  * Enhanced security infrastructure to support the new widget functionality

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 19:16:44 +00:00
Yash094 26a91537d2 Etherlink shadownet chain (#8611)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

-->
[SDK] Feature: Add Etherlink Shadownet Testnet chain

## Notes for the reviewer

Adds the Etherlink Shadownet testnet chain (ID 127823) to the predefined chains in the SDK. This includes its native currency (XTZ) and block explorer URL.

## How to test

- Verify the `etherlinkShadownet` chain can be imported from `thirdweb/chains`.
- Check that the chain object contains the correct `id`, `name`, `nativeCurrency`, and `blockExplorers` properties.
- Run `pnpm build` and `pnpm lint` to ensure no new issues are introduced.

---
[Slack Thread](https://thirdwebdev.slack.com/archives/C09DS2CKGP2/p1767789368607579?thread_ts=1767789368.607579&cid=C09DS2CKGP2)

<a href="https://cursor.com/background-agent?bcId=bc-a0f088a7-057b-46f0-b6a6-3ba957d1e261"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-a0f088a7-057b-46f0-b6a6-3ba957d1e261"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a>

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the `Etherlink Shadownet` testnet chain to the project, defining its properties and adding it to the exports.

### Detailed summary
- Added a new chain definition for `etherlinkShadownet` in `etherlink-shadownet.ts`.
- Defined properties such as `id`, `name`, `nativeCurrency`, and `blockExplorers`.
- Exported `etherlinkShadownet` in `chains.ts` for use in the application.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added support for Etherlink Shadownet testnet chain, enabling development and testing on this network with proper block explorer integration and native currency configuration (XTZ).

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 14:06:28 +00:00
MananTank a3ff7572d6 [MNY-319] SDK: Add token details screen in token selection UI (#8608)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the token selection UI by adding a token details screen and improving the display of token information within the `SwapWidget` and `BridgeWidget`.

### Detailed summary
- Added a token details screen in the token selection UI.
- Enhanced the `SelectedTokenButton` to include a more flexible display of the token symbol.
- Introduced `useTokenPrice` hook for fetching token prices.
- Updated `TokenButton` to include an info button for showing token details.
- Created `TokenInfoScreen` and `TokenInfoRow` components for displaying detailed token information.
- Integrated token info functionality into `TokenSelectionScreen`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2026-01-06 21:15:08 +00:00
MananTank 01004e8408 [MNY-353] SDK: TransactionWidget UI improvements (#8607)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on UI improvements for the `TransactionWidget` in the `thirdweb` package, enhancing user experience with better layout, spacing, and loading indicators.

### Detailed summary
- Changed the `chain` in `simpleBuyRequest` from `baseSepolia` to `base`.
- Updated the story names and added new stories for `TransactionWidget`.
- Improved layout and spacing in `TransactionPayment`.
- Replaced static loading elements with `Skeleton` components for better loading states.
- Enhanced button styles and added a loading spinner.
- Refined contract info and address display for better clarity.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **UI Improvements**
  * Improved TransactionWidget loading states with clearer visual spinner components
  * Enhanced visual presentation and layout of transaction details, addresses, and network information
  * Refined typography, spacing, and styling throughout the widget for better readability and consistency

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-06 21:15:08 +00:00
MananTank f497b614cb [MNY-352] Playground: add swap widget iframe (#8605)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces enhancements to the `SwapWidgetPlayground`, allowing for integration with both `iframe` and `react` types. It modifies how themes and options are handled, improves URL management, and updates iframe handling for better user experience.

### Detailed summary
- Added `integrationType` to `SwapWidgetPlaygroundOptions`.
- Conditional rendering of `ColorFormGroup` based on `integrationType`.
- Created `getPreviewSrc` function to manage iframe URLs.
- Updated `persistTokenSelections` handling in various components.
- Introduced `SwapWidgetPlaygroundAsync` for async loading.
- Refactored `RightSection` to conditionally render `iframe` or `SwapWidget`.
- Implemented URL update logic based on selected integration type.
- Enhanced code generation for iframe and react integration types.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2026-01-05 21:40:36 +00:00
MananTank f344fb5fbd [MNY-351] Portal: Add swap widget iframe docs (#8604)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `Swap Widget` functionality by adding an `iframe` integration option and a new `React Component`. It updates documentation and examples for better usability and expands features like dark/light mode support and customizable token selections.

### Detailed summary
- Added `links` for `React Component` and `Iframe` in `sidebar.tsx`.
- Introduced `IframeCodePreview` component in `iframe-code-preview.tsx`.
- Updated `page.mdx` for `iframe` integration with features and examples.
- Revised `page.mdx` for `react` with improved descriptions and features.
- Enhanced API reference and examples for token selection configurations.
- Added iframe event listening instructions for swap results.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2026-01-05 21:40:36 +00:00
MananTank 9f265bf734 [MNY-344] Dashboard: Add swap-widget iframe (#8603)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID` constant and enhances the swap functionality with a `SwapWidgetEmbed` component, while refactoring the existing `Providers` component to `BridgeProvidersLite`. It also adds necessary headers for security and updates layout components.

### Detailed summary
- Added `NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID` constant in `public-envs.ts`.
- Updated `next.config.ts` to include security headers for swap widget routes.
- Introduced `BridgeProvidersLite` component in `Providers.client.tsx`.
- Created `SwapWidgetEmbed` component for swap functionality.
- Refactored `page.tsx` to utilize `BridgeProvidersLite` and `SwapWidgetEmbed`.
- Removed the old `Providers` component logic.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added a cross‑chain swap widget with page, embed component and layout, URL-prefill support, theming, and fiat currency options.
  * Added a lightweight provider wrapper for embed usage.

* **Changes**
  * Checkout widget no longer auto-connects preconfigured wallets; provider usage unified to the lightweight wrapper.

* **Chores**
  * Added CSP headers for swap widget routes.
  * Introduced public env var for swap iframe client ID.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-05 20:26:31 +00:00
MananTank e5519eaef9 Dashboard: Update bridge supported tokens count (#8602)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `supportedTokens` value in the `data.ts` file for the bridge statistics, reflecting an increase in the number of supported tokens.

### Detailed summary
- Updated `supportedTokens` from `"14,000+"` to `"17,000+"`.
- Changed `supportedTokens` from `14000` to `17000` in the `bridgeStatsNumbers` object.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Updates**
  * Increased supported tokens count from 14,000+ to 17,000+ on the bridge dashboard.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-05 16:11:01 +00:00
MananTank 805b838fd3 [MNY-346] Playground: Add CheckoutWidget iframe (#8591)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for customizable payment methods in the `checkout` widget, allowing users to select between "crypto" and "card" options. It also refines the integration type handling and enhances the iframe functionality for the checkout experience.

### Detailed summary
- Added `integrationType` option to `BridgeComponentsPlaygroundOptions`.
- Introduced `paymentMethods` parsing in `page.tsx` for checkout widget.
- Updated `LeftSection` to conditionally render `ColorFormGroup`.
- Enhanced `CheckoutWidgetEmbed` to accept `paymentMethods`.
- Modified `RightSection` to support iframe rendering for checkout.
- Created `buildCheckoutIframeUrl` function to generate URLs with payment methods.
- Updated `CodeGen` to handle iframe code generation.
- Added documentation for payment methods in `iframe` page.
- Adjusted `CheckoutPlayground` to manage integration types and theme changes.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Checkout widget: optional paymentMethods filter (crypto or card) and iframe integration.
  * Playground: tab-based switcher (React / Iframe), URL state persistence, and theme-aware preview.
  * Preview/code generation: iframe-specific preview and HTML snippet export for iframe integration.

* **Documentation**
  * Added "Payment Methods" docs for iframe integration with examples.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-30 11:17:26 +00:00
Yash094 ec73f1ff83 X402 playground chainlist (#8588)
## [Playground] Fix: Exclude deprecated chains from x402 network selector

## Notes for the reviewer

This change enables the existing `disableDeprecated` functionality of the `SingleNetworkSelector` component on the x402 playground page. This ensures that deprecated chains are no longer displayed in the network selection dropdown, consistent with patterns used elsewhere in the codebase.

## How to test

1.  Navigate to `https://playground.thirdweb.com/x402`.
2.  Open the network selector dropdown.
3.  Verify that deprecated chains (e.g., Mumbai) are not listed.

---
[Slack Thread](https://thirdwebdev.slack.com/archives/C09DS2CKGP2/p1766589810150189?thread_ts=1766589810.150189&cid=C09DS2CKGP2)

<a href="https://cursor.com/background-agent?bcId=bc-662b659b-43e5-4760-8634-cf73b25f1a02"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-662b659b-43e5-4760-8634-cf73b25f1a02"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a>

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new property to a component, enhancing its functionality by allowing it to disable deprecated features.

### Detailed summary
- Added the `disableDeprecated` property to the `X402LeftSection` component.
- This change modifies the component's behavior, likely affecting how deprecated features are handled.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Updated component configuration to disable deprecated options in the network selector.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-25 10:26:16 +00:00
saminacodes 245e9a688c delete outdated webhook docs and move webhooks to tokens (#8587)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on the addition and enhancement of webhook documentation for both `Tokens` and `Bridge` functionalities in the application, while removing outdated files.

### Detailed summary
- Deleted files related to `webhooks` in `portal/src/app`.
- Added `webhooks` section in `tokens` documentation with use cases and configuration instructions.
- Updated `bridge` documentation to reflect the correct title and description.
- Enhanced explanations for creating webhooks in both `tokens` and `bridge` contexts.
- Included images for `TokenWebhooksScreen` and `BridgeWebhookScreen`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Documentation**
  * Reorganized webhooks docs into product-specific guides: Bridge (renamed from Payments Webhooks) and Tokens.
  * Added new Token Webhooks guide with use cases, setup steps, and an illustrative image.
  * Updated Bridge Webhooks content and dashboard guidance; added visuals and clarified structure and headings.
  * Removed the generic Webhooks overview in favor of the new guides.
  * Minor wording improvements in Wallets security docs.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-23 20:49:33 +00:00
saminacodes 8bdc0c0c27 Expand FAQ with Dedicated Relayer FAQs (#8585)
Added information about gas sponsorship and dedicated relayer features.

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new content related to `Gas Sponsorship` in the `page.mdx` file, adding FAQs about the dedicated relayer service offered by thirdweb.

### Detailed summary
- Added a new section titled `Gas Sponsorship`.
- Included multiple FAQs regarding the dedicated relayer:
  - Its status as an add-on service.
  - Expected performance benchmarks.
  - Comparison to other alternatives.
  - Management of scaling on multiple relayer instances.
  - Transaction handling capacity.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Documentation**
  * Expanded wallets FAQ with a new "Gas Sponsorship" section covering dedicated relayer plan/pricing, throughput benchmarks for single vs. multi-executor setups, comparisons with alternative queue approaches, scaling guidance for multiple relayer instances, and transaction-per-second expectations. Reordered content so Gas Sponsorship appears before Security & Recoverability; Ecosystem Wallets content retained.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-23 18:52:17 +00:00
MananTank 9c04b3f180 [MNY-338] Add filters when fetching chains in chain selection ui (#8573)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `SwapWidget` and related components by improving the chain selection logic and UI responsiveness, introducing a more reliable token selection process, and adjusting the timeout for modal unmounting.

### Detailed summary
- Updated `setTimeout` duration in `onModalUnmount` to 200ms.
- Changed `useBridgeChains` to `useBridgeChain` for better chain fetching.
- Added `type` and `selections` props to various components for handling buy/sell logic.
- Implemented a new `useBridgeChainsWithFilters` function for filtered chain queries.
- Enhanced UI components to accommodate new selection logic.
- Improved loading states and mobile responsiveness in `SelectBridgeChain` and `SelectToken`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  - Bridge filtering now supports testnet and mutually exclusive origin/destination filters.
  - Token selection accepts operation context (buy/sell) and selected-chain hints.

* **Improvements**
  - Filter-driven chain lists yield more relevant token choices.
  - Loading skeletons adapt sizing for mobile and desktop.
  - Deferred buy-token assignment reduces redundant fetches during modal close.
  - Slightly longer modal unmount delay for smoother transitions.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-19 22:46:35 +00:00
MananTank 800503da68 [MNY-345] Portal: Add Checkout widget iframe docs (#8581)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `Checkout Widget` and `Swap Widget` components to enhance their functionality and improve user experience, including new image formats, additional features, and documentation updates.

### Detailed summary
- Replaced `.png` images with `.jpg` for `swap-dark` and `swap-light`.
- Added `IframeCodePreview` component for iframe integration in `Checkout Widget`.
- Updated `sendMessageToParent` function to use a single object parameter.
- Enhanced sidebar with new links for `Checkout Widget` and `Swap Widget`.
- Expanded documentation for `Checkout Widget` and `Swap Widget` with features, examples, and API references.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added an iframe code-preview component with "Code" and "Preview" tabs for live iframe examples.

* **Documentation**
  * New Checkout Widget docs (React + iframe) with integration guides, examples, options, and event-listening examples.
  * New Swap Widget docs with usage examples and API reference.
  * Swap page asset updates (image format changes).

* **Improvements**
  * Reorganized sidebar to surface Widgets (Bridge, Checkout, Swap).
  * Updated widget postMessage event payload shape — consumers should verify event format.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-19 22:19:15 +00:00
MananTank 0ff8b9ff60 [MNY-316] SDK: directly show token selection after wallet connection in bridge widgets (#8580)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR modifies the behavior of the wallet connection process in the `WalletSwitcherConnectionScreen`. After a wallet is connected, it skips the "Choose Payment" screen and directly moves to the token selection screen, enhancing user experience in bridge widgets.

### Detailed summary
- Updated the `onConnect` function to remove the call to `props.onBack()`, preventing the return to the "Choose Payment" screen.
- Changed the flow to directly transition to the token selection screen after wallet connection.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved wallet connection flow in bridge widgets. After connecting a wallet on the Choose Payment screen, users are now directed directly to token selection instead of returning to the payment screen.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-19 20:06:41 +00:00
MananTank 304e4e5be8 [MNY-348] SDK: Remove in-app wallet from default wallet list for bridge widgets (#8579)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing wallet integration in bridge components by introducing custom wallet options and modifying default wallet selections for various widgets.

### Detailed summary
- Removed in-app wallet from default wallet list for bridge widgets.
- Added `CustomWallets` component in `BuyWidget.stories.tsx` and `SwapWidget.stories.tsx`.
- Implemented `getDefaultWalletsForBridgeComponents` to return a list of wallets.
- Updated `FundWallet`, `SwapUI`, and `TransactionPayment` components to use custom wallet options.
- Introduced new wallet options in `TransactionWidget.stories.tsx`.
- Added `CustomWallets` functionality in `BridgeWidget` stories.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-12-19 19:29:48 +00:00
MananTank fdd2557663 [MNY-343] Add checkout widget iframe (#8578)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `CheckoutWidget` and related components in the `thirdweb` application, including the removal of duplicate fiat price displays, adding new environment constants, and refining query parameter parsing for improved functionality and usability.

### Detailed summary
- Removed duplicate fiat price display in `CheckoutWidget`.
- Added `NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID` constant.
- Introduced `parseQueryParams` utility for better query handling.
- Updated `CheckoutWidgetEmbed` to utilize new constants and improved query parsing.
- Added validation for required parameters in the checkout page.
- Refactored currency validation to use `isValidCurrency`.
- Modified tests to skip certain cases based on environment variables.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Embeddable checkout widget with a dedicated checkout page/layout and a new public env var for iframe client ID.
  * Content-Security-Policy headers applied to checkout widget routes.

* **Bug Fixes**
  * Removed duplicate fiat price display in the checkout button.

* **Chores / Refactor**
  * Centralized query-parameter parsing and currency validation; added input sanitization utilities.
  * Added a patch-level changeset entry.

* **Tests**
  * Marked several integration tests as skipped by default; enabled one parallel-execution test.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-19 19:29:48 +00:00
MananTank 56a1c0a467 [MNY-341] Payment Details UI improvements (#8576)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the Payment Details UI for bridge widget components in the `thirdweb` package, enhancing layout, styling, and functionality.

### Detailed summary
- Adjusted `padding` in `TokenBalanceRow.tsx`.
- Replaced `OutlineWalletIcon` with a styled `Container` in `WalletRow.tsx`.
- Updated `borderRadius` and `borderBottom` styles in `PaymentOverview.tsx`.
- Enhanced `StepConnectorArrow` styles and added `iconSize` for `ChevronDownIcon`.
- Modified `OutlineWalletIcon` definition to accept `size`, `color`, and `style` props.
- Refactored `cryptoPaymentMethod` and `ethCryptoPaymentMethod` structures for clarity.
- Updated `PaymentDetails.tsx` styles, including `gap`, `borderRadius`, and button styles.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved bridge payment details UI with enhanced spacing, borders, and styling
  * Updated wallet row display with better visual presentation when disconnected
  * Refined typography and color usage across payment interface components
  * Enhanced border radius and padding consistency throughout bridge widget

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-19 12:57:51 +00:00
saminacodes 6e87b9205f added audits (#8577)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds documentation for building and deploying tokens programmatically, generating bridge links, and auditing wallets. It enhances the user experience by providing clear instructions and examples for developers.

### Detailed summary
- Added a section titled `Build with tokens` in `page.mdx`.
- Introduced `Deploy Programmatically through API` section with details on ERC-20, ERC-721, and ERC-1155 tokens in `deploy-programmatically/page.mdx`.
- Included a guide on generating bridge links with steps in `link/page.mdx`.
- Added `Wallets Audits` section with links to audit reports in `security/page.mdx`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Documentation**
  * Added Bridge Link guide with step-by-step instructions for generating payment links and hosting them on websites
  * Added Build with Tokens guide
  * Added Deploying Tokens Programmatically guide with API examples for ERC-20, ERC-721, and ERC-1155 tokens
  * Added Wallet Security Audits section highlighting audit reports

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-18 20:59:07 +00:00
MananTank 9383ccdf4e Portal: Add bridge widget playground links (#8575)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `Bridge Widget` to enhance its integration options and improve user experience by adding a live playground feature. It modifies the integration types, updates the code logic, and introduces new UI components for better functionality.

### Detailed summary
- Changed `href` in `sidebar.tsx` for the Playground link.
- Updated `integrationType` options in `types.ts` to include "react".
- Adjusted code language conditions in `code.tsx` based on `integrationType`.
- Added `PlayIcon` and `ArticleIconCard` components in multiple `.mdx` files.
- Introduced a "Try it out" section in several `.mdx` files for the Playground.
- Modified the `Page` component to accept `searchParams` and determine the valid tab.
- Enhanced `BridgeWidgetPlayground` to manage URL updates based on the selected integration type.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Playground now supports URL parameters to pre-select integration tabs and keeps the browser URL in sync when switching tabs
  * React integration tab standardized and labeled consistently with other tabs

* **Documentation**
  * Added "Try it out" call-to-action cards throughout docs linking to the live playground

* **Chores**
  * Sidebar playground link updated to point to the bridge-specific playground URL

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-18 18:08:11 +00:00
eabdelmoneim ac326e24f1 [Portal] Fix Transactions link in Archived Documentation section (#8574)
https://linear.app/thirdweb/issue/PRO-149/fix-archive-link-for-transactions

Update the Transactions link to point to /engine instead of /wallets/server/send-transactions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `href` property of the `ArticleCardIndex` component in the `page.tsx` file to redirect users to a new location.

### Detailed summary
- Changed the `href` property of the `ArticleCardIndex` from `"/wallets/server/send-transactions"` to `"/engine"`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Chores**
  * Updated the Transactions card navigation link destination in the Archive section.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-18 17:23:51 +00:00
FirekeeperandGitHub 7ebc32a0c4 Improve portfolio fetch concurrency and error handling (#8572) 2025-12-17 06:06:03 +07:00
FirekeeperandGitHub 5f0e25b5a8 Add wallet portfolio fetching and display to user table (#8570) 2025-12-17 05:31:39 +07:00
MananTank aad3e6477e [MNY-226] Add Bridge Widget playgrounds (script, iframe, react) (#8571)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `BridgeWidget` component within the playground by introducing new features, improving styling, and refining existing functionalities. It also includes updates to metadata and the handling of token selections and amounts.

### Detailed summary
- Removed `PayIcon` and replaced with `BringToFrontIcon`.
- Added `showThirdwebBranding` option to `UniversalBridgeEmbed`.
- Updated `BridgeWidget` to support token amounts and selections.
- Enhanced sorting of bridge chains in `chains.ts`.
- Improved styling for various components, including `FeatureCard` and `ChatPageContent`.
- Updated documentation for widget parameters.
- Refactored code for better readability and maintainability in multiple components.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Bridge Widget playground: interactive preview, code generation (script/component/iframe), theme, token/amount prefills, and preview modes.
  * Unified buy/swap experience under a single Bridge widget and a new branding toggle (hide/show branding).
  * Expanded connection options for wallet/connect flows.

* **Documentation**
  * Added docs for token amounts, branding, and iframe examples.

* **Style**
  * Minor typography and spacing refinements.

* **Chores**
  * Navigation and feature card updates; small test adjustment (one image test skipped).

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-16 20:42:47 +00:00
MananTank 4bab6f1ae8 [PRO-125] Dashboard: Update token creation modal UI (#8561)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the UI components in the dashboard by improving dialog descriptions, layout adjustments, and introducing images for token cards, along with some styling updates.

### Detailed summary
- Added `DialogDescription` to provide context in the dialog.
- Increased `DialogContent` max width from `!max-w-md` to `!max-w-2xl`.
- Changed layout of `Cards` from a column to a grid.
- Introduced `images` for token cards with light and dark themes.
- Updated descriptions for token creation.
- Enhanced styling of `CardLink` components, including rounded corners.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added theme-aware image rendering with light and dark mode support for card components.
  * Implemented responsive 2-column grid layout on larger screens.

* **Style**
  * Increased border radius on cards and dialog components for a more refined appearance.
  * Enhanced card title typography sizing.
  * Expanded dialog width for improved content display.
  * Improved responsive behavior with compact icons on smaller viewports.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-15 18:38:38 +00:00
MananTank f9a435051d [MNY-336] Portal: Fix spacing issue on menu with link (#8560)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the sidebar configuration and adjusting styles in the `Sidebar` component for better layout and design.

### Detailed summary
- In `apps/portal/src/app/bridge/sidebar.tsx`:
  - Added an entry for `name: "Guides"` in the sidebar configuration.
- In `apps/portal/src/components/others/Sidebar.tsx`:
  - Updated the `className` of a `div` to include `py-1.5` and `px-3`.
  - Modified the `className` of a `Link` to remove padding classes `py-1.5` and `px-3`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Style**
  * Improved sidebar category spacing with enhanced padding distribution
  * Expanded interactive click target area for sidebar navigation items
  * Better visual organization of the Guides section

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-15 17:55:21 +00:00
MananTank 27d72e5d3e [PRO-124] Dashboard: Project Wallet UI tweaks (#8559)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining the user interface and functionality of the `ProjectWallet` component in the dashboard application. It includes style adjustments, component updates, and the introduction of new dropdown menu options for better user interaction.

### Detailed summary
- Adjusted the size of `ChevronDownIcon` in `select-with-search.tsx`.
- Removed `XIcon` from `project-wallet.tsx` and updated the heading style.
- Renamed `projectWallet` variables to `serverWallet` in `project-wallet.stories.tsx`.
- Introduced a new `Variant` function for rendering `ProjectWalletSectionUI`.
- Added new icons in `project-wallet-details.tsx` and refined the dropdown menu.
- Enhanced wallet detail presentation with better layout and added new dropdown options for actions like Deposit, Withdraw, and Change Project Wallet.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added dropdown menu with wallet action options: Deposit Funds, Withdraw Funds, Swap Tokens, View Transactions, and Change Project Wallet.
  * Added copy-address button in the wallet header.

* **Style**
  * Redesigned wallet header layout with improved visual structure.
  * Refined icon sizing and spacing throughout the wallet interface.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-15 17:44:00 +00:00
MananTank 151127d668 [PRO-144] SDK: Update in-app wallet icon in wide connect ui (#8556)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the in-app wallet feature by updating icons, adding new components for different authentication methods, and improving the UI for wallet connection options.

### Detailed summary
- Updated the in-app wallet icon in the wide connect UI.
- Changed `defaultAuthOptions` from a constant to an exported variable.
- Modified `EmailIcon` component for better styling and structure.
- Added new functions for various modal configurations (`ConfiguredInAppWalletWideModal`, `GoogleLoginWideModal`, `GithubLoginWideModal`).
- Introduced `InAppWalletIcon` component to handle multiple authentication methods visually.
- Created comprehensive storybook examples for `InAppWalletIcon` with various authentication options.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added an in-app wallet icon that displays available authentication methods and adapts layout for single vs. multiple options.

* **UI/UX Improvements**
  * Updated wallet icon visuals and dynamic color handling for more consistent rendering in the connect modal.
  * Simplified email icon visuals for clearer strokes.

* **Stories**
  * Added Storybook examples showcasing the in-app wallet icon across themes and auth-option permutations.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 21:03:26 +00:00
MananTank 759f41b11e Dashboard: Update Bridge get started section (#8555)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `QuickstartSection.client.tsx` file to improve the presentation of the bridge widget feature. It modifies the `FeatureCard` components to enhance descriptions, titles, and links for better clarity and usability.

### Detailed summary
- Changed the title of the first `FeatureCard` from "Buy Widget Component" to "Bridge Widget".
- Updated the description of the first `FeatureCard` to "Integrate cross-chain swaps and fiat onramp into your app using bridge widget".
- Altered features in the first `FeatureCard` to include "Integrate with iframe, script, or React component".
- Updated the link URL in the first `FeatureCard` to point to the bridge widget documentation.
- Removed the "Customizable UI" feature from the first `FeatureCard`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Updates**
  * Bridge Widget quickstart section now supports integration via iframe, script tag, and React component.
  * Enhanced feature card descriptions for improved clarity in the onboarding flow.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 19:22:00 +00:00
MananTank 5ec84bd3b6 [MNY-314] SDK: Token Selection UX improvements in SwapWidget (#8552)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the user experience in the `SwapWidget` by automatically selecting the native token when no sell or buy token is selected.

### Detailed summary
- Added logic to set the `sellToken` as the native token of the buy token's chain if no sell token is selected.
- Added logic to set the `buyToken` as the native token of the sell token's chain if no buy token is selected.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-12-12 18:49:07 +00:00
MananTank 38f4a89076 [MNY-334] SDK: Fix Spinner animation missing sometimes in Next.js (#8553)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR addresses an issue with the `Spinner` component's animation in a Next.js environment, ensuring that the animation works consistently.

### Detailed summary
- Updated the `Spinner` component to use a `<circle>` element instead of a `Circle` styled component.
- Removed the `StyledCircle` import from `elements.js`.
- Added animation styles directly to the `<circle>` element.
- Defined the `tw-spinner-circle-dash` keyframe animation within the component.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-12-12 18:35:49 +00:00
MananTank 31e24f1c58 [MNY-331] SDK: Alphabetically sort chains in SwapWidget UI (#8551)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the `SwapWidget` UI by alphabetically sorting the chains returned by the `use-bridge-chains.ts` module, ensuring that chains with names starting with numbers are placed at the end of the list.

### Detailed summary
- Updated the `queryFn` in `use-bridge-chains.ts` to be asynchronous.
- Introduced a copy of the fetched data to sort it.
- Implemented sorting logic to place chains starting with numbers at the end.
- Used `localeCompare` for alphabetical sorting of chain names.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Chains in the SwapWidget are now alphabetically sorted for improved navigation; chain names beginning with digits appear after alphabetic entries.

* **Chores**
  * Patch release entry added to the changelog.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 18:35:49 +00:00
MananTank 2bb6a4f9a3 [MNY-332] Dashboard: Checkout link error message improvements (#8550)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances error handling in the `billing.ts` files by checking for a specific error structure in the JSON response. If the error is present and formatted correctly, it returns a detailed error message instead of a generic one.

### Detailed summary
- Added error handling for JSON responses in `apps/dashboard/src/@/actions/billing.ts`:
  - Checks if `error` and `message` exist in the response.
  - Validates that `json.error.message` is a string.
  - Returns a structured error object with the message and status.

- Similar changes were made in `apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Bug Fixes
* Improved error message handling for billing operations to display API-provided error messages to users instead of generic fallback errors, enabling clearer feedback when checkout transactions encounter issues.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 17:56:56 +00:00
eabdelmoneim e281ed185e [Playground] Fix bridge/payments documentation links (#8549)
https://linear.app/thirdweb/issue/PRO-148/fix-playground-doc-links-for-bridge-components

Update documentation links for bridge/payments widgets to follow consistent naming convention:
- Buy Widget: wallets/sponsor-gas → references/typescript/v5/BuyWidget
- Checkout Widget: payments → references/typescript/v5/CheckoutWidget
- Transaction Widget: wallets/sponsor-gas → references/typescript/v5/TransactionWidget
- Transaction Button: Add missing utm_source=playground parameter

All links now point to /references/typescript/v5/{WidgetName} with utm_source tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `docsLink` properties in several components to point to the correct documentation pages for various widgets in the `playground-web` application.

### Detailed summary
- Updated `docsLink` in `apps/playground-web/src/app/bridge/buy-widget/page.tsx` to point to `BuyWidget`.
- Updated `docsLink` in `apps/playground-web/src/app/bridge/checkout-widget/page.tsx` to point to `CheckoutWidget`.
- Updated `docsLink` in `apps/playground-web/src/app/bridge/transaction-button/page.tsx` to include `?utm_source=playground`.
- Updated `docsLink` in `apps/playground-web/src/app/bridge/transaction-widget/page.tsx` to point to `TransactionWidget`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Documentation**
  * Updated documentation links across playground widgets to reference current TypeScript v5 documentation resources.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 17:52:12 +00:00
MananTank 116975286f [MNY-327] SDK: Display error returned from API in login with phone number UI (#8548)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling and state management in the OTP authentication process. It enhances the way error messages are displayed and modifies the structure of `AccountStatus` to accommodate additional information.

### Detailed summary
- In `otp.ts`, enhanced error handling by parsing the response for a detailed message.
- Updated `AccountStatus` type in `OTPLoginUI.tsx` to an object structure, allowing for an error message.
- Modified state management for `accountStatus` to use the new object structure.
- Adjusted UI rendering logic to handle the new `AccountStatus` format.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Enhanced error message handling in OTP verification to display detailed server error messages to users instead of generic messages.
  * Improved error status display in OTP login interface.

* **Style**
  * Updated OTP login UI layout and spacing for better status presentation.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 17:08:22 +00:00
FirekeeperandGitHub ff8f4130e9 Add Dedicated Relayer docs and sidebar link (#8546) 2025-12-12 23:54:19 +07:00
MananTank ffd8c402f4 Dashboard: Fix webhooks link in project/contracts page (#8547)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the URL structure for webhooks in the `page.tsx` file, changing the path from `/webhooks/contracts` to `/tokens/webhooks`.

### Detailed summary
- Modified the `href` property in the object from:
  - `href: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts``
  - to `href: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks``

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
  * Fixed the "webhooks" navigation link in the Contracts page to correctly redirect to the tokens section instead of the contracts section.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 16:32:05 +00:00
MananTank d365efe997 Update Next.js version (#8545)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily updates various dependencies across multiple `package.json` files, particularly upgrading the `next` version from `15.3.6` to `15.3.8` and `@next/eslint-plugin-next` from `15.3.6` to `15.3.8`. It also modifies the `pnpm-lock.yaml` to reflect these updates.

### Detailed summary
- Updated `next` from `15.3.6` to `15.3.8` in:
  - `packages/ui/package.json`
  - `apps/wallet-ui/package.json`
  - `apps/portal/package.json`
  - `apps/playground-web/package.json`
  - `apps/nebula/package.json`
  - `apps/dashboard/package.json`
- Updated `@next/eslint-plugin-next` from `15.3.6` to `15.3.8` in:
  - `apps/wallet-ui/package.json`
  - `apps/nebula/package.json`
  - `apps/dashboard/package.json`
- Updated `@next/mdx` from `15.3.6` to `15.3.8` in `apps/portal/package.json`.
- Adjusted `pnpm-lock.yaml` to reflect the above dependency updates.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Chores**
  * Bumped Next.js and related Next packages (minor/patch updates) across multiple apps and packages for stability and consistency.
  * Normalized package manifest formatting across projects; no functional or public API changes introduced.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-12 11:06:15 +00:00
FirekeeperandGitHub 1558b48056 relayer prod (#8535) 2025-12-12 06:14:14 +07:00
FirekeeperandGitHub 3fba7e4e88 relayer updates (#8541) 2025-12-12 05:41:59 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
57ccadbef2 Version Packages (#8539)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-11 02:52:55 -08:00
Joaquim VergesandGitHub 20e41ee164 [SDK] Fix null options handling in useFetchWithPayment hook (#8540) 2025-12-11 02:47:50 -08:00
Joaquim VergesandGitHub f5ab57f365 [SDK] Cache and reuse x402 permit signatures for upto schemes (#8538) 2025-12-11 02:40:55 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
01dce0e581 Version Packages (#8536)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-10 17:43:25 -08:00
Joaquim VergesandGitHub 18425f9d74 [SDK] Add minPrice property for x402 payments using 'upto' schema (#8534) 2025-12-10 17:36:03 -08:00
Joaquim VergesandGitHub bd2761af15 [Portal] Improve documentation for upto payment scheme (#8531) 2025-12-10 16:06:05 -08:00
FirekeeperandGitHub f3d2739df1 Improve relayer analytics (#8533) 2025-12-11 06:47:03 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1aa4912b17 Version Packages (#8532)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-11 04:36:33 +07:00
7967d7f17e Dedicated Relayer (#8479)
Co-authored-by: Manan Tank <[email protected]>
2025-12-11 04:26:03 +07:00
Joaquim VergesandGitHub 1487de6808 Fix typo in "Create Project" button text (#8530) 2025-12-10 10:53:06 -08:00
MananTank 18917b632a Fix bridge iframe config (#8529)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates references from `/bridge/embed` to `/bridge/widget` in the configuration files, reflecting a change in the iframe page used in the application.

### Detailed summary
- Updated `PAYMENT_METHOD_CONFIGURATION` comment in `.env.example` from `/bridge/embed` to `/bridge/widget`.
- Changed `source` in `next.config.ts` from `/bridge/embed` to `/bridge/widget`.
- Updated `source` path pattern in `next.config.ts` from `/bridge/embed/:path*` to `/bridge/widget/:path*`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Documentation**
  * Updated configuration documentation.

* **Chores**
  * Updated internal routing paths in application configuration.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-10 18:35:11 +00:00
MananTank 41919024c3 Add bridge widget iframe docs, update path, link to docs (#8528)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `Bridge` and `Bridge Widget` functionalities, improving UI elements, adding new features, and updating documentation. It includes modifications to components, styling adjustments, and expanded capabilities for currency selection.

### Detailed summary
- Updated `Sidebar.tsx` to remove padding.
- Changed `page.mdx` headers from "Key Features" to "Features".
- Enhanced `reportBridgePageLinkClick` to include "integrate-bridge-widget".
- Modified `page.tsx` to include client ID instructions.
- Adjusted header font sizes in `header.tsx`.
- Improved `bridgeRedirects` to include new paths.
- Updated `bridge-widget` links in `sidebar.tsx`.
- Added `persistTokenSelections` and `currency` props to `UniversalBridgeEmbed`.
- Created `IframeCodePreview` component for iframe integration documentation.
- Added a new `BadgeLink` component for better user interaction.
- Expanded `BuyAndSwapEmbed` props to support currency and token selections.
- Updated documentation for `BridgeWidget` and `Iframe` integrations with examples.
- Enhanced `BridgePageUI` to include the new `AddBridgeWidgetLink`.
- Introduced multiple currency options for fiat values in widget interfaces.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Bridge widget available via Iframe, Script, and React component
  * Fiat currency selection for buy/swap widgets
  * Token selection persistence option
  * "Add Bridge widget" integration link added to Bridge page

* **Documentation**
  * New detailed guides and examples for Iframe, Script, and React integration
  * Code preview and embedding examples added

* **Style**
  * Typography, spacing, and layout refinements across Bridge pages
  * Navigation reorganized into a Bridge Widget group

* **Chores**
  * Added redirects for Bridge widget paths

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-10 17:53:09 +00:00
471 changed files with 19728 additions and 5743 deletions
+1 -1
View File
@@ -67,5 +67,5 @@ STRIPE_SECRET_KEY=""
GROWTH_PLAN_SKU=""
PAYMENT_METHOD_CONFIGURATION=""
# required for bridge iframe (/bridge/embed) page
# required for bridge iframe (/bridge/widget) page
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID=""
+56 -2
View File
@@ -150,7 +150,7 @@ const baseNextConfig: NextConfig = {
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/embed",
source: "/bridge/widget",
},
{
headers: [
@@ -159,7 +159,61 @@ const baseNextConfig: NextConfig = {
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/embed/:path*",
source: "/bridge/widget/:path*",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/checkout-widget",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/checkout-widget/:path*",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/swap-widget",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/swap-widget/:path*",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/buy-widget",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/buy-widget/:path*",
},
];
},
+130 -130
View File
@@ -1,132 +1,132 @@
{
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^7.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.2.1",
"react-dropzone": "^14.3.8",
"react-error-boundary": "6.0.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-table": "^7.8.0",
"recharts": "2.15.3",
"remark-gfm": "4.0.1",
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.6",
"@next/eslint-plugin-next": "15.3.6",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/color": "4.2.0",
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit"
},
"version": "3.0.0"
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.525.0",
"next": "15.3.8",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^7.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.2.1",
"react-dropzone": "^14.3.8",
"react-error-boundary": "6.0.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-table": "^7.8.0",
"recharts": "2.15.3",
"remark-gfm": "4.0.1",
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.8",
"@next/eslint-plugin-next": "15.3.8",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/color": "4.2.0",
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit"
},
"version": "3.0.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+12
View File
@@ -109,6 +109,18 @@ export async function getChainInfraCheckoutURL(options: {
}
const json = await res.json();
if (
"error" in json &&
"message" in json.error &&
typeof json.error.message === "string"
) {
return {
error: json.error.message,
status: "error",
} as const;
}
if (!json.result) {
return {
error: "An unknown error occurred, please try again later.",
+1 -1
View File
@@ -683,7 +683,7 @@ export function reportProductFeedback(properties: {
*
*/
export function reportBridgePageLinkClick(params: {
linkType: "bridge-docs" | "trending-tokens";
linkType: "bridge-docs" | "trending-tokens" | "integrate-bridge-widget";
}) {
posthog.capture("bridge page link clicked", params);
}
@@ -105,6 +105,7 @@ type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
export type Partner = {
id: string;
name: string;
imageUrl?: string;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
permissions: [PartnerPermission];
@@ -6,13 +6,16 @@ export const StatCard: React.FC<{
icon: React.FC<{ className?: string }>;
formatter?: (value: number) => string;
isPending: boolean;
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
emptyText?: string;
}> = ({ label, value, formatter, icon: Icon, isPending, emptyText }) => {
return (
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
<div>
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
{isPending ? (
<Skeleton className="h-8 w-20" />
) : emptyText ? (
<span className="text-muted-foreground">{emptyText}</span>
) : value !== undefined && formatter ? (
formatter(value)
) : (
@@ -2,9 +2,8 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import { defineChain } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import { useEffect, useMemo, useRef } from "react";
import { BridgeWidget, type SupportedFiatCurrency } from "thirdweb/react";
import type { Wallet } from "thirdweb/wallets";
import {
reportAssetBuyCancelled,
@@ -18,14 +17,12 @@ import {
reportTokenSwapFailed,
reportTokenSwapSuccessful,
} from "@/analytics/report";
import { Button } from "@/components/ui/button";
import {
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID,
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
} from "@/constants/public-envs";
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { appMetadata } from "../../constants/connect";
@@ -34,6 +31,7 @@ import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
type PageType = "asset" | "bridge" | "chain" | "bridge-iframe";
export type BuyAndSwapEmbedProps = {
persistTokenSelections?: boolean;
buyTab:
| {
buyToken:
@@ -65,11 +63,12 @@ export type BuyAndSwapEmbedProps = {
| undefined;
pageType: PageType;
wallets?: Wallet[];
currency?: SupportedFiatCurrency;
showThirdwebBranding?: boolean;
};
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
const isMounted = useRef(false);
@@ -102,208 +101,161 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
}, [props.pageType]);
return (
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl min-w-0">
<div className="flex gap-2.5 p-4 border-b border-dashed">
<TabButton
label="Swap"
onClick={() => setTab("swap")}
isActive={tab === "swap"}
/>
<TabButton
label="Buy"
onClick={() => setTab("buy")}
isActive={tab === "buy"}
/>
</div>
<BridgeWidget
client={client}
theme={themeObj}
className="z-10 min-w-0 shadow-xl"
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
buy={
props.buyTab
? {
amount: props.buyTab.buyToken?.amount,
chainId: props.buyTab.buyToken?.chainId,
tokenAddress: props.buyTab.buyToken?.tokenAddress,
onError: (e, quote) => {
const errorMessage = parseError(e);
{tab === "buy" && (
<BuyWidget
amount={props.buyTab?.buyToken?.amount || "1"}
chain={
props.buyTab?.buyToken?.chainId
? defineChain(props.buyTab.buyToken.chainId)
: undefined
}
tokenAddress={
props.buyTab?.buyToken?.tokenAddress as `0x${string}` | undefined
}
className="!rounded-2xl !border-none"
title=""
client={client}
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
onError={(e, quote) => {
const errorMessage = parseError(e);
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
if (!buyChainId) {
return;
reportTokenBuyFailed({
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: buyChainId,
error: errorMessage,
contractType: undefined,
is_testnet: false,
});
}
},
onCancel: (quote) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuyCancelled({
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: buyChainId,
contractType: undefined,
is_testnet: false,
});
}
},
onSuccess: ({ quote }) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuySuccessful({
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: buyChainId,
contractType: undefined,
is_testnet: false,
});
}
},
}
reportTokenBuyFailed({
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: buyChainId,
error: errorMessage,
contractType: undefined,
is_testnet: false,
});
}
}}
onCancel={(quote) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuyCancelled({
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: buyChainId,
contractType: undefined,
is_testnet: false,
});
}
}}
onSuccess={({ quote }) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuySuccessful({
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: buyChainId,
contractType: undefined,
is_testnet: false,
});
}
}}
theme={themeObj}
paymentMethods={["card"]}
/>
)}
{tab === "swap" && (
<SwapWidget
client={client}
theme={themeObj}
className="!rounded-2xl !border-none"
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
prefill={{
buyToken: props.swapTab?.buyToken,
sellToken: props.swapTab?.sellToken,
}}
onError={(error, quote) => {
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,
});
}}
/>
)}
</div>
);
}
function TabButton(props: {
label: string;
onClick: () => void;
isActive: boolean;
}) {
return (
<Button
onClick={props.onClick}
size="sm"
className={cn(
"rounded-full text-muted-foreground px-4 bg-accent",
props.isActive && "text-foreground border-foreground/50",
)}
variant="outline"
>
{props.label}
</Button>
: undefined
}
swap={{
persistTokenSelections: props.persistTokenSelections,
prefill: {
buyToken: props.swapTab?.buyToken,
sellToken: props.swapTab?.sellToken,
},
onError: (error, quote) => {
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,
});
},
}}
currency={props.currency}
showThirdwebBranding={props.showThirdwebBranding}
/>
);
}
@@ -125,7 +125,7 @@ export const SelectWithSearch = React.forwardRef<
? renderOption(selectedOption)
: selectedOption?.label || placeholder}
</span>
<ChevronDownIcon className="size-4 cursor-pointer text-muted-foreground" />
<ChevronDownIcon className="size-3.5 cursor-pointer text-muted-foreground shrink-0" />
</div>
</Button>
</PopoverTrigger>
@@ -2,14 +2,25 @@
import { createColumnHelper } from "@tanstack/react-table";
import { format } from "date-fns";
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
import {
ArrowLeftIcon,
ArrowRightIcon,
DollarSignIcon,
RefreshCwIcon,
UserIcon,
WalletIcon,
} from "lucide-react";
import Papa from "papaparse";
import { useCallback, useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { WalletUser } from "thirdweb/wallets";
import { StatCard } from "@/components/analytics/stat";
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { TWTable } from "@/components/blocks/TWTable";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/Spinner";
import {
ToolTipLabel,
@@ -22,6 +33,10 @@ import {
useAllEmbeddedWallets,
useEmbeddedWallets,
} from "@/hooks/useEmbeddedWallets";
import {
useFetchAllPortfolios,
type WalletPortfolioData,
} from "@/hooks/useWalletPortfolio";
import { CopyTextButton } from "../ui/CopyTextButton";
import { AdvancedSearchInput } from "./AdvancedSearchInput";
import { SearchResults } from "./SearchResults";
@@ -74,6 +89,101 @@ export function UserWalletsTable(
| { ecosystemSlug: string; projectClientId?: never }
),
) {
const [activePage, setActivePage] = useState(1);
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearchResults, setHasSearchResults] = useState(false);
// Portfolio state
const [selectedChains, setSelectedChains] = useState<number[]>([1]); // Default to Ethereum
const [portfolioMap, setPortfolioMap] = useState<
Map<string, WalletPortfolioData>
>(new Map());
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
const [fetchProgress, setFetchProgress] = useState({
completed: 0,
total: 0,
});
const walletsQuery = useEmbeddedWallets({
authToken: props.authToken,
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
page: activePage,
});
const wallets = walletsQuery?.data?.users || [];
const { mutateAsync: getAllEmbeddedWallets, isPending: isLoadingAllWallets } =
useAllEmbeddedWallets({
authToken: props.authToken,
});
const fetchPortfoliosMutation = useFetchAllPortfolios();
const handleFetchBalances = useCallback(async () => {
if (selectedChains.length === 0) return;
try {
// First get all wallets
const allWallets = await getAllEmbeddedWallets({
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
});
const allAddresses = allWallets
.map((w) => w.wallets[0]?.address)
.filter((a): a is string => !!a);
if (allAddresses.length === 0) {
setPortfolioLoaded(true);
return;
}
setFetchProgress({ completed: 0, total: allAddresses.length });
const results = await fetchPortfoliosMutation.mutateAsync({
addresses: allAddresses,
chainIds: selectedChains,
authToken: props.authToken,
teamId: props.teamId,
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
onProgress: (completed, total) => {
setFetchProgress({ completed, total });
},
});
setPortfolioMap(results);
setPortfolioLoaded(true);
} catch (error) {
console.error("Failed to fetch balances:", error);
}
}, [
selectedChains,
getAllEmbeddedWallets,
props.projectClientId,
props.ecosystemSlug,
props.teamId,
props.authToken,
fetchPortfoliosMutation,
]);
const isFetchingBalances =
isLoadingAllWallets || fetchPortfoliosMutation.isPending;
const aggregatedStats = useMemo(() => {
let fundedWallets = 0;
let totalValue = 0;
portfolioMap.forEach((data) => {
if (data.totalUsdValue > 0) {
fundedWallets++;
totalValue += data.totalUsdValue;
}
});
return { fundedWallets, totalValue };
}, [portfolioMap]);
const columns = useMemo(() => {
return [
columnHelper.accessor("id", {
@@ -129,6 +239,79 @@ export function UserWalletsTable(
header: "Address",
id: "address",
}),
columnHelper.accessor("wallets", {
id: "total_balance",
header: "Total Balance",
cell: (cell) => {
const address = cell.getValue()[0]?.address;
if (!address) return "N/A";
if (!portfolioLoaded) {
return <span className="text-muted-foreground text-sm">—</span>;
}
const data = portfolioMap.get(address);
if (!data) {
return <span className="text-muted-foreground text-sm">—</span>;
}
return (
<span className="text-sm">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(data.totalUsdValue)}
</span>
);
},
}),
columnHelper.accessor("wallets", {
id: "tokens",
header: "Tokens",
cell: (cell) => {
const address = cell.getValue()[0]?.address;
if (!address) return "N/A";
if (!portfolioLoaded) {
return <span className="text-muted-foreground text-sm">—</span>;
}
const data = portfolioMap.get(address);
if (!data || data.tokens.length === 0) {
return <span className="text-muted-foreground text-sm">None</span>;
}
const topTokens = data.tokens
.sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0))
.slice(0, 3)
.map((t) => t.symbol)
.join(", ");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="text-sm">
{topTokens}
{data.tokens.length > 3 ? "..." : ""}
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-1">
{data.tokens.map((t) => (
<div
key={`${t.tokenAddress}-${t.chainId}`}
className="flex justify-between gap-4 text-xs"
>
<span>{t.symbol}</span>
<span>
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(t.usdValue || 0)}
</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
}),
columnHelper.accessor("linkedAccounts", {
cell: (cell) => {
const email = getPrimaryEmail(cell.getValue());
@@ -201,24 +384,7 @@ export function UserWalletsTable(
id: "login_methods",
}),
];
}, [props.client]);
const [activePage, setActivePage] = useState(1);
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearchResults, setHasSearchResults] = useState(false);
const walletsQuery = useEmbeddedWallets({
authToken: props.authToken,
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
page: activePage,
});
const wallets = walletsQuery?.data?.users || [];
const { mutateAsync: getAllEmbeddedWallets, isPending } =
useAllEmbeddedWallets({
authToken: props.authToken,
});
}, [props.client, portfolioMap, portfolioLoaded]);
const handleSearch = async (searchType: SearchType, query: string) => {
setIsSearching(true);
@@ -315,11 +481,11 @@ export function UserWalletsTable(
</div>
<Button
className="gap-2 bg-background rounded-full"
disabled={wallets.length === 0 || isPending}
disabled={wallets.length === 0 || isLoadingAllWallets}
onClick={downloadCSV}
variant="outline"
>
{isPending && <Spinner className="size-4" />}
{isLoadingAllWallets && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
@@ -330,6 +496,83 @@ export function UserWalletsTable(
<SearchResults results={searchResults} client={props.client} />
) : (
<>
{/* Chain Selector and Fetch Button */}
<div className="flex items-center gap-3 px-4 lg:px-6 pb-4 border-b border-border">
<div className="flex items-center gap-2">
<MultiNetworkSelector
client={props.client}
selectedChainIds={selectedChains}
onChange={setSelectedChains}
disableChainId
hideTestnets
popoverContentClassName="max-h-[300px]"
/>
<Button
onClick={() => handleFetchBalances()}
disabled={
isFetchingBalances ||
selectedChains.length === 0 ||
walletsQuery.isPending
}
className="gap-2"
>
{isFetchingBalances ? (
<Spinner className="size-4" />
) : (
<RefreshCwIcon className="size-4" />
)}
{isFetchingBalances
? `Fetching... ${fetchProgress.total > 0 ? Math.round((fetchProgress.completed / fetchProgress.total) * 100) : 0}%`
: "Fetch All Balances"}
</Button>
</div>
{isFetchingBalances && (
<div className="flex flex-col gap-1 flex-1 min-w-[150px]">
{fetchProgress.total > 0 && (
<Progress
value={
(fetchProgress.completed / fetchProgress.total) * 100
}
className="h-2"
/>
)}
<p className="text-xs text-muted-foreground">
This may take a few minutes
</p>
</div>
)}
{portfolioLoaded && !isFetchingBalances && (
<Badge variant="success" className="ml-auto">
Balances loaded for {portfolioMap.size} wallets
</Badge>
)}
</div>
{/* Stats Section */}
<div className="grid grid-cols-2 gap-4 px-4 lg:px-6 py-4">
<StatCard
label="Funded Wallets"
value={portfolioLoaded ? aggregatedStats.fundedWallets : 0}
icon={WalletIcon}
isPending={isFetchingBalances}
emptyText={!portfolioLoaded ? "—" : undefined}
/>
<StatCard
label="Total Value"
value={portfolioLoaded ? aggregatedStats.totalValue : 0}
icon={DollarSignIcon}
formatter={(value) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value)
}
isPending={isFetchingBalances}
emptyText={!portfolioLoaded ? "—" : undefined}
/>
</div>
<TWTable
columns={columns}
data={wallets}
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
<DialogOverlay className={dialogOverlayClassName} />
<DialogPrimitive.Content
className={cn(
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-xl",
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
"right-0 bottom-0 left-0",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
@@ -44,3 +44,12 @@ export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID;
export const NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID;
export const NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID;
+10 -14
View File
@@ -330,20 +330,16 @@ export async function rotateSecretKeyClient(params: { project: Project }) {
throw new Error(res.error);
}
try {
// if the project has an encrypted vault admin key, rotate it as well
const service = params.project.services.find(
(service) => service.name === "engineCloud",
);
if (service?.encryptedAdminKey) {
await rotateVaultAccountAndAccessToken({
project: params.project,
projectSecretKey: res.data.data.secret,
projectSecretHash: res.data.data.secretHash,
});
}
} catch (error) {
console.error("Failed to rotate vault admin key", error);
// if the project has an encrypted vault admin key, rotate it as well
const service = params.project.services.find(
(service) => service.name === "engineCloud",
);
if (service?.encryptedAdminKey) {
await rotateVaultAccountAndAccessToken({
project: params.project,
projectSecretKey: res.data.data.secret,
projectSecretHash: res.data.data.secretHash,
});
}
return res.data;
@@ -177,33 +177,63 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
}) => {
const responses: WalletUser[] = [];
let page = 1;
let consecutiveFailures = 0;
const maxConsecutiveFailures = 3;
while (true) {
const res = await queryClient.fetchQuery<{
users: WalletUser[];
hasMore: boolean;
}>({
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId || ecosystemSlug || "",
page,
),
});
while (consecutiveFailures < maxConsecutiveFailures) {
try {
const res = await queryClient.fetchQuery<{
users: WalletUser[];
hasMore: boolean;
}>({
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId || ecosystemSlug || "",
page,
),
retry: 3,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 10000),
});
responses.push(...res.users);
responses.push(...res.users);
consecutiveFailures = 0; // Reset on success
if (!res.hasMore) {
break;
if (!res.hasMore) {
break;
}
page++;
} catch (error) {
consecutiveFailures++;
console.warn(
`Failed to fetch page ${page}, attempt ${consecutiveFailures}/${maxConsecutiveFailures}:`,
error,
);
if (consecutiveFailures >= maxConsecutiveFailures) {
// If we have some data, return it instead of throwing
if (responses.length > 0) {
console.warn(
`Returning partial results (${responses.length} users) after ${maxConsecutiveFailures} consecutive failures`,
);
break;
}
throw error;
}
// Wait before retrying the same page
await new Promise((resolve) =>
setTimeout(resolve, 1000 * consecutiveFailures),
);
}
page++;
}
return responses;
@@ -0,0 +1,231 @@
import { useMutation } from "@tanstack/react-query";
import type { GetBalanceResult } from "thirdweb/extensions/erc20";
import { THIRDWEB_API_HOST } from "@/constants/urls";
type WalletPortfolioToken = GetBalanceResult & {
usdValue?: number;
priceUsd?: number;
};
export type WalletPortfolioData = {
totalUsdValue: number;
tokens: WalletPortfolioToken[];
};
// Retry helper with exponential backoff - returns null on failure instead of throwing
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3,
): Promise<Response | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Retry on rate limit (429) or server errors (5xx)
if (response.status === 429 || response.status >= 500) {
const delay = Math.min(1000 * 2 ** attempt, 10000); // Max 10s delay
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
return response;
} catch (_e) {
// Network error - retry with backoff
const delay = Math.min(1000 * 2 ** attempt, 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
// All retries failed - return null instead of throwing
return null;
}
// Fetch tokens for a single address on a single chain
async function fetchTokensForChain(
address: string,
chainId: number,
authToken: string,
teamId: string,
clientId?: string,
ecosystemSlug?: string,
): Promise<WalletPortfolioToken[]> {
try {
const url = new URL(`${THIRDWEB_API_HOST}/v1/wallets/${address}/tokens`);
url.searchParams.set("chainId", chainId.toString());
url.searchParams.set("limit", "50");
url.searchParams.set("metadata", "true");
url.searchParams.set("includeSpam", "false");
url.searchParams.set("includeNative", "true");
url.searchParams.set("sortBy", "usd_value");
url.searchParams.set("sortOrder", "desc");
const response = await fetchWithRetry(url.toString(), {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
...(ecosystemSlug && {
"x-ecosystem-id": `ecosystem.${ecosystemSlug}`,
}),
},
});
// If all retries failed or response not ok, return empty (balance = 0)
if (!response || !response.ok) {
return [];
}
const data = await response.json();
const rawTokens = data.result?.tokens || [];
return rawTokens.map(
(t: {
balance: string;
decimals: number;
name: string;
symbol: string;
token_address: string;
chain_id: number;
price_data?: { usd_value?: number; price_usd?: number };
}): WalletPortfolioToken => ({
value: BigInt(t.balance),
decimals: t.decimals,
displayValue: (Number(t.balance) / 10 ** t.decimals).toString(),
name: t.name,
symbol: t.symbol,
tokenAddress: t.token_address,
chainId: t.chain_id,
usdValue: t.price_data?.usd_value,
priceUsd: t.price_data?.price_usd,
}),
);
} catch (e) {
// Silent fail for individual chain - continue with others
console.warn(
`Failed to fetch tokens for ${address} on chain ${chainId}:`,
e,
);
return [];
}
}
// Fetch tokens for a single address on selected chains
async function fetchWalletPortfolio(
address: string,
chainIds: number[],
authToken: string,
teamId: string,
clientId?: string,
ecosystemSlug?: string,
): Promise<WalletPortfolioData> {
// Fetch all chains concurrently for this address
const results = await Promise.all(
chainIds.map((chainId) =>
fetchTokensForChain(
address,
chainId,
authToken,
teamId,
clientId,
ecosystemSlug,
),
),
);
const allTokens = results.flat();
const totalUsdValue = allTokens.reduce(
(acc, token) => acc + (token.usdValue || 0),
0,
);
return {
totalUsdValue,
tokens: allTokens,
};
}
// Batch fetch portfolios for all addresses with progress callback
async function fetchAllPortfolios(
addresses: string[],
chainIds: number[],
authToken: string,
teamId: string,
clientId?: string,
ecosystemSlug?: string,
onProgress?: (completed: number, total: number) => void,
): Promise<Map<string, WalletPortfolioData>> {
const results = new Map<string, WalletPortfolioData>();
const concurrency = 50; // Process up to 50 addresses concurrently
let completed = 0;
let index = 0;
// Worker function that processes one address at a time
async function worker() {
while (index < addresses.length) {
const currentIndex = index++;
const address = addresses[currentIndex];
if (!address) continue;
try {
const data = await fetchWalletPortfolio(
address,
chainIds,
authToken,
teamId,
clientId,
ecosystemSlug,
);
results.set(address, data);
} catch (e) {
// Silent fail - continue with others
console.warn(`Failed to fetch portfolio for ${address}:`, e);
}
completed++;
onProgress?.(completed, addresses.length);
}
}
// Start concurrent workers
const workers = Array.from(
{ length: Math.min(concurrency, addresses.length) },
() => worker(),
);
await Promise.all(workers);
return results;
}
export function useFetchAllPortfolios() {
return useMutation({
mutationFn: async ({
addresses,
chainIds,
authToken,
teamId,
clientId,
ecosystemSlug,
onProgress,
}: {
addresses: string[];
chainIds: number[];
authToken: string;
teamId: string;
clientId?: string;
ecosystemSlug?: string;
onProgress?: (completed: number, total: number) => void;
}) => {
return fetchAllPortfolios(
addresses,
chainIds,
authToken,
teamId,
clientId,
ecosystemSlug,
onProgress,
);
},
});
}
+7
View File
@@ -13,9 +13,16 @@ export type ProductSKU =
| "usage:in_app_wallet"
| "usage:aa_sponsorship"
| "usage:aa_sponsorship_op_grant"
// dedicated relayer SKUs
| DedicatedRelayerSKU
| null;
export type ChainInfraSKU =
| "chain:infra:rpc"
| "chain:infra:insight"
| "chain:infra:account_abstraction";
export type DedicatedRelayerSKU =
| "product:dedicated_relayer_standard"
| "product:dedicated_relayer_premium"
| "product:dedicated_relayer_enterprise";
@@ -283,7 +283,7 @@ function TokenInfoSection(props: {
<dt className="text-xs font-medium text-muted-foreground tracking-wider uppercase">
{props.label}
</dt>
<dd className="text-3xl font-bold text-foreground tracking-tight">
<dd className="text-3xl font-semibold text-foreground tracking-tight">
{props.value}
</dd>
</dl>
@@ -285,6 +285,11 @@ const chainMetaRecord = {
"Lisk is focused on serving builders in high-growth markets like Africa and Southeast Asia. When you build on Lisk, you become part of the strongest Ethereum collective, alongside Coinbase, Sony, World, Kraken and Uniswap -- the OP Superchain.",
headerImgUrl: liskBanner.src,
},
//Sei Mainnet
1329: {
about:
"Sei is a blockchain designed for fast, cheap financial transactions, combining the network effects of Ethereum with the performance of Solana. Sei has processed more than four billion transactions across more than 80 million wallets and has become the #1 EVM chain by number of active users. The team is backed by top investors such as Multicoin, Jump, Coinbase Ventures, and Circle Ventures – and includes talent from companies like Robinhood, Google, Coinbase, Databricks, Uber, and Goldman Sachs.",
},
//Reactive Mainnet
1597: {
about:
@@ -72,7 +72,6 @@ export const DecodedInputSet: React.FC<DecodedInputSetProps> = ({
<div className="mt-6 flex justify-start">
<Button
className="gap-2"
disabled={param.type === "bytes" && fields.length >= 1}
onClick={() =>
append({
defaultValue: "",
@@ -17,15 +17,21 @@ export default async function CheckoutPage(props: {
searchParams: Promise<{
amount?: string;
invoice_id?: string;
project_id?: string;
chain_id?: string | string[];
}>;
}) {
const params = await props.params;
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
console.log("params", params);
console.log("searchParams", searchParams);
switch (params.sku) {
case "topup": {
const amountUSD = Number.parseInt(
(await props.searchParams).amount || "10",
);
const amountUSD = Number.parseInt(searchParams.amount || "10");
if (Number.isNaN(amountUSD)) {
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
}
@@ -43,7 +49,7 @@ export default async function CheckoutPage(props: {
break;
}
case "invoice": {
const invoiceId = (await props.searchParams).invoice_id;
const invoiceId = searchParams.invoice_id;
if (!invoiceId) {
return <StripeRedirectErrorPage errorMessage="Invalid invoice ID" />;
}
@@ -59,10 +65,12 @@ export default async function CheckoutPage(props: {
redirect(invoice);
break;
}
default: {
const response = await getBillingCheckoutUrl({
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
teamSlug: params.team_slug,
params: searchParams,
});
if (response.status === "error") {
@@ -7,6 +7,10 @@ import { getAbsoluteUrl } from "@/utils/vercel";
export async function getBillingCheckoutUrl(options: {
teamSlug: string;
sku: Exclude<ProductSKU, null>;
params: {
project_id?: string;
chain_id?: string | string[];
};
}) {
const token = await getAuthToken();
@@ -17,13 +21,26 @@ export async function getBillingCheckoutUrl(options: {
} as const;
}
const chainIds = options.params.chain_id
? (Array.isArray(options.params.chain_id)
? options.params.chain_id
: [options.params.chain_id]
)
.map((value) => Number(value))
.filter((value) => Number.isFinite(value))
: undefined;
const body = {
redirectTo: getAbsoluteStripeRedirectUrl(),
sku: options.sku,
projectId: options.params.project_id,
chainIds: chainIds,
};
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/create-link`,
{
body: JSON.stringify({
redirectTo: getAbsoluteStripeRedirectUrl(),
sku: options.sku,
}),
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
@@ -58,6 +75,18 @@ export async function getBillingCheckoutUrl(options: {
}
const json = await res.json();
if (
"error" in json &&
"message" in json.error &&
typeof json.error.message === "string"
) {
return {
error: json.error.message,
status: "error",
} as const;
}
if (!json.result) {
return {
error: "An unknown error occurred, please try again later.",
@@ -312,7 +312,7 @@ function CreateProjectForm(props: {
type="submit"
>
{createProject.isPending && <Spinner className="size-4" />}
Create Projecct
Create Project
</Button>
</footer>
</form>
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useAddPartner } from "../../hooks/use-add-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
@@ -23,6 +24,8 @@ export function AddPartnerForm({
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const storageUpload = useDashboardStorageUpload({ client });
const { mutateAsync: addPartner, isPending } = useAddPartner(
{
authToken,
@@ -48,10 +51,23 @@ export function AddPartnerForm({
},
);
const handleSubmit = (
const isUploading = storageUpload.isPending;
const handleSubmit = async (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
let imageUrl: string | undefined;
if (values.logo) {
try {
const [uri] = await storageUpload.mutateAsync([values.logo]);
imageUrl = uri;
} catch {
toast.error("Failed to upload logo");
return;
}
}
addPartner({
accessControl: finalAccessControl,
allowlistedBundleIds: values.bundleIds
@@ -61,6 +77,7 @@ export function AddPartnerForm({
.split(/,| /)
.filter((d) => d.length > 0),
ecosystem,
imageUrl,
name: values.name,
});
};
@@ -68,7 +85,7 @@ export function AddPartnerForm({
return (
<PartnerForm
client={client}
isSubmitting={isPending}
isSubmitting={isPending || isUploading}
onSubmit={handleSubmit}
submitLabel="Add"
/>
@@ -1,12 +1,13 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useId } from "react";
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useId, useRef } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import type { z } from "zod";
import type { Partner } from "@/api/team/ecosystems";
import { Img } from "@/components/blocks/Img";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -17,11 +18,13 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ImageUpload } from "@/components/ui/image-upload";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { partnerFormSchema } from "../../constants";
import { AllowedOperationsSection } from "./allowed-operations-section";
@@ -118,6 +121,7 @@ export function PartnerForm({
const accessControlId = useId();
const serverVerifierId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<Form {...form}>
@@ -150,6 +154,101 @@ export function PartnerForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="logo"
render={() => {
const removeLogo = form.watch("removeLogo");
const logoFile = form.watch("logo");
const newFilePreview = logoFile
? URL.createObjectURL(logoFile)
: undefined;
const existingImageUrl =
partner?.imageUrl && !removeLogo
? resolveSchemeWithErrorHandler({
client,
uri: partner.imageUrl,
})
: undefined;
const displayUrl = newFilePreview || existingImageUrl;
return (
<FormItem>
<FormLabel>Partner Logo</FormLabel>
<FormControl>
<div>
<input
ref={fileInputRef}
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
form.setValue("logo", file, {
shouldValidate: true,
});
form.setValue("removeLogo", false);
}
e.target.value = "";
}}
type="file"
/>
{displayUrl ? (
<div className="relative inline-block">
<Img
alt={partner?.name ?? "Partner logo"}
className="size-20 rounded-md border object-contain object-center"
src={displayUrl}
/>
<Button
aria-label="Remove logo"
className="absolute -top-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
onClick={() => {
form.setValue("logo", undefined);
form.setValue("removeLogo", true);
}}
size="icon"
type="button"
variant="ghost"
>
<XIcon className="size-3" />
</Button>
<Button
aria-label="Change logo"
className="absolute -bottom-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
onClick={() => fileInputRef.current?.click()}
size="icon"
type="button"
variant="ghost"
>
<PencilIcon className="size-3" />
</Button>
</div>
) : (
<ImageUpload
accept="image/png, image/jpeg, image/webp"
className="bg-background"
onUpload={(files) => {
if (files[0]) {
form.setValue("logo", files[0], {
shouldValidate: true,
});
form.setValue("removeLogo", false);
}
}}
/>
)}
</div>
</FormControl>
<FormDescription>
Optional logo for this partner. Used in OTP emails sent to
users authenticating through this partner.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
defaultValue=""
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useUpdatePartner } from "../../hooks/use-update-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
@@ -25,6 +26,8 @@ export function UpdatePartnerForm({
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const storageUpload = useDashboardStorageUpload({ client });
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
{
authToken,
@@ -50,10 +53,31 @@ export function UpdatePartnerForm({
},
);
const handleSubmit = (
const isUploading = storageUpload.isPending;
const handleSubmit = async (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
// Determine imageUrl based on three states:
// 1. New file uploaded → upload and use new URI
// 2. Explicit removal → send null to clear
// 3. No change → preserve existing partner imageUrl
let imageUrl: string | null | undefined;
if (values.logo) {
try {
const [uri] = await storageUpload.mutateAsync([values.logo]);
imageUrl = uri;
} catch {
toast.error("Failed to upload logo");
return;
}
} else if (values.removeLogo) {
imageUrl = null;
} else {
imageUrl = partner.imageUrl;
}
updatePartner({
accessControl: finalAccessControl,
allowlistedBundleIds: values.bundleIds
@@ -63,6 +87,7 @@ export function UpdatePartnerForm({
.split(/,| /)
.filter((d) => d.length > 0),
ecosystem,
imageUrl,
name: values.name,
partnerId: partner.id,
});
@@ -71,7 +96,7 @@ export function UpdatePartnerForm({
return (
<PartnerForm
client={client}
isSubmitting={isPending}
isSubmitting={isPending || isUploading}
onSubmit={handleSubmit}
partner={partner}
submitLabel="Update"
@@ -154,6 +154,21 @@ const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
export const partnerFormSchema = z
.object({
logo: z
.instanceof(File, {
message: "Please select an image file",
})
.refine(
(file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type),
{
message: "Only PNG, JPG or WEBP images are allowed",
},
)
.refine((file) => file.size <= 500 * 1024, {
message: "Logo size must be less than 500KB",
})
.optional(),
removeLogo: z.boolean().default(false),
accessControl: z
.object({
allowedOperations: z
@@ -8,6 +8,7 @@ import type { Ecosystem, Partner } from "@/api/team/ecosystems";
type AddPartnerParams = {
ecosystem: Ecosystem;
name: string;
imageUrl?: string;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
accessControl?: Partner["accessControl"] | null;
@@ -37,6 +38,7 @@ export function useAddPartner(
accessControl: params.accessControl ?? undefined,
allowlistedBundleIds: params.allowlistedBundleIds,
allowlistedDomains: params.allowlistedDomains,
imageUrl: params.imageUrl,
name: params.name,
// TODO - remove the requirement for permissions in API endpoint
permissions: ["FULL_CONTROL_V1"],
@@ -9,6 +9,7 @@ type UpdatePartnerParams = {
partnerId: string;
ecosystem: Ecosystem;
name: string;
imageUrl?: string | null;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
accessControl?: {
@@ -43,6 +44,7 @@ export function useUpdatePartner(
accessControl: params.accessControl,
allowlistedBundleIds: params.allowlistedBundleIds,
allowlistedDomains: params.allowlistedDomains,
imageUrl: params.imageUrl,
name: params.name,
}),
@@ -20,35 +20,17 @@ export function QuickStartSection(props: {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard
title="Buy Widget Component"
icon={CodeIcon}
id="swap_tokens"
features={[
"React component",
"Cross-chain token swaps across 85+ blockchains",
"Fiat onramp support to buy tokens with credit/debit cards",
"Customizable UI",
]}
description={undefined}
link={{
href: `https://portal.thirdweb.com/bridge/swap`,
label: "Get Started",
}}
/>
<FeatureCard
title="Bridge Widget Script"
description={undefined}
title="Bridge Widget"
description="Integrate cross-chain swaps and fiat onramp into your app using bridge widget"
icon={CodeIcon}
id="fees"
features={[
"Integrate with a script tag",
"Integrate with iframe, script, or React component",
"Cross-chain token swaps across 85+ blockchains",
"Fiat onramp support to buy tokens with credit/debit cards",
"Customizable UI",
]}
link={{
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
href: `https://portal.thirdweb.com/bridge/bridge-widget`,
label: "Get Started",
}}
/>
@@ -51,6 +51,14 @@ export function ProjectSidebarLayout(props: {
href: `${props.layoutPath}/wallets/sponsored-gas`,
label: "Gas Sponsorship",
},
{
href: `${props.layoutPath}/wallets/dedicated-relayer`,
label: (
<span className="flex items-center gap-2">
Dedicated Relayer <Badge>New</Badge>
</span>
),
},
],
},
...(props.showContracts
@@ -3,13 +3,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLineIcon,
ArrowLeftIcon,
ArrowLeftRightIcon,
ArrowUpFromLineIcon,
EllipsisVerticalIcon,
ListCollapseIcon,
RefreshCcwIcon,
SendIcon,
ShuffleIcon,
WalletIcon,
Settings2Icon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
@@ -49,6 +50,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -228,9 +230,12 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
return (
<div>
<div className="rounded-xl border border-border bg-card">
<div className="p-5 flex flex-col lg:flex-row gap-5">
<div className="flex-1">
<p className="text-sm text-foreground mb-1">Wallet Address</p>
<div className="px-5 py-4 border-b border-dashed flex justify-between items-center">
<div>
<h2 className="text-base font-semibold text-foreground mb-0.5 tracking-tight">
{projectWallet.label || "Project Wallet"}
</h2>
<CopyTextButton
textToShow={shortenAddress(projectWallet.address, 6)}
textToCopy={projectWallet.address}
@@ -241,14 +246,66 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</div>
<div className="flex-1">
<p className="text-sm text-foreground mb-1">Wallet Label</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
{projectWallet.label || "N/A"}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-auto h-auto p-2 rounded-full"
>
<EllipsisVerticalIcon className="size-5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 rounded-xl"
sideOffset={10}
>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<ArrowUpFromLineIcon className="size-4 text-muted-foreground" />
Deposit Funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsSendOpen(true)}
>
<ArrowDownToLineIcon className="size-4 text-muted-foreground" />
Withdraw Funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsSwapOpen(true)}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
Swap Tokens
</DropdownMenuItem>
{/* add line */}
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ListCollapseIcon className="size-4 text-muted-foreground" />
View Transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<Settings2Icon className="size-4 text-muted-foreground" />
Change Project Wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
@@ -280,10 +337,10 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex flex-col sm:flex-row sm:items-center">
<SingleNetworkSelector
chainId={selectedChainId}
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50 border-r-0 rounded-r-none"
client={props.client}
disableDeprecated
disableChainId
@@ -302,76 +359,16 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
chainId={selectedChainId}
client={props.client}
enabled={true}
disableAddress={true}
showCheck={true}
addNativeTokenIfMissing={true}
placeholder="Native token"
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50 rounded-l-none"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSendOpen(true)}
>
<SendIcon className="size-4" />
Withdraw
</Button>
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSwapOpen(true)}
>
<ArrowLeftRightIcon className="size-4" />
Swap
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="px-2 bg-background hover:bg-accent/50"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<WalletIcon className="size-4 text-muted-foreground" />
Deposit
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
Transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<ShuffleIcon className="size-4 text-muted-foreground" />
Change Wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SendProjectWalletModal
@@ -5,7 +5,7 @@ import { storybookThirdwebClient } from "@/storybook/utils";
import { ProjectWalletSectionUI } from "./project-wallet";
const meta = {
component: ProjectWalletSectionUI,
component: Variant,
title: "Project/ProjectWalletSection",
parameters: {
nextjs: {
@@ -35,16 +35,16 @@ projectWithManagedAccessToken.services = [
const projectWithoutManagedAccessToken = projectStub("foo", "bar");
const projectWallet1: ProjectWalletSummary = {
const serverWallet1: ProjectWalletSummary = {
id: "server-wallet-id",
address: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
label: "Project Wallet 1",
label: "XYZ",
};
const projectWallet2: ProjectWalletSummary = {
const serverWallet2: ProjectWalletSummary = {
id: "server-wallet-id-2",
address: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
label: "Project Wallet 2",
label: "FooBar",
};
export const NoProjectWalletSetNoManagedAccessToken: Story = {
@@ -84,7 +84,7 @@ export const NoProjectWalletSetWithManagedAccessTokenAndServerWallets: Story = {
projectWallet: undefined,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [projectWallet1, projectWallet2];
return [serverWallet1, serverWallet2];
},
},
};
@@ -98,7 +98,7 @@ export const NoProjectWalletSetLoading: Story = {
projectWallet: undefined,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 100000));
return [projectWallet1, projectWallet2];
return [serverWallet1, serverWallet2];
},
},
};
@@ -109,10 +109,10 @@ export const ProjectWalletSetMultipleServerWallets: Story = {
project: projectWithManagedAccessToken,
teamSlug: "bar",
client: storybookThirdwebClient,
projectWallet: projectWallet1,
projectWallet: serverWallet1,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [projectWallet1, projectWallet2];
return [serverWallet1, serverWallet2];
},
},
};
@@ -122,11 +122,23 @@ export const ProjectWalletSetSingleServerWallet: Story = {
layout: "column",
project: projectWithManagedAccessToken,
teamSlug: "bar",
projectWallet: projectWallet1,
projectWallet: serverWallet1,
client: storybookThirdwebClient,
getProjectServerWallets: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [projectWallet1];
return [serverWallet1];
},
},
};
function Variant(props: Parameters<typeof ProjectWalletSectionUI>[0]) {
return (
<div className="space-y-20">
<ProjectWalletSectionUI {...props} />
<div className="max-w-[660px]">
<ProjectWalletSectionUI {...props} />
</div>
</div>
);
}
@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
import { ArrowUpRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { ArrowUpRightIcon, ChevronDownIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -134,11 +134,8 @@ function CreateProjectWalletSection(props: {
return (
<div className="rounded-xl border p-5 bg-card">
<div className="rounded-full p-2 border bg-background inline-flex mb-4">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h2 className="text-base font-medium text-foreground mb-0.5">
No Project Wallet set
<h2 className="text-sm font-medium text-foreground mb-0.5 text-yellow-700 dark:text-yellow-500">
No project wallet set
</h2>
<p className="text-sm text-muted-foreground">
Set a project wallet to set the default sender in thirdweb API.
@@ -102,7 +102,7 @@ export default async function Page(props: {
},
{
type: "webhooks",
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
href: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
},
],
}}
@@ -1,8 +1,10 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { Img } from "@workspace/ui/components/img";
import { ArrowDownToLineIcon, CoinsIcon, ImagesIcon } from "lucide-react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import {
@@ -63,19 +65,27 @@ export function Cards(props: {
projectId: string;
}) {
return (
<div className="flex flex-col gap-4">
<div className="grid lg:grid-cols-2 gap-4">
<CardLink
description="Launch your own ERC-20 coin"
images={{
light: "/assets/tokens/coin-light.png",
dark: "/assets/tokens/coin-dark.png",
}}
description="Launch an ERC-20 coin with various pricing strategies"
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens/create/token`}
icon={CoinsIcon}
title="Create Coin"
title="Coin"
/>
<CardLink
description="Launch your own NFT collection"
images={{
light: "/assets/tokens/nft-light.png",
dark: "/assets/tokens/nft-dark.png",
}}
description="Launch an ERC-721 or ERC-1155 NFT collection"
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens/create/nft`}
icon={ImagesIcon}
title="Create NFT Collection"
title="NFT Collection"
/>
</div>
);
@@ -87,15 +97,20 @@ function CardLink(props: {
href: string | undefined;
onClick?: () => void;
icon: React.FC<{ className?: string }>;
images: {
dark: string;
light: string;
};
}) {
const { onClick } = props;
const isClickable = !!onClick || !!props.href;
const { resolvedTheme } = useTheme();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: FIXME
<div
className={cn(
"relative flex flex-col rounded-lg border bg-card p-4",
"relative flex flex-col rounded-xl border bg-card p-4",
isClickable && "cursor-pointer hover:border-active-border ",
)}
onClick={onClick}
@@ -107,13 +122,23 @@ function CardLink(props: {
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
>
<div className="mb-4 flex">
<div className="flex items-center justify-center rounded-full border p-2">
<props.icon className="size-4 text-muted-foreground" />
<div className="mb-4">
<div className="flex lg:hidden">
<div className="flex items-center justify-center rounded-full border p-2">
<props.icon className="size-4 text-muted-foreground" />
</div>
</div>
<Img
src={
resolvedTheme === "light" ? props.images.light : props.images.dark
}
alt={props.title}
className="hidden lg:block"
/>
</div>
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.href ? (
<Link className="before:absolute before:inset-0" href={props.href}>
{props.title}
@@ -8,6 +8,7 @@ import { ProjectPage } from "@/components/blocks/project-page/project-page";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -15,7 +16,7 @@ import {
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TokenIcon } from "@/icons/TokenIcon";
import { loginRedirect } from "@/utils/redirects";
import { Cards, ImportTokenButton } from "../cards";
import { Cards, ImportTokenButton } from "./cards";
export default async function Layout(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -63,9 +64,12 @@ export default async function Layout(props: {
Create Token
</Button>
</DialogTrigger>
<DialogContent className="!max-w-md">
<DialogContent className="!max-w-2xl">
<DialogHeader>
<DialogTitle>Create Token</DialogTitle>
<DialogDescription>
Launch your own ERC-20 coin or NFT collection
</DialogDescription>
</DialogHeader>
<Cards
client={client}
@@ -21,6 +21,41 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
"Management Token for Dashboard";
/**
* Retry a function with exponential backoff.
*/
async function withRetry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise<T> {
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new Error("maxAttempts must be at least 1");
}
if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) {
throw new Error("baseDelayMs must be a non-negative number");
}
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < maxAttempts) {
// Exponential backoff with cap at 30s and jitter to prevent thundering herd
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 30_000);
const jitter = Math.floor(Math.random() * Math.min(250, delay));
await new Promise<void>((resolve) =>
setTimeout(resolve, delay + jitter),
);
}
}
}
throw lastError ?? new Error("withRetry failed without capturing an error");
}
let vc: VaultClient | null = null;
export async function initVaultClient() {
@@ -47,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: {
throw new Error("No rotation code found");
}
// IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project.
// If we rotate first and then secret key validation fails, the old rotation code
// is consumed but we can't save the new one, leaving the project unrecoverable.
if (props.projectSecretKey) {
const projectSecretKeyHash = await hashSecretKey(props.projectSecretKey);
const secretKeysHashed = [
...props.project.secretKeys,
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
];
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
throw new Error("Invalid project secret key");
}
}
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
@@ -69,6 +118,9 @@ export async function rotateVaultAccountAndAccessToken(props: {
vaultClient,
adminKey,
rotationCode,
// Skip wallet creation on rotation - preserve the existing project wallet
skipWalletCreation: true,
existingProjectWalletAddress: service?.projectWalletAddress ?? undefined,
});
return {
@@ -222,9 +274,19 @@ async function createAndEncryptVaultAccessTokens(props: {
projectSecretHash?: string;
adminKey: string;
rotationCode: string;
skipWalletCreation?: boolean;
existingProjectWalletAddress?: string;
}) {
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
props;
const {
project,
projectSecretKey,
projectSecretHash,
vaultClient,
adminKey,
rotationCode,
skipWalletCreation,
existingProjectWalletAddress,
} = props;
const [managementTokenResult, walletTokenResult] = await Promise.all([
createManagementAccessToken({ project, adminKey, vaultClient }),
@@ -246,12 +308,12 @@ async function createAndEncryptVaultAccessTokens(props: {
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
// create a default project server wallet
const defaultProjectServerWallet = await createProjectServerWallet({
project,
managementAccessToken: managementToken.accessToken,
label: getProjectWalletLabel(project.name),
});
// CRITICAL: Save credentials IMMEDIATELY after creating tokens.
// This prevents a broken state if wallet creation or other operations fail.
// The rotationCode is consumed when rotating, so if we don't save the new one,
// the project becomes unrecoverable.
let encryptedAdminKey: string | null = null;
let encryptedWalletAccessToken: string | null = null;
if (projectSecretKey) {
// verify that the project secret key is valid
@@ -259,68 +321,65 @@ async function createAndEncryptVaultAccessTokens(props: {
const secretKeysHashed = [
...project.secretKeys,
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
...(projectSecretHash ? [{ hash: projectSecretHash }] : []),
];
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
throw new Error("Invalid project secret key");
}
// encrypt admin key and wallet token with project secret key
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
[encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
encrypt(adminKey, projectSecretKey),
encrypt(walletToken.accessToken, projectSecretKey),
]);
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey,
encryptedWalletAccessToken,
rotationCode: rotationCode,
projectWalletAddress: defaultProjectServerWallet.address,
},
],
},
);
} else {
// no secret key, only store the management token, remove any encrypted keys
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey: null,
encryptedWalletAccessToken: null,
rotationCode: rotationCode,
projectWalletAddress: defaultProjectServerWallet.address,
},
],
},
);
}
// For rotation, preserve existing wallet address. For new creation, create a default wallet.
let projectWalletAddress: string | null | undefined =
existingProjectWalletAddress ??
project.services.find((s) => s.name === "engineCloud")
?.projectWalletAddress;
// Only create a new wallet if we don't have one (initial setup, not rotation)
if (!skipWalletCreation && !projectWalletAddress) {
const defaultProjectServerWallet = await createProjectServerWallet({
project,
managementAccessToken: managementToken.accessToken,
label: getProjectWalletLabel(project.name),
});
projectWalletAddress = defaultProjectServerWallet.address;
}
// Save credentials with retry - this is critical because if rotation succeeded
// but this save fails, the new rotation code is lost and project becomes unrecoverable
await withRetry(
() =>
updateProjectClient(
{
projectId: project.id,
teamId: project.teamId,
},
{
services: [
...project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey,
encryptedWalletAccessToken,
rotationCode: rotationCode,
projectWalletAddress: projectWalletAddress ?? null,
},
],
},
),
{ maxAttempts: 3, baseDelayMs: 1000 },
);
return {
managementToken,
walletToken,
@@ -0,0 +1,392 @@
"use client";
import { format, formatDistance } from "date-fns";
import { ExternalLinkIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import {
DateRangeSelector,
type Range,
} from "@/components/analytics/date-range-selector";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { cn } from "@/lib/utils";
import {
useFleetTransactions,
useFleetTransactionsSummary,
} from "../lib/hooks";
import type { Fleet, FleetTransaction } from "../types";
type DedicatedRelayerActiveStateProps = {
fleet: Fleet;
teamId: string;
fleetId: string;
client: ThirdwebClient;
range: Range;
setRange: (range: Range) => void;
className?: string;
};
export function DedicatedRelayerActiveState(
props: DedicatedRelayerActiveStateProps,
) {
const { fleet, teamId, fleetId, client, range, setRange } = props;
const pageSize = 10;
const [page, setPage] = useState(1);
const [chainIdFilter, setChainIdFilter] = useState<number | undefined>();
const summaryQuery = useFleetTransactionsSummary({
teamId,
fleetId,
from: range.from.toISOString(),
to: range.to.toISOString(),
});
const transactionsQuery = useFleetTransactions({
teamId,
fleetId,
from: range.from.toISOString(),
to: range.to.toISOString(),
limit: pageSize,
offset: (page - 1) * pageSize,
chainId: chainIdFilter,
});
const totalPages = transactionsQuery.data
? Math.ceil(transactionsQuery.data.meta.total / pageSize)
: 0;
// Filter active chains to only include those in the fleet
const activeChainsCount =
summaryQuery.data?.data.transactionsByChain.filter((c) =>
fleet.chainIds.includes(Number(c.chainId)),
).length ?? 0;
return (
<div className={cn("flex flex-col gap-6", props.className)}>
<div className="flex justify-end">
<DateRangeSelector range={range} setRange={setRange} />
</div>
{/* Summary Stats */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Transactions"
value={
summaryQuery.data?.data.totalTransactions.toLocaleString() ?? "—"
}
isLoading={summaryQuery.isPending}
/>
<StatCard
title="Total Gas Spent"
value={
summaryQuery.data?.data.totalGasSpentUsd !== undefined
? `$${summaryQuery.data.data.totalGasSpentUsd.toFixed(2)}`
: "—"
}
isLoading={summaryQuery.isPending}
/>
<StatCard
title="Active Chains"
value={summaryQuery.data ? activeChainsCount.toString() : "—"}
isLoading={summaryQuery.isPending}
/>
</div>
{/* Configuration Info */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Executors */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">Executors</h2>
</div>
<div className="p-4 lg:p-6">
<div className="flex flex-wrap gap-2">
{fleet.executors.map((address) => (
<div
key={address}
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
>
<WalletAddress address={address} client={client} />
</div>
))}
</div>
</div>
</div>
{/* Chains */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">Chains</h2>
</div>
<div className="p-4 lg:p-6">
<div className="flex flex-wrap gap-2">
{fleet.chainIds.map((chainId) => (
<div
key={chainId}
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
>
<ChainCell chainId={chainId.toString()} client={client} />
</div>
))}
</div>
</div>
</div>
</div>
{/* Transactions Table */}
<div className="overflow-hidden rounded-lg border bg-card">
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
<h2 className="font-semibold text-xl">Transaction History</h2>
<div className="flex gap-2">
<ChainFilter
chainId={chainIdFilter}
setChainId={(chainId) => {
setChainIdFilter(chainId);
setPage(1);
}}
client={client}
chainIds={fleet.chainIds}
/>
</div>
</div>
<TableContainer className="rounded-none border-none">
<Table>
<TableHeader>
<TableRow>
<TableHead>Transaction Hash</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Wallet</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Fee</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!transactionsQuery.isPending
? transactionsQuery.data?.data.map((tx) => (
<TransactionRow
key={tx.transactionHash}
transaction={tx}
client={client}
/>
))
: Array.from({ length: pageSize }).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no unique id
<SkeletonRow key={`skeleton-${index}`} />
))}
</TableBody>
</Table>
</TableContainer>
{!transactionsQuery.isPending &&
(transactionsQuery.isError ? (
<div className="px-6 py-24">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-2">
<XIcon className="size-5" />
</div>
</div>
<p className="text-center text-destructive-text text-sm">
Failed to load transactions
</p>
</div>
) : transactionsQuery.data?.data.length === 0 ? (
<div className="px-6 py-24">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-2">
<XIcon className="size-5 text-muted-foreground" />
</div>
</div>
<p className="text-center text-muted-foreground text-sm">
No transactions yet
</p>
</div>
) : null)}
{totalPages > 1 && (
<div className="flex justify-end gap-3 rounded-b-lg border-t p-4">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
</div>
);
}
function StatCard(props: {
title: string;
value: string;
isLoading?: boolean;
}) {
return (
<div className="rounded-lg border bg-card p-4">
<p className="text-muted-foreground text-sm">{props.title}</p>
{props.isLoading ? (
<Skeleton className="mt-1 h-8 w-24" />
) : (
<p className="font-semibold text-2xl">{props.value}</p>
)}
</div>
);
}
function TransactionRow(props: {
transaction: FleetTransaction;
client: ThirdwebClient;
}) {
const { transaction, client } = props;
const utcTimestamp = transaction.timestamp.endsWith("Z")
? transaction.timestamp
: `${transaction.timestamp}Z`;
return (
<TableRow>
<TableCell>
<TransactionHashCell
chainId={transaction.chainId}
hash={transaction.transactionHash}
/>
</TableCell>
<TableCell>
<ChainCell chainId={transaction.chainId} client={client} />
</TableCell>
<TableCell>
<WalletAddress address={transaction.walletAddress} client={client} />
</TableCell>
<TableCell>
<ToolTipLabel hoverable label={format(new Date(utcTimestamp), "PPpp")}>
<span>
{formatDistance(new Date(utcTimestamp), new Date(), {
addSuffix: true,
})}
</span>
</ToolTipLabel>
</TableCell>
<TableCell>
<TransactionFeeCell usdValue={transaction.transactionFeeUsd} />
</TableCell>
</TableRow>
);
}
function SkeletonRow() {
return (
<TableRow className="h-[72.5px]">
<TableCell>
<Skeleton className="h-7 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-7 w-[130px]" />
</TableCell>
<TableCell>
<Skeleton className="h-7 w-[130px]" />
</TableCell>
<TableCell>
<Skeleton className="h-7 w-[80px]" />
</TableCell>
<TableCell>
<Skeleton className="h-7 w-[40px]" />
</TableCell>
</TableRow>
);
}
function TransactionHashCell(props: { hash: string; chainId: string }) {
const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`;
return (
<Button asChild size="sm" variant="ghost">
<Link
className="-translate-x-2 gap-2 font-mono"
href={`https://thirdweb.com/${props.chainId}/tx/${props.hash}`}
rel="noopener noreferrer"
target="_blank"
>
{txHashToShow}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
</Button>
);
}
function ChainCell(props: { chainId: string; client: ThirdwebClient }) {
const { idToChain, allChains } = useAllChainsData();
const chain = idToChain.get(Number(props.chainId));
if (allChains.length === 0) {
return <Skeleton className="w-[100px]" />;
}
return (
<div className="relative flex w-max items-center gap-2">
<ChainIconClient
className="size-6"
client={props.client}
src={chain?.icon?.url}
/>
<Link
className="before:absolute before:inset-0 hover:underline hover:underline-offset-4"
href={`/${chain ? chain.slug : props.chainId}`}
rel="noopener noreferrer"
target="_blank"
>
{chain ? chain.name : `Chain #${props.chainId}`}
</Link>
</div>
);
}
function TransactionFeeCell(props: { usdValue: number | null }) {
if (props.usdValue === null) {
return <span className="font-mono text-sm text-muted-foreground">—</span>;
}
return (
<span className="font-mono text-sm">
{props.usdValue < 0.001 ? "< $0.001" : `$${props.usdValue.toFixed(3)}`}
</span>
);
}
function ChainFilter(props: {
chainId: number | undefined;
setChainId: (chainId: number | undefined) => void;
client: ThirdwebClient;
chainIds: number[];
}) {
return (
<div className="flex bg-background">
<SingleNetworkSelector
client={props.client}
chainId={props.chainId}
onChange={(chainId) => props.setChainId(chainId)}
popoverContentClassName="z-[10001] !w-[280px]"
align="end"
placeholder="All Chains"
className="min-w-[150px]"
chainIds={props.chainIds}
/>
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { Img } from "@workspace/ui/components/img";
import { useTheme } from "next-themes";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { DedicatedRelayerSKU } from "@/types/billing";
import { RELAYER_SUPPORTED_CHAINS } from "../constants";
import { PlanSection } from "./tier-selection";
type DedicatedRelayerEmptyStateProps = {
teamSlug: string;
projectSlug: string;
onPurchaseTier: (
tier: DedicatedRelayerSKU,
chainIds: number[],
) => Promise<void>;
client: ThirdwebClient;
className?: string;
};
/**
* Empty state shown when user hasn't purchased a dedicated relayer fleet.
* Shows tier selection for purchasing.
*/
export function DedicatedRelayerEmptyState(
props: DedicatedRelayerEmptyStateProps,
) {
const [isLoading, setIsLoading] = useState(false);
const [selectedTier, setSelectedTier] = useState<DedicatedRelayerSKU | null>(
null,
);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedChainIds, setSelectedChainIds] = useState<number[]>([]);
const handleSelectTier = async (tier: DedicatedRelayerSKU) => {
if (tier === "product:dedicated_relayer_enterprise") {
window.open("https://thirdweb.com/contact-us", "_blank");
return;
}
setSelectedTier(tier);
setSelectedChainIds([]);
setIsModalOpen(true);
};
const handlePurchase = async () => {
if (!selectedTier) return;
setIsLoading(true);
try {
await props.onPurchaseTier(selectedTier, selectedChainIds);
setIsModalOpen(false);
setSelectedTier(null);
setSelectedChainIds([]);
} catch (error) {
console.error(error);
toast.error("Failed to purchase dedicated relayer");
} finally {
setIsLoading(false);
}
};
const maxChains =
selectedTier === "product:dedicated_relayer_standard" ? 1 : 2;
return (
<div className={cn("flex flex-col gap-8 pt-2", props.className)}>
<FeatureSection />
<PlanSection
onSelectTier={handleSelectTier}
isLoading={isLoading}
selectedTier={selectedTier}
/>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Select Chains</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
Select up to {maxChains} chains for your dedicated relayer.
</p>
<MultiNetworkSelector
selectedChainIds={selectedChainIds}
onChange={setSelectedChainIds}
client={props.client}
chainIds={RELAYER_SUPPORTED_CHAINS}
/>
</div>
<DialogFooter>
<Button
onClick={handlePurchase}
disabled={
selectedChainIds.length === 0 ||
selectedChainIds.length > maxChains ||
isLoading
}
>
{isLoading ? "Processing..." : "Continue"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function FeatureSection() {
return (
<div className="grid lg:grid-cols-3 border rounded-xl bg-card">
<FeatureCard
title="Prioritized Queueing"
description="Dedicated infrastructure to avoid sharing resources and competing with other wallets"
images={{
darkSrc: "/assets/dedicated-relayer/server-wallet-dark.png",
lightSrc: "/assets/dedicated-relayer/server-wallet-light.png",
}}
/>
<FeatureCard
title="Zero Configuration"
className="border-t lg:border-t-0 lg:border-l border-dashed"
description="No code changes required, gas fees continue to be sponsored without need to top up funds"
images={{
darkSrc: "/assets/dedicated-relayer/no-config-dark.png",
lightSrc: "/assets/dedicated-relayer/no-config-light.png",
}}
/>
<FeatureCard
title="Monitoring"
className="border-t lg:border-t-0 lg:border-l border-dashed"
description="All your transactions are initiated from the same onchain relayer address, making monitoring and dashboard building easy"
images={{
darkSrc: "/assets/dedicated-relayer/monitoring-dark.png",
lightSrc: "/assets/dedicated-relayer/monitoring-light.png",
}}
/>
</div>
);
}
function FeatureCard(props: {
title: string;
description: string;
className?: string;
images: {
darkSrc: string;
lightSrc: string;
};
}) {
const { resolvedTheme } = useTheme();
const imageSrc =
resolvedTheme === "light" ? props.images.lightSrc : props.images.darkSrc;
return (
<div className={props.className}>
<Img src={imageSrc} alt="" className="object-cover" key={imageSrc} />
<div className="p-6">
<h3 className="font-semibold text-xl mb-1 tracking-tight">
{props.title}
</h3>
<p className="text-muted-foreground text-sm text-pretty">
{props.description}
</p>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import { subDays } from "date-fns";
import { useEffect, useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Range } from "@/components/analytics/date-range-selector";
import type { DedicatedRelayerSKU } from "@/types/billing";
import { getAbsoluteUrl } from "@/utils/vercel";
import { useFleetStatus, useFleetTransactionsSummary } from "../lib/hooks";
import type { Fleet, FleetStatus } from "../types";
import { DedicatedRelayerActiveState } from "./active-state";
import { DedicatedRelayerEmptyState } from "./empty-state";
import { DedicatedRelayerPendingState } from "./pending-state";
type DedicatedRelayerPageClientProps = {
teamId: string;
projectId: string;
teamSlug: string;
projectSlug: string;
client: ThirdwebClient;
fleetId: string;
from: string;
to: string;
rangeType: Range["type"];
initialFleet: Fleet | null;
};
export function DedicatedRelayerPageClient(
props: DedicatedRelayerPageClientProps,
) {
const [fleet, _setFleet] = useState<Fleet | null>(props.initialFleet);
const [fleetStatus, _setFleetStatus] = useState<FleetStatus>(() =>
getInitialStatus(props.initialFleet),
);
const [dateRange, setDateRange] = useState<Range>({
from: new Date(props.from),
to: new Date(props.to),
type: props.rangeType,
});
// Poll for fleet status when not purchased or pending setup
const fleetStatusQuery = useFleetStatus(
props.teamSlug,
props.projectSlug,
fleetStatus === "not-purchased" || fleetStatus === "pending-setup",
);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (fleetStatusQuery.data) {
_setFleet(fleetStatusQuery.data);
_setFleetStatus(getInitialStatus(fleetStatusQuery.data));
}
}, [fleetStatusQuery.data]);
const activityWindowDates = useMemo(() => {
const now = new Date();
return {
from: subDays(now, 120).toISOString(),
to: now.toISOString(),
};
}, []);
// Check for any activity in the last 120 days to determine if we should show the dashboard
// This prevents switching back to "pending" state if the user selects a date range with no transactions
const hasActivityQuery = useFleetTransactionsSummary({
teamId: props.teamId,
fleetId: props.fleetId,
from: activityWindowDates.from,
to: activityWindowDates.to,
enabled: fleetStatus === "active",
refetchInterval: 5000,
});
const isCheckingActivity = hasActivityQuery.isPending;
const totalTransactions = hasActivityQuery.data?.data.totalTransactions ?? 0;
const hasTransactions = totalTransactions > 0;
// TODO-FLEET: Implement purchase flow
// 1. Call Stripe checkout API to create a checkout session for the selected tier
// 2. Redirect user to Stripe checkout
// 3. On success callback, call API server to provision fleet
// 4. API server should:
// - Create fleet record in DB
// - Call bundler service to provision executor wallets
// - Update ProjectBundlerService with fleet config
// 5. Refetch fleet status and update UI
const handlePurchaseTier = async (
sku: DedicatedRelayerSKU,
chainIds: number[],
) => {
const search = new URLSearchParams();
search.set("project_id", props.projectId);
for (const chainId of chainIds) {
search.append("chain_id", chainId.toString());
}
// Redirect to Stripe checkout
window.open(
`${getAbsoluteUrl()}/checkout/${props.teamSlug}/${sku}?${search.toString()}`,
"_blank",
);
};
return (
<div className="flex flex-col gap-6">
{fleetStatus === "not-purchased" && (
<DedicatedRelayerEmptyState
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
onPurchaseTier={handlePurchaseTier}
client={props.client}
/>
)}
{fleetStatus === "pending-setup" && fleet && (
<DedicatedRelayerPendingState fleet={fleet} />
)}
{fleetStatus === "active" &&
fleet &&
!hasTransactions &&
!isCheckingActivity && (
<DedicatedRelayerPendingState
fleet={fleet}
hasTransactions={hasTransactions}
/>
)}
{fleetStatus === "active" &&
fleet &&
(hasTransactions || isCheckingActivity) && (
<DedicatedRelayerActiveState
fleet={fleet}
teamId={props.teamId}
fleetId={props.fleetId}
client={props.client}
range={dateRange}
setRange={setDateRange}
/>
)}
</div>
);
}
function getInitialStatus(fleet: Fleet | null): FleetStatus {
if (!fleet) {
return "not-purchased";
}
if (fleet.executors.length === 0) {
return "pending-setup";
}
return "active";
}
@@ -0,0 +1,201 @@
"use client";
import { ClockIcon, Loader2Icon } from "lucide-react";
import { CodeClient } from "@/components/ui/code/code.client";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { cn } from "@/lib/utils";
import type { Fleet } from "../types";
type DedicatedRelayerPendingStateProps = {
fleet: Fleet;
hasTransactions?: boolean;
className?: string;
};
/**
* Pending state shown when fleet is purchased but executors are not yet set up.
* Shows the requested chain IDs and a "setting up" status.
*/
export function DedicatedRelayerPendingState(
props: DedicatedRelayerPendingStateProps,
) {
const { fleet, hasTransactions } = props;
const hasExecutors = fleet.executors.length > 0;
return (
<div className={cn("flex flex-col gap-6", props.className)}>
{/* Status Banner */}
<div className="flex items-center gap-3 rounded-lg border border-warning/50 bg-warning/10 px-4 py-3">
<Loader2Icon className="size-5 animate-spin text-warning" />
<div>
<p className="font-medium text-warning">
{hasExecutors
? "Waiting for first transaction"
: "Setting up your dedicated relayer"}
</p>
<p className="text-muted-foreground text-sm">
{hasExecutors
? "Your dedicated relayer is ready. Waiting for the first transaction to appear."
: "Your relayer is being provisioned. This usually takes 48 to 72 hours."}
</p>
</div>
</div>
{/* Fleet Details Preview */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">
Fleet Configuration
</h2>
</div>
<div className="p-4 lg:p-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Requested Chains */}
<div>
<h3 className="mb-3 font-medium text-muted-foreground text-sm">
Requested Chains
</h3>
<div className="flex flex-wrap gap-2">
{fleet.chainIds.map((chainId) => (
<ChainBadge key={chainId} chainId={chainId} />
))}
</div>
</div>
{/* Status */}
<div>
<h3 className="mb-3 font-medium text-muted-foreground text-sm">
Status
</h3>
<div className="flex items-center gap-2 text-muted-foreground">
<ClockIcon className="size-4" />
<span>
{hasExecutors
? "Active, waiting for transactions"
: "Awaiting executor deployment"}
</span>
</div>
</div>
</div>
</div>
</div>
{/* What to Expect */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">
What to Expect
</h2>
</div>
<div className="p-4 lg:p-6">
<ol className="flex flex-col gap-4">
<SetupStep
completed={true}
number={1}
title="Subscription activated"
/>
<SetupStep
completed={hasExecutors}
inProgress={!hasExecutors}
number={2}
title="Executor wallets deployed"
/>
<SetupStep
completed={!!hasTransactions}
inProgress={hasExecutors && !hasTransactions}
number={3}
title="Waiting for first transaction"
/>
</ol>
</div>
</div>
{/* Test Transaction Snippet */}
{hasExecutors && (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">
Send a Test Transaction
</h2>
</div>
<div className="p-4 lg:p-6">
<p className="mb-4 text-muted-foreground text-sm">
You can send a test transaction using the thirdweb API. Replace{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
YOUR_SECRET_KEY
</code>{" "}
with your actual secret key.
</p>
<CodeClient
code={`curl https://api.thirdweb.com/v1/transactions \\
--request POST \\
--header 'Content-Type: application/json' \\
--header 'x-secret-key: YOUR_SECRET_KEY' \\
--data '{
"chainId": ${fleet.chainIds[0]},
"transactions": [
{
"data": "0x",
"to": "vitalik.eth",
"value": "0"
}
]
}'`}
lang="bash"
/>
</div>
</div>
)}
</div>
);
}
function ChainBadge(props: { chainId: number }) {
const chain = useV5DashboardChain(props.chainId);
const label = chain?.name ?? `Chain ${props.chainId}`;
return (
<span className="inline-flex items-center gap-1.5 rounded-full border bg-background px-3 py-1 text-sm">
{label}
</span>
);
}
function SetupStep(props: {
number: number;
title: string;
completed: boolean;
inProgress?: boolean;
}) {
return (
<li className="flex items-center gap-3">
<div
className={`flex size-6 items-center justify-center rounded-full text-xs font-medium ${
props.completed
? "bg-success text-success-foreground"
: props.inProgress
? "bg-warning text-warning-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{props.inProgress ? (
<Loader2Icon className="size-3 animate-spin" />
) : (
props.number
)}
</div>
<span
className={
props.completed
? "text-success"
: props.inProgress
? "text-warning"
: "text-muted-foreground"
}
>
{props.title}
</span>
</li>
);
}
@@ -0,0 +1,196 @@
"use client";
import {
ArrowUpRightIcon,
BoxesIcon,
FolderCogIcon,
FolderIcon,
FoldersIcon,
} from "lucide-react";
import Link from "next/link";
import type React from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { cn } from "@/lib/utils";
import type { DedicatedRelayerSKU } from "@/types/billing";
type TierConfig = {
id: DedicatedRelayerSKU;
icon: React.FC<{ className?: string }>;
name: string;
description: string;
price: string;
isPerMonth?: boolean;
features: Array<{ icon: React.FC<{ className?: string }>; label: string }>;
cta: string;
isRecommended?: boolean;
};
const TIERS: TierConfig[] = [
{
id: "product:dedicated_relayer_standard",
icon: FolderIcon,
name: "Standard",
description:
"Most suitable for startups and applications with moderate transaction volume",
price: "$99",
isPerMonth: true,
features: [
{ icon: WalletProductIcon, label: "Single executor wallet" },
{ icon: BoxesIcon, label: "Support for 1 chain" },
],
cta: "Select",
},
{
id: "product:dedicated_relayer_premium",
icon: FoldersIcon,
name: "Premium",
description:
"Best for enterprise companies and applications with high transaction volume",
price: "$299",
isPerMonth: true,
features: [
{
icon: WalletProductIcon,
label: "10 executor wallets (10x throughput)",
},
{ icon: BoxesIcon, label: "Support for up to 2 chains" },
],
cta: "Select",
isRecommended: true,
},
{
id: "product:dedicated_relayer_enterprise",
icon: FolderCogIcon,
name: "Custom",
description:
"Contact us for applications operating at a global scale with custom requirements",
price: "Custom",
features: [
{ icon: WalletProductIcon, label: "Unlimited executor wallets" },
{ icon: BoxesIcon, label: "Unlimited chains" },
],
cta: "Contact Sales",
},
];
export function PlanSection(props: {
onSelectTier: (tier: DedicatedRelayerSKU) => void;
isLoading?: boolean;
selectedTier?: DedicatedRelayerSKU | null;
className?: string;
}) {
return (
<div className={cn("border rounded-xl bg-card", props.className)}>
<div className="p-6 border-b border-dashed">
<h2 className="font-semibold text-xl tracking-tight">Select a plan</h2>
</div>
<div className="grid lg:grid-cols-3">
{TIERS.map((tier, index) => (
<PlanCard
key={tier.id}
tier={tier}
isLoading={props.isLoading && props.selectedTier === tier.id}
isDisabled={props.isLoading && props.selectedTier !== tier.id}
onSelect={() => props.onSelectTier(tier.id)}
className={
index !== 0
? "border-t lg:border-t-0 lg:border-l border-dashed"
: ""
}
isRecommended={tier.isRecommended}
/>
))}
</div>
</div>
);
}
function PlanCard(props: {
tier: TierConfig;
onSelect: () => void;
isLoading?: boolean;
isDisabled?: boolean;
className?: string;
isRecommended?: boolean;
}) {
const { tier } = props;
const isEnterprise = tier.id === "product:dedicated_relayer_enterprise";
return (
<div className={cn("relative flex flex-col px-6 py-8", props.className)}>
<div className="mb-5">
<div className="p-2.5 mb-5 inline-flex rounded-full border bg-background">
<tier.icon className="size-5 text-muted-foreground" />
</div>
<div className="h-10">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-2xl tracking-tight">
{tier.name}
</h3>
{props.isRecommended && (
<span className="text-xs border border-blue-500 bg-blue-500/10 text-blue-800 dark:border-blue-600 dark:text-blue-200 dark:bg-blue-600/10 rounded-full px-2.5 py-1 font-medium">
Recommended
</span>
)}
</div>
</div>
<p className="text-muted-foreground text-sm text-pretty max-w-[320px]">
{tier.description}
</p>
</div>
<div className="mb-5 flex items-end gap-1">
<span className="font-semibold text-3xl tracking-tight text-foreground">
{tier.price}
</span>
{tier.isPerMonth && (
<span className="text-muted-foreground font-medium"> / month</span>
)}
</div>
<p className="text-muted-foreground text-sm mb-3">Includes:</p>
<ul className="mb-8 flex flex-1 flex-col gap-2">
{tier.features.map((feature) => (
<li
key={feature.label}
className="flex items-start gap-2 text-sm text-muted-foreground"
>
<feature.icon className="size-4 shrink-0 mt-0.5" />
<span>{feature.label}</span>
</li>
))}
</ul>
{isEnterprise ? (
<Button
asChild
variant="outline"
className="w-full rounded-full py-3 h-auto text-base bg-background gap-2"
>
<Link
href="https://ea58c.share.hsforms.com/2o8qbJVozSX2Q4oukYfYGuA"
target="_blank"
rel="noopener noreferrer"
>
{tier.cta}
<ArrowUpRightIcon className="size-4" />
</Link>
</Button>
) : (
<Button
onClick={props.onSelect}
disabled={props.isLoading || props.isDisabled}
variant="outline"
className="w-full gap-2 rounded-full py-3 h-auto text-base bg-background"
>
{props.isLoading && <Spinner className="size-4" />}
{tier.cta}
<ArrowUpRightIcon className="size-4" />
</Button>
)}
</div>
);
}
@@ -0,0 +1,72 @@
export const RELAYER_SUPPORTED_CHAINS = [
// MAINNETS
1, // ethereum mainnet
100, // gnosis mainnet
56, // bsc mainnet
10, // optimism mainnet
8453, // base mainnet
57073, // ink mainnet
130, // unichain mainnet
7777777, // zora mainnet
1868, // soneium mainnet
34443, // mode mainnet
534352, // scroll mainnet
42161, // arbitrum mainnet
80094, // berachain mainnet
137, // polygon mainnet
185, // Mint Mainnet mainnet
747, // EVM on Flow mainnet
1135, // Lisk mainnet
2020, // Ronin mainnet
5330, // Superseed mainnet
7897, // Arena-Z mainnet
42170, // Arbitrum Nova mainnet
60808, // BOB mainnet
747474, // Katana mainnet
1628, // T-REX mainnet
466, // Appchain mainnet
80888, // Onyx mainnet
42220, // Celo mainnet
1329, // Sei mainnet
42793, // Etherlink mainnet
143, // Monad mainnet
252, // Fraxtal mainnet
// TESTNETS
11155111, // sepolia testnet
10200, // gnosis chiado testnet
97, // bsc testnet
11155420, // op sepolia testnet
84532, // base sepolia testnet
763373, // ink sepolia testnet
1301, // unichain sepolia testnet
999999999, // zora sepolia testnet
1946, // soneium minato testnet
919, // mode sepolia testnet
534351, // scroll sepolia testnet
421614, // arbitrum sepolia testnet
80069, // berachain bepolia testnet
9899, // Arena-Z Testnet testnet testnet
1962, // T-Rex testnet testnet
545, // EVM on Flow Testnet testnet
1001, // Kaia Testnet Kairos testnet
2021, // Saigon testnet testnet
4202, // Lisk Sepolia Testnet testnet
14853, // Humanode Testnet 5 Israfel testnet
53302, // Superseed Sepolia Testnet testnet
59141, // Linea Sepolia testnet
129399, // Tatara testnet
808813, // BOB Sepolia testnet
1992, // Sanko Testnet testnet
2030232745, // Lumia Beam Testnet testnet
11142220, // Celo Sepolia Testnet testnet
4661, // Appchain Testnet
1328, // Sei Testnet
128123, // Etherlink Testnet
123420001114, // Basecamp testnet
5042002, // Arc Testnet (Circle USDC Native)
127823, // Etherlink Shadownet Testnet
10143, // Monad Testnet (MON)
1417429182, // Zephyr Testnet
42429, // Tempo Testnet
2523, // Fraxtal Testnet testnet
];
@@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/dedicated-relayer`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "Dedicated Relayer",
description:
"Executor wallets which automatically relay transactions with higher throughput and dedicated infrastructure",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets/server",
},
],
}}
tabs={[]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,111 @@
"use server";
import { analyticsServerProxy } from "@/actions/proxies";
import { getProject } from "@/api/project/projects";
import {
buildFleetId,
type Fleet,
type FleetTransaction,
type FleetTransactionsSummary,
} from "../types";
type GetFleetTransactionsParams = {
teamId: string;
fleetId: string;
from: string;
to: string;
limit: number;
offset: number;
chainId?: number;
};
/**
* Fetches paginated fleet transactions from the analytics service.
*/
export async function getFleetTransactions(params: GetFleetTransactionsParams) {
const res = await analyticsServerProxy<{
data: FleetTransaction[];
meta: { total: number };
}>({
method: "GET",
pathname: "/v2/bundler/fleet-transactions",
searchParams: {
teamId: params.teamId,
fleetId: params.fleetId,
from: params.from,
to: params.to,
limit: params.limit.toString(),
offset: params.offset.toString(),
...(params.chainId && { chainId: params.chainId.toString() }),
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
}
type GetFleetTransactionsSummaryParams = {
teamId: string;
fleetId: string;
from: string;
to: string;
};
/**
* Fetches fleet transactions summary from the analytics service.
*/
export async function getFleetTransactionsSummary(
params: GetFleetTransactionsSummaryParams,
) {
const res = await analyticsServerProxy<{
data: FleetTransactionsSummary;
}>({
method: "GET",
pathname: "/v2/bundler/fleet-transactions/summary",
searchParams: {
teamId: params.teamId,
fleetId: params.fleetId,
from: params.from,
to: params.to,
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
}
/**
* Fetches the current fleet status for a project.
*/
export async function getFleetStatus(
teamSlug: string,
projectSlug: string,
): Promise<Fleet | null> {
const project = await getProject(teamSlug, projectSlug);
if (!project) return null;
const bundlerService = project.services.find((s) => s.name === "bundler");
const fleetConfig =
bundlerService && "dedicatedRelayer" in bundlerService
? (bundlerService.dedicatedRelayer as {
sku: string;
chainIds: number[];
executors: string[];
} | null)
: null;
if (fleetConfig) {
return {
id: buildFleetId(project.teamId, project.id),
chainIds: fleetConfig.chainIds,
executors: fleetConfig.executors,
};
}
return null;
}
@@ -0,0 +1,71 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getFleetStatus,
getFleetTransactions,
getFleetTransactionsSummary,
} from "./api";
type UseFleetTransactionsParams = {
teamId: string;
fleetId: string;
from: string;
to: string;
limit: number;
offset: number;
chainId?: number;
};
/**
* React Query hook for fetching paginated fleet transactions.
*/
export function useFleetTransactions(params: UseFleetTransactionsParams) {
return useQuery({
queryKey: ["fleet-transactions", params],
queryFn: () => getFleetTransactions(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
type UseFleetTransactionsSummaryParams = {
teamId: string;
fleetId: string;
from: string;
to: string;
enabled?: boolean;
refetchInterval?: number;
};
/**
* React Query hook for fetching fleet transactions summary.
*/
export function useFleetTransactionsSummary(
params: UseFleetTransactionsSummaryParams,
) {
return useQuery({
queryKey: ["fleet-transactions-summary", params],
queryFn: () => getFleetTransactionsSummary(params),
refetchOnWindowFocus: false,
enabled: params.enabled !== false,
refetchInterval: params.refetchInterval,
});
}
/**
* React Query hook for fetching fleet status.
* Polls every 5 seconds if enabled.
*/
export function useFleetStatus(
teamSlug: string,
projectSlug: string,
enabled: boolean,
) {
return useQuery({
queryKey: ["fleet-status", teamSlug, projectSlug],
queryFn: () => getFleetStatus(teamSlug, projectSlug),
enabled,
refetchInterval: 5000,
});
}
@@ -0,0 +1,86 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { getLastNDaysRange } from "@/components/analytics/date-range-selector";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { DedicatedRelayerPageClient } from "./components/page-client";
import { buildFleetId, type Fleet } from "./types";
export const dynamic = "force-dynamic";
export default async function DedicatedRelayerPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/dedicated-relayer`,
);
}
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
// Build fleet ID from team and project
const fleetId = buildFleetId(team.id, project.id);
// Default date range: last 7 days
const range = getLastNDaysRange("last-7");
// Extract fleet configuration from bundler service
const bundlerService = project.services.find((s) => s.name === "bundler");
const fleetConfig =
bundlerService && "dedicatedRelayer" in bundlerService
? (bundlerService.dedicatedRelayer as {
sku: string;
chainIds: number[];
executors: string[];
} | null)
: null;
// Convert fleet config to Fleet type
// If fleet is undefined/null, show empty state (not-purchased)
// If fleet.executors is empty, show pending state
// If fleet.executors has addresses, show active state
let initialFleet: Fleet | null = null;
if (fleetConfig) {
initialFleet = {
id: fleetId,
chainIds: fleetConfig.chainIds,
executors: fleetConfig.executors,
};
}
return (
<DedicatedRelayerPageClient
teamId={team.id}
projectId={project.id}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
client={client}
fleetId={fleetId}
from={range.from.toISOString()}
to={range.to.toISOString()}
rangeType={range.type}
initialFleet={initialFleet}
/>
);
}
@@ -0,0 +1,59 @@
/**
* Represents a dedicated relayer fleet configuration.
* Fleet config is fetched from ProjectBundlerService.fleet (service-utils).
*/
export type Fleet = {
id: string;
chainIds: number[];
executors: string[]; // executor wallet addresses
};
/**
* Fleet status based on its state.
*/
export type FleetStatus = "not-purchased" | "pending-setup" | "active";
/**
* Derives the fleet status from the fleet object.
*/
function _getFleetStatus(fleet: Fleet | null): FleetStatus {
if (!fleet) {
return "not-purchased";
}
if (fleet.executors.length === 0) {
return "pending-setup";
}
return "active";
}
/**
* A single transaction from the fleet.
*/
export type FleetTransaction = {
timestamp: string;
chainId: string;
transactionFee: number;
transactionFeeUsd: number | null;
walletAddress: string;
transactionHash: string;
};
/**
* Summary statistics for fleet transactions.
*/
export type FleetTransactionsSummary = {
totalTransactions: number;
totalGasSpentUsd: number;
transactionsByChain: {
chainId: string;
count: number;
gasSpentUsd: number;
}[];
};
/**
* Build the fleet ID from team and project.
*/
export function buildFleetId(teamId: string, projectId: string): string {
return `fleet-${teamId}-${projectId}`;
}
@@ -9,6 +9,7 @@ import { ServerWalletsTable } from "../../../transactions/components/server-wall
import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types";
import { VaultRecoveryCard } from "./vault-recovery-card.client";
export const dynamic = "force-dynamic";
@@ -107,12 +108,10 @@ export default async function Page(props: {
return (
<div className="flex flex-col gap-10">
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
</div>
<VaultRecoveryCard
errorMessage={eoas.error.message}
project={project}
/>
) : (
<ServerWalletsTable
client={client}
@@ -0,0 +1,385 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import {
AlertTriangleIcon,
CheckIcon,
DownloadIcon,
RotateCcwIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import {
createVaultAccountAndAccessToken,
initVaultClient,
maskSecret,
} from "../../../transactions/lib/vault.client";
interface VaultRecoveryCardProps {
errorMessage: string;
project: Project;
}
export function VaultRecoveryCard({
errorMessage,
project,
}: VaultRecoveryCardProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [secretKeyInput, setSecretKeyInput] = useState("");
// Option for managed vault users who lost their secret key
const [manageKeysSelf, setManageKeysSelf] = useState(false);
// For ejected vault key download flow
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
// Check if this was a managed vault (had encryptedAdminKey)
const engineCloudService = project.services.find(
(s) => s.name === "engineCloud",
);
const wasManagedVault = !!engineCloudService?.encryptedAdminKey;
// Will create ejected vault if: wasn't managed, OR user chose to manage keys themselves
const willCreateEjectedVault = !wasManagedVault || manageKeysSelf;
const isInsufficientScopeError =
errorMessage.includes("AUTH_INSUFFICIENT_SCOPE") ||
errorMessage.toLowerCase().includes("insufficient scope");
const regenerateMutation = useMutation({
mutationFn: async () => {
await initVaultClient();
const result = await createVaultAccountAndAccessToken({
project,
// Only pass secret key if creating managed vault (not ejected)
projectSecretKey: willCreateEjectedVault ? undefined : secretKeyInput,
});
return result;
},
onSuccess: () => {
// For managed vaults (with secret key), reload immediately
// For ejected vaults, show the key download dialog first
if (!willCreateEjectedVault) {
window.location.reload();
}
// For ejected vaults, we stay in the dialog to show the admin key
},
});
const handleDownloadKeys = () => {
if (!regenerateMutation.data) {
return;
}
const fileContent = `Project:\n${project.name} (${project.publishableKey})\n\nVault Admin Key:\n${regenerateMutation.data.adminKey}\n\nVault Access Token:\n${regenerateMutation.data.walletToken.accessToken}\n`;
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = `${project.name}-vault-keys.txt`;
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Keys downloaded as ${filename}`);
setKeysDownloaded(true);
};
const handleCloseAfterKeysSaved = () => {
if (!keysConfirmed) {
return;
}
window.location.reload();
};
// For managed vaults creating managed vault, require secret key input
// For ejected vaults (or managed choosing to manage keys), just need confirmation
const canProceed = willCreateEjectedVault
? confirmed
: confirmed && secretKeyInput.trim().length > 0;
if (!isInsufficientScopeError) {
// Show standard error for non-scope errors
return (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="mb-2 font-semibold text-destructive">EVM Wallet Error</p>
<p className="text-muted-foreground text-sm">{errorMessage}</p>
</div>
);
}
return (
<Alert variant="destructive" className="border-destructive/50">
<AlertTriangleIcon className="size-4" />
<AlertTitle>Server Wallet Access Lost</AlertTitle>
<AlertDescription className="mt-3 space-y-4">
<p>
Your server wallet credentials have become invalid. This can happen if
the secret key was rotated but the new credentials weren&apos;t saved
properly.
</p>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<p className="font-medium text-destructive-text text-sm">
⚠️ Important: Existing wallets cannot be recovered
</p>
<p className="mt-1 text-muted-foreground text-sm">
If you have existing wallets that are important to your project,
please{" "}
<a
href="https://thirdweb.com/support"
target="_blank"
rel="noopener noreferrer"
className="text-link-foreground underline"
>
contact support
</a>{" "}
before proceeding.
</p>
</div>
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<RotateCcwIcon className="size-4" />
Create New Server Wallet
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
{/* Show key download UI for ejected vaults after success */}
{willCreateEjectedVault && regenerateMutation.data ? (
<>
<AlertDialogHeader>
<AlertDialogTitle>Save your Vault Admin Key</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p>
You&apos;ll need this key to create server wallets and
access tokens.
</p>
<div className="space-y-2">
<CopyTextButton
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
copyIconPosition="right"
textToCopy={regenerateMutation.data.adminKey}
textToShow={maskSecret(
regenerateMutation.data.adminKey,
)}
tooltip="Copy Admin Key"
/>
<p className="text-muted-foreground text-xs">
Download this key to your local machine or a password
manager.
</p>
</div>
<Alert variant="destructive">
<AlertTitle>Secure your admin key</AlertTitle>
<AlertDescription>
This key will not be displayed again. Store it
securely as it provides access to your server wallets.
</AlertDescription>
<div className="mt-4 flex items-center gap-2">
<Button
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
onClick={handleDownloadKeys}
variant="link"
>
<DownloadIcon className="size-4" />
{keysDownloaded
? "Key Downloaded"
: "Download Admin Key"}
</Button>
{keysDownloaded && (
<span className="text-success-text text-xs">
<CheckIcon className="size-4" />
</span>
)}
</div>
<div className="mt-4">
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={keysConfirmed}
onCheckedChange={(v) => setKeysConfirmed(!!v)}
/>
I confirm that I&apos;ve securely stored my admin
key
</CheckboxWithLabel>
</div>
</Alert>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
disabled={!keysConfirmed}
onClick={handleCloseAfterKeysSaved}
variant="primary"
>
Close
</AlertDialogAction>
</AlertDialogFooter>
</>
) : (
<>
<AlertDialogHeader>
<AlertDialogTitle>
Create New Server Wallet Configuration?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
This will create a completely new server wallet
configuration for your project.
</p>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<p className="font-semibold text-destructive-text text-sm">
Warning: This action cannot be undone
</p>
<ul className="mt-2 list-inside list-disc space-y-1 text-muted-foreground text-sm">
<li>
Any existing server wallets will be permanently
inaccessible
</li>
<li>
You will need to create new wallets after this
process
</li>
</ul>
</div>
{wasManagedVault && (
<div className="space-y-3">
{!manageKeysSelf && (
<>
<label
htmlFor="secret-key-input"
className="font-medium text-sm"
>
Enter your project Secret Key
</label>
<Input
id="secret-key-input"
type="password"
placeholder="sk_..."
value={secretKeyInput}
onChange={(e) =>
setSecretKeyInput(e.target.value)
}
/>
<p className="text-muted-foreground text-xs">
Your secret key is required to create a managed
vault.
</p>
</>
)}
<div className="flex items-start gap-2 border-t pt-3">
<Checkbox
id="manage-keys-self"
checked={manageKeysSelf}
onCheckedChange={(checked) => {
setManageKeysSelf(checked === true);
if (checked) {
setSecretKeyInput("");
}
}}
/>
<label
htmlFor="manage-keys-self"
className="cursor-pointer text-sm leading-tight"
>
I lost my secret key and want to manage vault keys
myself (advanced)
</label>
</div>
</div>
)}
<div className="flex items-start gap-2 pt-2">
<Checkbox
id="confirm-recovery"
checked={confirmed}
onCheckedChange={(checked) =>
setConfirmed(checked === true)
}
/>
<label
htmlFor="confirm-recovery"
className="cursor-pointer text-sm leading-tight"
>
I understand that existing server wallets will not be
recoverable and I want to proceed
</label>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setConfirmed(false);
setSecretKeyInput("");
setManageKeysSelf(false);
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!canProceed || regenerateMutation.isPending}
onClick={(e) => {
e.preventDefault();
regenerateMutation.mutate();
}}
variant="destructive"
className="gap-2"
>
{regenerateMutation.isPending && (
<Spinner className="size-4" />
)}
Create New Wallet Configuration
</AlertDialogAction>
</AlertDialogFooter>
</>
)}
</AlertDialogContent>
</AlertDialog>
{regenerateMutation.error && (
<p className="text-destructive-text text-sm">
Failed to create new configuration:{" "}
{regenerateMutation.error.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Error details: {errorMessage}
</p>
</AlertDescription>
</Alert>
);
}
@@ -8,6 +8,7 @@ import RoutesImage from "../assets/routes.png";
import TokensImage from "../assets/tokens.png";
import { bridgeStats, bridgeStatsNumbers } from "../data";
import { AnimatedNumbers } from "./client/animated-numbers";
import { AddBridgeWidgetLink } from "./client/badge-link";
import { UniversalBridgeEmbed } from "./client/UniversalBridgeEmbed";
import { BridgePageHeader } from "./header";
@@ -29,6 +30,12 @@ export function BridgePageUI(props: {
/>
</div>
<div className="container max-w-2xl flex justify-center">
<AddBridgeWidgetLink />
</div>
<div className="h-10" />
<HeadingSection title={props.title} />
<div className="h-20 lg:h-40" />
@@ -43,7 +50,7 @@ export function BridgePageUI(props: {
function HeadingSection(props: { title: React.ReactNode }) {
return (
<div className="container">
<div className="mb-3 lg:mb-6">{props.title}</div>
<div className="mb-3 lg:mb-5">{props.title}</div>
<p className="text-muted-foreground text-sm text-pretty text-center lg:text-lg mb-6 lg:mb-8">
Seamlessly move your assets across {bridgeStats.supportedChains} chains
@@ -81,9 +88,9 @@ function DataSquare(props: {
imageClassName?: string;
}) {
return (
<div className="py-2 lg:py-0 size-full lg:size-[220px] rounded-xl border hover:bg-card bg-card/50 relative shrink-0 overflow-hidden">
<div className="py-2 lg:py-0 size-full lg:size-[200px] rounded-2xl border hover:bg-card bg-card/50 relative shrink-0 overflow-hidden">
<div className="p-4">
<div className="flex items-center gap-1 text-3xl lg:text-5xl font-medium tracking-tight font-mono mb-1 h-[45px] lg:h-[56px]">
<div className="flex items-center gap-1 text-3xl lg:text-4xl font-semibold tracking-tight mb-1 h-[45px]">
<AnimatedNumbers
value={props.data}
format={props.format}
@@ -40,3 +40,25 @@ export function BridgeProviders({
</ThirdwebProvider>
);
}
export function BridgeProvidersLite({
children,
forcedTheme,
}: {
children: React.ReactNode;
forcedTheme?: string;
}) {
return (
<ThirdwebProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
forcedTheme={forcedTheme}
>
{children}
</ThemeProvider>
</ThirdwebProvider>
);
}
@@ -1,5 +1,6 @@
"use client";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { createWallet } from "thirdweb/wallets";
import {
BuyAndSwapEmbed,
@@ -19,16 +20,22 @@ export const bridgeWallets = [
];
export function UniversalBridgeEmbed(props: {
persistTokenSelections?: boolean;
buyTab: BuyAndSwapEmbedProps["buyTab"];
swapTab: BuyAndSwapEmbedProps["swapTab"];
pageType: "bridge" | "bridge-iframe";
currency?: SupportedFiatCurrency;
showThirdwebBranding?: boolean;
}) {
return (
<BuyAndSwapEmbed
persistTokenSelections={props.persistTokenSelections}
currency={props.currency}
buyTab={props.buyTab}
swapTab={props.swapTab}
pageType={props.pageType}
wallets={bridgeWallets}
showThirdwebBranding={props.showThirdwebBranding}
/>
);
}
@@ -0,0 +1,34 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { reportBridgePageLinkClick } from "@/analytics/report";
export function AddBridgeWidgetLink() {
return (
<BadgeLink
href="https://portal.thirdweb.com/bridge/bridge-widget"
label="Add Bridge widget in your app"
onClick={() => {
reportBridgePageLinkClick({ linkType: "integrate-bridge-widget" });
}}
/>
);
}
function BadgeLink(props: {
href: string;
label: string;
onClick?: () => void;
}) {
return (
<Link
href={props.href}
className="text-sm text-foreground bg-accent/50 rounded-full px-4 py-2 border hover:bg-accent flex items-center gap-2"
target="_blank"
onClick={props.onClick}
>
{props.label}
<ArrowUpRightIcon className="size-4 text-foreground" />
</Link>
);
}
@@ -13,10 +13,10 @@ import { bridgeWallets } from "./client/UniversalBridgeEmbed";
export function BridgePageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b border-border/70">
<div>
<header
className={cn(
"container flex max-w-7xl justify-between py-3 lg:py-4",
"container flex max-w-5xl justify-between py-3 lg:py-5",
props.containerClassName,
)}
>
@@ -1,11 +1,11 @@
export const bridgeStats = {
supportedChains: "95+",
supportedTokens: "14,000+",
supportedTokens: "17,000+",
supportedRoutes: "15M+",
};
export const bridgeStatsNumbers = {
supportedChains: 95,
supportedTokens: 14000,
supportedTokens: 17000,
supportedRoutes: 15000000,
};
@@ -60,7 +60,7 @@ export default async function Page(props: {
: undefined,
}}
title={
<h1 className="text-3xl md:text-6xl font-semibold tracking-tighter text-balance text-center">
<h1 className="text-3xl md:text-5xl font-semibold tracking-tighter text-balance text-center">
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
chain, instantly
</h1>
@@ -0,0 +1,36 @@
import type { SupportedFiatCurrency } from "thirdweb/react";
export function isValidCurrency(
currency: string,
): currency is SupportedFiatCurrency {
if (currency in VALID_CURRENCIES) {
return true;
}
return false;
}
const VALID_CURRENCIES: Record<SupportedFiatCurrency, true> = {
USD: true,
EUR: true,
GBP: true,
JPY: true,
KRW: true,
CNY: true,
INR: true,
NOK: true,
SEK: true,
CHF: true,
AUD: true,
CAD: true,
NZD: true,
MXN: true,
BRL: true,
CLP: true,
CZK: true,
DKK: true,
HKD: true,
HUF: true,
IDR: true,
ILS: true,
ISK: true,
};
@@ -0,0 +1,15 @@
import { isAddress } from "thirdweb";
export function parseQueryParams<T>(
value: string | string[] | undefined,
fn: (value: string) => T | undefined,
): T | undefined {
if (typeof value === "string") {
return fn(value);
}
return undefined;
}
export const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
export const onlyNumber = (v: string) =>
Number.isNaN(Number(v)) ? undefined : Number(v);
@@ -0,0 +1,101 @@
"use client";
import { useMemo } from "react";
import { type Address, defineChain } from "thirdweb";
import { BuyWidget, type SupportedFiatCurrency } from "thirdweb/react";
import { NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
export function BuyWidgetEmbed({
chainId,
tokenAddress,
amount,
showThirdwebBranding,
theme,
currency,
title,
description,
image,
paymentMethods,
buttonLabel,
receiverAddress,
country,
amountEditable,
tokenEditable,
}: {
chainId?: number;
tokenAddress?: Address;
amount?: string;
showThirdwebBranding?: boolean;
theme: "light" | "dark";
currency?: SupportedFiatCurrency;
title?: string;
description?: string;
image?: string;
paymentMethods?: ("crypto" | "card")[];
buttonLabel?: string;
receiverAddress?: Address;
country?: string;
amountEditable?: boolean;
tokenEditable?: boolean;
}) {
const client = useMemo(
() =>
getConfiguredThirdwebClient({
clientId: NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID,
secretKey: undefined,
teamId: undefined,
}),
[],
);
const chain = useMemo(() => {
if (!chainId) return undefined;
// eslint-disable-next-line no-restricted-syntax
return defineChain(chainId);
}, [chainId]);
return (
<BuyWidget
className="shadow-xl"
client={client}
chain={chain}
tokenAddress={tokenAddress}
amount={amount}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
title={title}
description={description}
image={image}
paymentMethods={paymentMethods}
buttonLabel={buttonLabel}
receiverAddress={receiverAddress}
country={country}
amountEditable={amountEditable}
tokenEditable={tokenEditable}
onSuccess={() => {
sendMessageToParent({
source: "buy-widget",
type: "success",
});
}}
onError={(error) => {
sendMessageToParent({
source: "buy-widget",
type: "error",
message: error.message,
});
}}
/>
);
}
function sendMessageToParent(content: object) {
try {
window.parent.postMessage(content, "*");
} catch (error) {
console.error("Failed to send post message to parent window");
console.error(error);
}
}
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
const fontSans = Inter({
display: "swap",
subsets: ["latin"],
variable: "--font-sans",
});
export default function BuyWidgetLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
{children}
</body>
</html>
);
}
@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import "@workspace/ui/global.css";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
import { BuyWidgetEmbed } from "./BuyWidgetEmbed.client";
const title = "thirdweb Buy: Purchase Crypto with Ease";
const description =
"Buy crypto tokens with credit card or other payment methods. Simple and secure purchases with thirdweb.";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
type SearchParams = {
[key: string]: string | string[] | undefined;
};
export default async function Page(props: {
searchParams: Promise<SearchParams>;
}) {
const searchParams = await props.searchParams;
// Token params
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
const amount = parseQueryParams(searchParams.amount, (v) => v);
// Optional params
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
(v) => (v === "false" ? false : true),
);
const theme =
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
);
// Metadata params
const widgetTitle = parseQueryParams(searchParams.title, (v) => v);
const widgetDescription = parseQueryParams(
searchParams.description,
(v) => v,
);
const image = parseQueryParams(searchParams.image, (v) => v);
// Payment params
const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => {
if (v === "crypto" || v === "card") {
return [v] as ("crypto" | "card")[];
}
return undefined;
});
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
const receiverAddress = parseQueryParams(searchParams.receiver, onlyAddress);
const country = parseQueryParams(searchParams.country, (v) => v);
// Editable params
const amountEditable = parseQueryParams(searchParams.amountEditable, (v) =>
v === "false" ? false : undefined,
);
const tokenEditable = parseQueryParams(searchParams.tokenEditable, (v) =>
v === "false" ? false : undefined,
);
return (
<BridgeProvidersLite forcedTheme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<BuyWidgetEmbed
chainId={chainId}
tokenAddress={tokenAddress}
amount={amount}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
title={widgetTitle}
description={widgetDescription}
image={image}
paymentMethods={paymentMethods}
buttonLabel={buttonLabel}
receiverAddress={receiverAddress}
country={country}
amountEditable={amountEditable}
tokenEditable={tokenEditable}
/>
</div>
</BridgeProvidersLite>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useMemo } from "react";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb";
import { CheckoutWidget, type SupportedFiatCurrency } from "thirdweb/react";
import { NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
export function CheckoutWidgetEmbed({
chainId,
amount,
seller,
tokenAddress,
name,
description,
image,
buttonLabel,
feePayer,
country,
showThirdwebBranding,
theme,
currency,
paymentMethods,
}: {
chainId: number;
amount: string;
seller: Address;
tokenAddress?: Address;
name?: string;
description?: string;
image?: string;
buttonLabel?: string;
feePayer?: "user" | "seller";
country?: string;
showThirdwebBranding?: boolean;
theme: "light" | "dark";
currency?: SupportedFiatCurrency;
paymentMethods?: ("crypto" | "card")[];
}) {
const client = useMemo(
() =>
getConfiguredThirdwebClient({
clientId: NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID,
secretKey: undefined,
teamId: undefined,
}),
[],
);
// eslint-disable-next-line no-restricted-syntax
const chain = useMemo(() => defineChain(chainId), [chainId]);
return (
<CheckoutWidget
className="shadow-xl"
client={client}
chain={chain}
amount={amount}
seller={seller}
tokenAddress={tokenAddress}
name={name}
description={description}
image={image}
buttonLabel={buttonLabel}
feePayer={feePayer}
country={country}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
paymentMethods={paymentMethods}
onSuccess={() => {
sendMessageToParent({
source: "checkout-widget",
type: "success",
});
}}
onError={(error) => {
sendMessageToParent({
source: "checkout-widget",
type: "error",
message: error.message,
});
}}
/>
);
}
function sendMessageToParent(content: object) {
try {
window.parent.postMessage(content, "*");
} catch (error) {
console.error("Failed to send post message to parent window");
console.error(error);
}
}
@@ -0,0 +1,140 @@
import type { Metadata } from "next";
import "@workspace/ui/global.css";
import { InlineCode } from "@workspace/ui/components/code/inline-code";
import { AlertTriangleIcon } from "lucide-react";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
import { CheckoutWidgetEmbed } from "./CheckoutWidgetEmbed.client";
const title = "thirdweb Checkout: Accept Crypto & Fiat Payments";
const description =
"Accept fiat or crypto payments on any chain—direct to your wallet. Instant checkout, webhook support, and full control over post-sale actions.";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
type SearchParams = {
[key: string]: string | string[] | undefined;
};
export default async function Page(props: {
searchParams: Promise<SearchParams>;
}) {
const searchParams = await props.searchParams;
// Required params
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
const amount = parseQueryParams(searchParams.amount, (v) => v);
const seller = parseQueryParams(searchParams.seller, onlyAddress);
// Optional params
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
const title = parseQueryParams(searchParams.title, (v) => v);
const productDescription = parseQueryParams(
searchParams.description,
(v) => v,
);
const image = parseQueryParams(searchParams.image, (v) => v);
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
const feePayer = parseQueryParams(searchParams.feePayer, (v) =>
v === "seller" || v === "user" ? v : undefined,
);
const country = parseQueryParams(searchParams.country, (v) => v);
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
(v) => v !== "false",
);
const theme =
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
);
const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => {
if (v === "crypto" || v === "card") {
return [v] as ("crypto" | "card")[];
}
return undefined;
});
// Validate required params
if (!chainId || !amount || !seller) {
return (
<BridgeProvidersLite forcedTheme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-xl">
<div className="p-2.5 inline-flex rounded-full bg-background mb-4 border">
<AlertTriangleIcon className="size-5 text-destructive-text" />
</div>
<h2 className="mb-2 font-semibold text-destructive-text text-lg">
Invalid Configuration
</h2>
<p className="text-muted-foreground text-sm mb-4">
The following query parameters are required but are missing:
</p>
<ul className="mt-2 text-left text-muted-foreground text-sm space-y-2">
{!chainId && (
<li>
• <InlineCode code="chain" /> - Chain ID (e.g., 1, 8453,
42161)
</li>
)}
{!amount && (
<li>
• <InlineCode code="amount" /> - Amount to charge (e.g.,
"0.01")
</li>
)}
{!seller && (
<li>
• <InlineCode code="seller" /> - Seller wallet address
</li>
)}
</ul>
</div>
</div>
</BridgeProvidersLite>
);
}
return (
<BridgeProvidersLite forcedTheme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<CheckoutWidgetEmbed
chainId={chainId}
amount={amount}
seller={seller}
tokenAddress={tokenAddress}
name={title}
description={productDescription}
image={image}
buttonLabel={buttonLabel}
feePayer={feePayer}
country={country}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
paymentMethods={paymentMethods}
/>
</div>
</BridgeProvidersLite>
);
}
@@ -0,0 +1,107 @@
"use client";
import { useMemo } from "react";
import type { Address } from "thirdweb";
import { type SupportedFiatCurrency, SwapWidget } from "thirdweb/react";
import { NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
export function SwapWidgetEmbed({
buyChainId,
buyTokenAddress,
buyAmount,
sellChainId,
sellTokenAddress,
sellAmount,
showThirdwebBranding,
theme,
currency,
persistTokenSelections,
}: {
buyChainId?: number;
buyTokenAddress?: Address;
buyAmount?: string;
sellChainId?: number;
sellTokenAddress?: Address;
sellAmount?: string;
showThirdwebBranding?: boolean;
theme: "light" | "dark";
currency?: SupportedFiatCurrency;
persistTokenSelections?: boolean;
}) {
const client = useMemo(
() =>
getConfiguredThirdwebClient({
clientId: NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID,
secretKey: undefined,
teamId: undefined,
}),
[],
);
const prefill = useMemo(() => {
const result: {
buyToken?: { chainId: number; tokenAddress?: string; amount?: string };
sellToken?: { chainId: number; tokenAddress?: string; amount?: string };
} = {};
if (buyChainId) {
result.buyToken = {
chainId: buyChainId,
tokenAddress: buyTokenAddress,
amount: buyAmount,
};
}
if (sellChainId) {
result.sellToken = {
chainId: sellChainId,
tokenAddress: sellTokenAddress,
amount: sellAmount,
};
}
return Object.keys(result).length > 0 ? result : undefined;
}, [
buyChainId,
buyTokenAddress,
buyAmount,
sellChainId,
sellTokenAddress,
sellAmount,
]);
return (
<SwapWidget
className="shadow-xl"
client={client}
prefill={prefill}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
persistTokenSelections={persistTokenSelections}
onSuccess={() => {
sendMessageToParent({
source: "swap-widget",
type: "success",
});
}}
onError={(error) => {
sendMessageToParent({
source: "swap-widget",
type: "error",
message: error.message,
});
}}
/>
);
}
function sendMessageToParent(content: object) {
try {
window.parent.postMessage(content, "*");
} catch (error) {
console.error("Failed to send post message to parent window");
console.error(error);
}
}
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
const fontSans = Inter({
display: "swap",
subsets: ["latin"],
variable: "--font-sans",
});
export default function SwapWidgetLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
{children}
</body>
</html>
);
}
@@ -0,0 +1,91 @@
import type { Metadata } from "next";
import "@workspace/ui/global.css";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
import { SwapWidgetEmbed } from "./SwapWidgetEmbed.client";
const title = "thirdweb Swap: Cross-Chain Token Swaps";
const description =
"Swap tokens across any chain with the best rates. Cross-chain swaps made simple with thirdweb.";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
type SearchParams = {
[key: string]: string | string[] | undefined;
};
export default async function Page(props: {
searchParams: Promise<SearchParams>;
}) {
const searchParams = await props.searchParams;
// Buy token params
const buyChainId = parseQueryParams(searchParams.buyChain, onlyNumber);
const buyTokenAddress = parseQueryParams(
searchParams.buyTokenAddress,
onlyAddress,
);
const buyAmount = parseQueryParams(searchParams.buyAmount, (v) => v);
// Sell token params
const sellChainId = parseQueryParams(searchParams.sellChain, onlyNumber);
const sellTokenAddress = parseQueryParams(
searchParams.sellTokenAddress,
onlyAddress,
);
const sellAmount = parseQueryParams(searchParams.sellAmount, (v) => v);
// Optional params
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
(v) => (v === "false" ? false : true),
);
const persistTokenSelections = parseQueryParams(
searchParams.persistTokenSelections,
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
(v) => (v === "false" ? false : true),
);
const theme =
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
);
return (
<BridgeProvidersLite forcedTheme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<SwapWidgetEmbed
buyChainId={buyChainId}
buyTokenAddress={buyTokenAddress}
buyAmount={buyAmount}
sellChainId={sellChainId}
sellTokenAddress={sellTokenAddress}
sellAmount={sellAmount}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
persistTokenSelections={persistTokenSelections}
/>
</div>
</BridgeProvidersLite>
);
}
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
const fontSans = Inter({
display: "swap",
subsets: ["latin"],
variable: "--font-sans",
});
export default function BridgeEmbedLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
{children}
</body>
</html>
);
}

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

@@ -1,9 +1,15 @@
import type { Metadata } from "next";
import { isAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { UniversalBridgeEmbed } from "../(general)/components/client/UniversalBridgeEmbed";
import { bridgeStats } from "../(general)/data";
import "@workspace/ui/global.css";
import { NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProviders } from "../(general)/components/client/Providers.client";
const title = `thirdweb Bridge: Buy, Bridge & Swap Crypto on ${bridgeStats.supportedChains} Chains`;
@@ -27,31 +33,61 @@ export default async function Page(props: {
}) {
const searchParams = await props.searchParams;
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
const onlyNumber = (v: string) =>
Number.isNaN(Number(v)) ? undefined : Number(v);
// output is buy, input is sell
const sellChain = parse(searchParams.inputChain, onlyNumber);
const sellCurrency = parse(searchParams.inputCurrency, onlyAddress);
const sellChain = parseQueryParams(searchParams.inputChain, onlyNumber);
const sellCurrency = parseQueryParams(
searchParams.inputCurrency,
onlyAddress,
);
const sellAmount = parseQueryParams(
searchParams.inputCurrencyAmount,
onlyNumber,
);
const buyChain = parse(searchParams.outputChain, onlyNumber);
const buyCurrency = parse(searchParams.outputCurrency, onlyAddress);
const buyChain = parseQueryParams(searchParams.outputChain, onlyNumber);
const buyCurrency = parseQueryParams(
searchParams.outputCurrency,
onlyAddress,
);
const buyAmount = parseQueryParams(
searchParams.outputCurrencyAmount,
onlyNumber,
);
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
(v) => v !== "false",
);
const persistTokenSelections =
parseQueryParams(searchParams.persistTokenSelections, (v) =>
v === "false" ? "false" : "true",
) || "true";
const theme =
parse(searchParams.theme, (v) => (v === "light" ? "light" : "dark")) ||
"dark";
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? v : undefined,
);
return (
<Providers theme={theme}>
<div className="flex items-center justify-center min-h-screen py-6 bg-background">
<UniversalBridgeEmbed
persistTokenSelections={persistTokenSelections === "true"}
pageType="bridge-iframe"
currency={currency}
showThirdwebBranding={showThirdwebBranding}
buyTab={{
buyToken: buyChain
? {
chainId: buyChain,
tokenAddress: buyCurrency || NATIVE_TOKEN_ADDRESS,
amount:
buyAmount === undefined ? undefined : buyAmount.toString(),
}
: undefined,
}}
@@ -60,12 +96,18 @@ export default async function Page(props: {
? {
chainId: sellChain,
tokenAddress: sellCurrency || NATIVE_TOKEN_ADDRESS,
amount:
sellAmount === undefined
? undefined
: sellAmount.toString(),
}
: undefined,
buyToken: buyChain
? {
chainId: buyChain,
tokenAddress: buyCurrency || NATIVE_TOKEN_ADDRESS,
amount:
buyAmount === undefined ? undefined : buyAmount.toString(),
}
: undefined,
}}
@@ -75,16 +117,6 @@ export default async function Page(props: {
);
}
function parse<T>(
value: string | string[] | undefined,
fn: (value: string) => T | undefined,
): T | undefined {
if (typeof value === "string") {
return fn(value);
}
return undefined;
}
function Providers({
children,
theme,
+83 -83
View File
@@ -1,85 +1,85 @@
{
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@tanstack/react-query": "5.81.5",
"@vercel/functions": "2.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/eslint-plugin-next": "15.3.6",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/node": "22.14.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "nebula-app",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit"
},
"version": "0.0.2"
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@tanstack/react-query": "5.81.5",
"@vercel/functions": "2.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"lucide-react": "0.525.0",
"next": "15.3.8",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/eslint-plugin-next": "15.3.8",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/node": "22.14.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "nebula-app",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit"
},
"version": "0.0.2"
}
+83 -83
View File
@@ -1,85 +1,85 @@
{
"dependencies": {
"@abstract-foundation/agw-react": "^1.10.0",
"@ai-sdk/react": "^2.0.25",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@scalar/api-reference-react": "^0.7.42",
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/ai-sdk-provider": "workspace:*",
"@workspace/ui": "workspace:*",
"ai": "^5.0.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"geist": "^1.5.1",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "12.1.3",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-pick-color": "^2.0.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"streamdown": "^1.1.4",
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@types/node": "22.14.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-config-next": "15.3.6",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"knip": "5.60.2",
"postcss": "8.5.6",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.8.3"
},
"name": "playground-web",
"private": true,
"scripts": {
"build": "next build",
"dev": "rm -rf .next && next dev --turbopack",
"fix": "eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"prefix": "biome check ./src --fix",
"prelint": "biome check ./src",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"version": "0.1.1"
"dependencies": {
"@abstract-foundation/agw-react": "^1.10.0",
"@ai-sdk/react": "^2.0.25",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@scalar/api-reference-react": "^0.7.42",
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/ai-sdk-provider": "workspace:*",
"@workspace/ui": "workspace:*",
"ai": "^5.0.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"geist": "^1.5.1",
"lucide-react": "0.525.0",
"next": "15.3.8",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "12.1.3",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-pick-color": "^2.0.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"streamdown": "^1.1.4",
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@types/node": "22.14.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-config-next": "15.3.8",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"knip": "5.60.2",
"postcss": "8.5.6",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.8.3"
},
"name": "playground-web",
"private": true,
"scripts": {
"build": "next build",
"dev": "rm -rf .next && next dev --turbopack",
"fix": "eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"prefix": "biome check ./src --fix",
"prelint": "biome check ./src",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"version": "0.1.1"
}
@@ -84,7 +84,7 @@ export function ChatPageContent(props: {
const userChainIdArray = userChainIds
.split(",")
.map((id) => id.trim())
.filter((id) => id !== "" && !isNaN(Number(id)));
.filter((id) => id !== "" && !Number.isNaN(Number(id)));
return {
chainIds:
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { createThirdwebClient, defineChain } from "thirdweb";
import { toUnits } from "thirdweb/utils";
import { facilitator, settlePayment } from "thirdweb/x402";
import { facilitator, settlePayment, verifyPayment } from "thirdweb/x402";
import { token } from "../../x402/components/constants";
// Allow streaming responses up to 5 minutes
@@ -27,7 +27,9 @@ export async function GET(request: NextRequest) {
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
const paymentData = request.headers.get("X-PAYMENT");
const paymentData =
request.headers.get("PAYMENT-SIGNATURE") ||
request.headers.get("X-PAYMENT");
const queryParams = request.nextUrl.searchParams;
const chainId = queryParams.get("chainId");
@@ -46,27 +48,78 @@ export async function GET(request: NextRequest) {
const waitUntil =
(queryParams.get("waitUntil") as "simulated" | "submitted" | "confirmed") ||
"simulated";
const scheme = (queryParams.get("scheme") as "exact" | "upto") || "exact";
const minPriceAmount = queryParams.get("minPrice");
const settlementAmount = queryParams.get("settlementAmount");
const result = await settlePayment({
const priceConfig = tokenAddress
? {
amount: toUnits(amount, parseInt(decimals)).toString(),
asset: {
address: tokenAddress as `0x${string}`,
decimals: decimals ? parseInt(decimals) : token.decimals,
},
}
: amount;
const minPriceConfig =
scheme === "upto" && minPriceAmount
? tokenAddress
? {
amount: toUnits(minPriceAmount, parseInt(decimals)).toString(),
asset: {
address: tokenAddress as `0x${string}`,
decimals: decimals ? parseInt(decimals) : token.decimals,
},
}
: minPriceAmount
: undefined;
let finalPriceConfig = priceConfig;
const paymentArgs = {
resourceUrl: "https://playground-web.thirdweb.com/api/paywall",
method: "GET",
paymentData,
network: defineChain(Number(chainId)),
payTo,
price: tokenAddress
? {
amount: toUnits(amount, parseInt(decimals)).toString(),
asset: {
address: tokenAddress as `0x${string}`,
decimals: decimals ? parseInt(decimals) : token.decimals,
},
}
: amount,
scheme,
price: priceConfig,
minPrice: minPriceConfig,
routeConfig: {
description: "Access to paid content",
},
waitUntil,
facilitator: twFacilitator,
};
if (minPriceConfig) {
const verifyResult = await verifyPayment(paymentArgs);
if (verifyResult.status !== 200) {
return NextResponse.json(verifyResult.responseBody, {
status: verifyResult.status,
headers: verifyResult.responseHeaders,
});
}
// If settlementAmount is provided, override the price for settlement
if (settlementAmount) {
finalPriceConfig = tokenAddress
? {
amount: toUnits(settlementAmount, parseInt(decimals)).toString(),
asset: {
address: tokenAddress as `0x${string}`,
decimals: decimals ? parseInt(decimals) : token.decimals,
},
}
: settlementAmount;
}
}
const result = await settlePayment({
...paymentArgs,
price: finalPriceConfig,
});
if (result.status === 200) {
@@ -76,7 +129,7 @@ export async function GET(request: NextRequest) {
success: true,
message: "Payment successful. You have accessed the protected route.",
payment: {
amount,
amount: settlementAmount || amount,
tokenAddress,
},
receipt: result.paymentReceipt,
@@ -0,0 +1,94 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { TabButtons } from "../../../../components/ui/tab-buttons";
import { LeftSection } from "./left-section";
import { RightSection } from "./right-section";
import type { BridgeWidgetPlaygroundOptions } from "./types";
const defaultOptions: BridgeWidgetPlaygroundOptions = {
integrationType: "iframe",
prefill: undefined,
currency: "USD",
showThirdwebBranding: true,
theme: {
darkColorOverrides: {},
lightColorOverrides: {},
type: "dark",
},
};
function updatePageUrl(tab: BridgeWidgetPlaygroundOptions["integrationType"]) {
const url = new URL(window.location.href);
if (tab === defaultOptions.integrationType) {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tab);
}
window.history.replaceState({}, "", url.toString());
}
export function BridgeWidgetPlayground(props: {
defaultTab: "iframe" | "script" | "react" | undefined;
}) {
const { theme } = useTheme();
const [options, setOptions] = useState<BridgeWidgetPlaygroundOptions>(() => {
return {
...defaultOptions,
integrationType: props.defaultTab || defaultOptions.integrationType,
};
});
// change theme on global theme change
useEffect(() => {
setOptions((prev) => ({
...prev,
theme: {
...prev.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
}, [theme]);
useEffect(() => {
updatePageUrl(options.integrationType);
}, [options.integrationType]);
return (
<div>
<TabButtons
tabs={[
{
name: "Iframe",
onClick: () =>
setOptions({ ...options, integrationType: "iframe" }),
isActive: options.integrationType === "iframe",
},
{
name: "Script",
onClick: () =>
setOptions({ ...options, integrationType: "script" }),
isActive: options.integrationType === "script",
},
{
name: "React",
onClick: () => setOptions({ ...options, integrationType: "react" }),
isActive: options.integrationType === "react",
},
]}
/>
<div className="h-6" />
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
<LeftSection options={options} setOptions={setOptions} />
</div>
<RightSection options={options} />
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import type { BridgeWidgetPlaygroundOptions } from "./types";
const BRIDGE_WIDGET_IFRAME_BASE_URL = "https://thirdweb.com/bridge/widget";
export function buildIframeUrl(
options: BridgeWidgetPlaygroundOptions,
type: "code" | "preview",
) {
const url = new URL(BRIDGE_WIDGET_IFRAME_BASE_URL);
if (type === "preview") {
// always set it to false so playground doesn't show last used tokens
url.searchParams.set("persistTokenSelections", "false");
}
// Theme (only add if light, dark is default)
if (options.theme.type === "light") {
url.searchParams.set("theme", "light");
}
// Currency (only add if not USD, USD is default)
if (options.currency && options.currency !== "USD") {
url.searchParams.set("currency", options.currency);
}
// Branding
if (options.showThirdwebBranding === false) {
url.searchParams.set("showThirdwebBranding", "false");
}
// Sell token (input)
if (options.prefill?.sellToken) {
url.searchParams.set(
"inputChain",
String(options.prefill.sellToken.chainId),
);
if (options.prefill.sellToken.tokenAddress) {
url.searchParams.set(
"inputCurrency",
options.prefill.sellToken.tokenAddress,
);
}
if (options.prefill.sellToken.amount) {
url.searchParams.set(
"inputCurrencyAmount",
options.prefill.sellToken.amount,
);
}
}
// Buy token (output)
if (options.prefill?.buyToken) {
url.searchParams.set(
"outputChain",
String(options.prefill.buyToken.chainId),
);
if (options.prefill.buyToken.tokenAddress) {
url.searchParams.set(
"outputCurrency",
options.prefill.buyToken.tokenAddress,
);
}
if (options.prefill.buyToken.amount) {
url.searchParams.set(
"outputCurrencyAmount",
options.prefill.buyToken.amount,
);
}
}
return url.toString();
}
@@ -0,0 +1,214 @@
import { lazy, Suspense } from "react";
import { LoadingDots } from "@/components/ui/LoadingDots";
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
import { buildIframeUrl } from "./buildIframeUrl";
import type { BridgeWidgetPlaygroundOptions } from "./types";
const CodeClient = lazy(() =>
import("../../../../components/code/code.client").then((m) => ({
default: m.CodeClient,
})),
);
function CodeLoading() {
return (
<div className="flex min-h-[300px] grow items-center justify-center bg-card border rounded-lg">
<LoadingDots />
</div>
);
}
export function CodeGen(props: { options: BridgeWidgetPlaygroundOptions }) {
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient
className="grow"
code={getCode(props.options)}
lang={props.options.integrationType === "react" ? "tsx" : "html"}
/>
</Suspense>
</div>
);
}
function getCode(options: BridgeWidgetPlaygroundOptions) {
if (options.integrationType === "script") {
return getCode_Script(options);
}
if (options.integrationType === "react") {
return getCode_ReactComponent(options);
}
if (options.integrationType === "iframe") {
return getCode_Iframe(options);
}
return "";
}
function getCode_Script(options: BridgeWidgetPlaygroundOptions) {
const widgetOptions: Record<string, unknown> = {
clientId: "your-thirdweb-client-id",
};
// Theme configuration
if (options.theme.type === "light") {
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
widgetOptions.theme = {
type: "light",
...options.theme.lightColorOverrides,
};
} else {
widgetOptions.theme = "light";
}
} else {
// dark theme
if (Object.keys(options.theme.darkColorOverrides || {}).length > 0) {
widgetOptions.theme = {
type: "dark",
...options.theme.darkColorOverrides,
};
}
// default is dark, so no need to set if no overrides
}
// Currency
if (options.currency && options.currency !== "USD") {
widgetOptions.currency = options.currency;
}
// Branding
if (options.showThirdwebBranding === false) {
widgetOptions.showThirdwebBranding = false;
}
// Buy tab options (for buyToken)
if (options.prefill?.buyToken) {
widgetOptions.buy = {
chainId: options.prefill.buyToken.chainId,
tokenAddress: options.prefill.buyToken.tokenAddress,
amount: options.prefill.buyToken.amount,
};
}
// Swap prefill options
if (options.prefill?.buyToken || options.prefill?.sellToken) {
widgetOptions.swap = {
prefill: options.prefill,
};
}
const optionsString = JSON.stringify(widgetOptions, null, 2)
.split("\n")
.map((line, i) => (i === 0 ? line : ` ${line}`))
.join("\n");
return `\
<!-- Add this script in <head> -->
<script src="https://unpkg.com/thirdweb/dist/scripts/bridge-widget.js"></script>
<!-- Add a container element where you want to render the UI -->
<div id="bridge-widget-container"></div>
<!-- Initialize the widget at the end of <body> -->
<script>
const container = document.querySelector("#bridge-widget-container");
BridgeWidget.render(container, ${optionsString});
</script>`;
}
function getCode_ReactComponent(options: BridgeWidgetPlaygroundOptions) {
const imports = {
thirdweb: ["createThirdwebClient"] as string[],
"thirdweb/react": ["BridgeWidget"] as string[],
};
let themeProp: string | undefined;
if (
options.theme.type === "dark" &&
Object.keys(options.theme.darkColorOverrides || {}).length > 0
) {
themeProp = `darkTheme({
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
})`;
imports["thirdweb/react"].push("darkTheme");
}
if (options.theme.type === "light") {
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
themeProp = `lightTheme({
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
})`;
imports["thirdweb/react"].push("lightTheme");
} else {
themeProp = quotes("light");
}
}
// Build buy prop for buyToken
let buyProp: string | undefined;
if (options.prefill?.buyToken) {
buyProp = JSON.stringify(
{
chainId: options.prefill.buyToken.chainId,
tokenAddress: options.prefill.buyToken.tokenAddress,
amount: options.prefill.buyToken.amount,
},
null,
2,
);
}
// Build swap prop with prefill
let swapProp: string | undefined;
if (options.prefill?.buyToken || options.prefill?.sellToken) {
swapProp = JSON.stringify(
{
prefill: {
buyToken: options.prefill?.buyToken,
sellToken: options.prefill?.sellToken,
},
},
null,
2,
);
}
const props: Record<string, string | undefined | boolean> = {
client: "client",
theme: themeProp,
buy: buyProp,
swap: swapProp,
currency:
options.currency !== "USD" && options.currency
? quotes(options.currency)
: undefined,
showThirdwebBranding:
options.showThirdwebBranding === false ? false : undefined,
};
return `\
${stringifyImports(imports)}
const client = createThirdwebClient({
clientId: "your-thirdweb-client-id",
});
function Example() {
return (
<BridgeWidget ${stringifyProps(props)} />
);
}`;
}
function getCode_Iframe(options: BridgeWidgetPlaygroundOptions) {
const src = buildIframeUrl(options, "code");
return `\
<iframe
src="${src}"
height="750px"
width="100%"
style="border: 0;"
/>`;
}
@@ -0,0 +1,232 @@
"use client";
import { CoinsIcon, PaletteIcon } from "lucide-react";
import type React from "react";
import { useId } from "react";
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { CollapsibleSection } from "@/app/wallets/sign-in/components/CollapsibleSection";
import { ColorFormGroup } from "@/app/wallets/sign-in/components/ColorFormGroup";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { CurrencySelector } from "../../../../components/blocks/CurrencySelector";
import type { BridgeWidgetPlaygroundOptions } from "./types";
export function LeftSection(props: {
options: BridgeWidgetPlaygroundOptions;
setOptions: React.Dispatch<
React.SetStateAction<BridgeWidgetPlaygroundOptions>
>;
}) {
const { options, setOptions } = props;
const setThemeType = (themeType: "dark" | "light") => {
setOptions((v) => ({
...v,
theme: {
...v.theme,
type: themeType,
},
}));
};
const themeId = useId();
return (
<div className="flex flex-col gap-4">
<CollapsibleSection defaultOpen icon={CoinsIcon} title="Token Selection">
<div className="flex flex-col gap-6 pt-5">
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<CurrencySelector
value={options.currency}
onChange={(currency) => {
setOptions((v) => ({ ...v, currency }));
}}
/>
</section>
<div className="border-t border-dashed" />
<TokenFieldset
title="Sell Token"
type="sellToken"
options={options}
setOptions={setOptions}
/>
<div className="border-t border-dashed" />
<TokenFieldset
title="Buy Token"
type="buyToken"
options={options}
setOptions={setOptions}
/>
</div>
</CollapsibleSection>
<CollapsibleSection icon={PaletteIcon} title="Appearance">
{/* Theme */}
<section className="flex flex-col gap-3 pt-6">
<Label htmlFor="theme"> Theme </Label>
<CustomRadioGroup
id={themeId}
onValueChange={setThemeType}
options={[
{ label: "Dark", value: "dark" },
{ label: "Light", value: "light" },
]}
value={options.theme.type}
/>
</section>
<div className="h-6" />
{/* Colors - disabled for iframe */}
{options.integrationType !== "iframe" && (
<ColorFormGroup
onChange={(newTheme) => {
setOptions((v) => ({
...v,
theme: newTheme,
}));
}}
theme={options.theme}
/>
)}
<div className="my-4 flex items-center gap-2">
<Checkbox
checked={options.showThirdwebBranding}
id={"branding"}
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
showThirdwebBranding: checked === true,
}));
}}
/>
<Label htmlFor={"branding"}>Show Branding</Label>
</div>
</CollapsibleSection>
</div>
);
}
function TokenFieldset(props: {
type: "buyToken" | "sellToken";
title: string;
options: BridgeWidgetPlaygroundOptions;
setOptions: React.Dispatch<
React.SetStateAction<BridgeWidgetPlaygroundOptions>
>;
}) {
const { options, setOptions } = props;
const chainId = options.prefill?.[props.type]?.chainId;
const tokenAddress = options.prefill?.[props.type]?.tokenAddress;
return (
<div>
<h3 className="mb-1 font-medium">{props.title}</h3>
<p className="text-sm text-muted-foreground mb-3">
Sets the default token and amount to{" "}
{props.type === "buyToken" ? "buy" : "sell"} in the widget, <br />
User can change this default selection in the widget
</p>
<div className="space-y-4">
{/* Chain selection */}
<div className="space-y-2">
<Label>Chain</Label>
<BridgeNetworkSelector
chainId={chainId}
onChange={(chainId) => {
setOptions((v) => ({
...v,
prefill: {
...v.prefill,
[props.type]: {
...v.prefill?.[props.type],
chainId,
tokenAddress: undefined, // clear token selection
},
},
}));
}}
placeholder="Select a chain"
className="bg-card"
/>
</div>
{/* Token selection - only show if chain is selected */}
<div className="space-y-2">
<Label>Token</Label>
<TokenSelector
chainId={chainId}
client={THIRDWEB_CLIENT}
disableAddress
enabled={true}
onChange={(token) => {
setOptions((v) => ({
...v,
prefill: {
...v.prefill,
[props.type]: {
chainId: token.chainId,
tokenAddress: token.address,
},
},
}));
}}
placeholder="Select a token"
selectedToken={
tokenAddress && chainId
? {
address: tokenAddress,
chainId: chainId,
}
: chainId
? {
address: getAddress(NATIVE_TOKEN_ADDRESS),
chainId: chainId,
}
: undefined
}
className="bg-card"
/>
</div>
{chainId && (
<div className="space-y-2">
<Label> Token Amount</Label>
<Input
className="bg-card"
value={options.prefill?.[props.type]?.amount || ""}
onChange={(e) => {
setOptions((v) => {
return {
...v,
prefill: {
...v.prefill,
[props.type]: {
...v.prefill?.[props.type],
amount: e.target.value,
chainId: chainId,
},
},
};
});
}}
placeholder="0.01"
/>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { BridgeWidget, darkTheme, lightTheme } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { cn } from "@/lib/utils";
import { buildIframeUrl } from "./buildIframeUrl";
import { CodeGen } from "./code";
import type { BridgeWidgetPlaygroundOptions } from "./types";
type Tab = "ui" | "code";
export function RightSection(props: {
options: BridgeWidgetPlaygroundOptions;
}) {
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
return "ui";
});
function setPreviewTab(tab: "ui" | "code") {
_setPreviewTab(tab);
}
const themeObj =
props.options.theme.type === "dark"
? darkTheme({
colors: props.options.theme.darkColorOverrides,
})
: lightTheme({
colors: props.options.theme.lightColorOverrides,
});
return (
<div className="flex shrink-0 flex-col gap-4 xl:sticky xl:top-0 xl:max-h-[100vh] xl:w-[764px]">
<TabButtons
tabs={[
{
isActive: previewTab === "ui",
name: "Preview",
onClick: () => setPreviewTab("ui"),
},
{
isActive: previewTab === "code",
name: "Code",
onClick: () => setPreviewTab("code"),
},
]}
/>
<div
className={cn(
"relative flex min-h-[300px] grow justify-center rounded-lg",
previewTab !== "code" && "items-center",
)}
>
{previewTab === "ui" && props.options.integrationType !== "iframe" && (
<BridgeWidget
className="shadow-xl"
client={THIRDWEB_CLIENT}
theme={themeObj}
buy={
props.options.prefill?.buyToken
? {
chainId: props.options.prefill.buyToken.chainId,
tokenAddress: props.options.prefill.buyToken.tokenAddress,
amount: props.options.prefill.buyToken.amount,
}
: undefined
}
swap={{
persistTokenSelections: false,
prefill: {
buyToken: props.options.prefill?.buyToken
? {
chainId: props.options.prefill.buyToken.chainId,
tokenAddress: props.options.prefill.buyToken.tokenAddress,
amount: props.options.prefill.buyToken.amount,
}
: undefined,
sellToken: props.options.prefill?.sellToken
? {
chainId: props.options.prefill.sellToken.chainId,
tokenAddress:
props.options.prefill.sellToken.tokenAddress,
amount: props.options.prefill.sellToken.amount,
}
: undefined,
},
}}
currency={props.options.currency}
showThirdwebBranding={props.options.showThirdwebBranding}
key={JSON.stringify(props.options.prefill)}
/>
)}
{previewTab === "ui" && props.options.integrationType === "iframe" && (
<iframe
src={buildIframeUrl(props.options, "preview")}
height="750px"
width="100%"
title="Bridge Widget"
className="fade-in-0 duration-500 animate-in rounded-xl"
style={{
border: "0",
}}
/>
)}
{previewTab === "code" && <CodeGen options={props.options} />}
</div>
</div>
);
}
function TabButtons(props: {
tabs: Array<{
name: string;
isActive: boolean;
onClick: () => void;
}>;
}) {
return (
<div>
<div className="inline-flex justify-start gap-1 rounded-lg border bg-card p-1.5">
{props.tabs.map((tab) => (
<Button
className={cn(
"gap-2 px-4",
tab.isActive
? "bg-accent text-foreground"
: "bg-transparent text-muted-foreground",
)}
key={tab.name}
onClick={tab.onClick}
variant="ghost"
>
{tab.name}
</Button>
))}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import type {
SupportedFiatCurrency,
SwapWidgetProps,
ThemeOverrides,
} from "thirdweb/react";
export type BridgeWidgetPlaygroundOptions = {
integrationType: "iframe" | "script" | "react";
theme: {
type: "dark" | "light";
darkColorOverrides: ThemeOverrides["colors"];
lightColorOverrides: ThemeOverrides["colors"];
};
currency?: SupportedFiatCurrency;
prefill?: {
buyToken?: {
tokenAddress?: string;
chainId: number;
amount?: string;
};
sellToken?: {
tokenAddress?: string;
chainId: number;
amount?: string;
};
};
showThirdwebBranding?: SwapWidgetProps["showThirdwebBranding"];
};
@@ -0,0 +1,48 @@
import { BringToFrontIcon } from "lucide-react";
import { PageLayout } from "@/components/blocks/APIHeader";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { BridgeWidgetPlayground } from "./components/bridge-playground";
const title = "Bridge Widget";
const description =
"Easily embed cross-chain token swap and fiat onramp UI into your app";
const validTabs = ["iframe", "script", "react"] as const;
type ValidTabs = (typeof validTabs)[number];
export const metadata = createMetadata({
description: description,
title,
image: {
icon: "payments",
title,
},
});
export default async function Page(props: {
searchParams: Promise<{
tab?: string | undefined | string[];
}>;
}) {
const searchParams = await props.searchParams;
const tab =
typeof searchParams.tab === "string" ? searchParams.tab : undefined;
const validTab = validTabs.includes(tab as ValidTabs)
? (tab as ValidTabs)
: undefined;
return (
<ThirdwebProvider>
<PageLayout
icon={BringToFrontIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/bridge/bridge-widget?utm_source=playground"
>
<BridgeWidgetPlayground defaultTab={validTab} />
</PageLayout>
</ThirdwebProvider>
);
}
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { arbitrum } from "thirdweb/chains";
import { TabButtons } from "@/components/ui/tab-buttons";
import { LeftSection } from "../components/LeftSection";
import { RightSection } from "../components/RightSection";
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
const defaultOptions: BridgeComponentsPlaygroundOptions = {
integrationType: "react",
payOptions: {
buyTokenAddress: undefined,
buyTokenAmount: "0.002",
@@ -21,6 +24,8 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
transactionData: "",
currency: "USD",
showThirdwebBranding: true,
amountEditable: true,
tokenEditable: true,
},
theme: {
darkColorOverrides: {},
@@ -29,16 +34,70 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
},
};
export function BuyPlayground() {
const [options, setOptions] =
useState<BridgeComponentsPlaygroundOptions>(defaultOptions);
function updatePageUrl(
tab: BridgeComponentsPlaygroundOptions["integrationType"],
) {
const url = new URL(window.location.href);
if (tab === defaultOptions.integrationType) {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tab || "");
}
window.history.replaceState({}, "", url.toString());
}
export function BuyPlayground(props: { defaultTab?: "iframe" | "react" }) {
const { theme } = useTheme();
const [options, setOptions] = useState<BridgeComponentsPlaygroundOptions>(
() => ({
...defaultOptions,
integrationType: props.defaultTab || defaultOptions.integrationType,
}),
);
// Change theme on global theme change
useEffect(() => {
setOptions((prev) => ({
...prev,
theme: {
...prev.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
}, [theme]);
useEffect(() => {
updatePageUrl(options.integrationType);
}, [options.integrationType]);
return (
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
<LeftSection widget="buy" options={options} setOptions={setOptions} />
<div>
<TabButtons
tabs={[
{
name: "React",
onClick: () => setOptions({ ...options, integrationType: "react" }),
isActive: options.integrationType === "react",
},
{
name: "Iframe",
onClick: () =>
setOptions({ ...options, integrationType: "iframe" }),
isActive: options.integrationType === "iframe",
},
]}
/>
<div className="h-6" />
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
<LeftSection widget="buy" options={options} setOptions={setOptions} />
</div>
<RightSection widget="buy" options={options} />
</div>
<RightSection widget="buy" options={options} />
</div>
);
}
@@ -19,16 +19,24 @@ export const metadata = createMetadata({
},
});
export default function Page() {
export default async function Page(props: {
searchParams: Promise<{ tab?: string }>;
}) {
const searchParams = await props.searchParams;
const defaultTab =
searchParams.tab === "iframe" || searchParams.tab === "react"
? searchParams.tab
: undefined;
return (
<ThirdwebProvider>
<PageLayout
icon={ShoppingBagIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
docsLink="https://portal.thirdweb.com/references/typescript/v5/BuyWidget?utm_source=playground"
>
<BuyPlayground />
<BuyPlayground defaultTab={defaultTab} />
</PageLayout>
</ThirdwebProvider>
);
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { arbitrum } from "thirdweb/chains";
import { TabButtons } from "@/components/ui/tab-buttons";
import { LeftSection } from "../components/LeftSection";
import { RightSection } from "../components/RightSection";
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
const defaultOptions: BridgeComponentsPlaygroundOptions = {
integrationType: "react",
payOptions: {
buyTokenAddress: undefined,
buyTokenAmount: "0.01",
@@ -21,6 +24,8 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
receiverAddress: undefined,
currency: "USD",
showThirdwebBranding: true,
amountEditable: true,
tokenEditable: true,
},
theme: {
darkColorOverrides: {},
@@ -29,20 +34,74 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
},
};
export function CheckoutPlayground() {
const [options, setOptions] =
useState<BridgeComponentsPlaygroundOptions>(defaultOptions);
function updatePageUrl(
tab: BridgeComponentsPlaygroundOptions["integrationType"],
) {
const url = new URL(window.location.href);
if (tab === defaultOptions.integrationType) {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tab || "");
}
window.history.replaceState({}, "", url.toString());
}
export function CheckoutPlayground(props: { defaultTab?: "iframe" | "react" }) {
const { theme } = useTheme();
const [options, setOptions] = useState<BridgeComponentsPlaygroundOptions>(
() => ({
...defaultOptions,
integrationType: props.defaultTab || defaultOptions.integrationType,
}),
);
// Change theme on global theme change
useEffect(() => {
setOptions((prev) => ({
...prev,
theme: {
...prev.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
}, [theme]);
useEffect(() => {
updatePageUrl(options.integrationType);
}, [options.integrationType]);
return (
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
<LeftSection
widget="checkout"
options={options}
setOptions={setOptions}
/>
<div>
<TabButtons
tabs={[
{
name: "React",
onClick: () => setOptions({ ...options, integrationType: "react" }),
isActive: options.integrationType === "react",
},
{
name: "Iframe",
onClick: () =>
setOptions({ ...options, integrationType: "iframe" }),
isActive: options.integrationType === "iframe",
},
]}
/>
<div className="h-6" />
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
<LeftSection
widget="checkout"
options={options}
setOptions={setOptions}
/>
</div>
<RightSection widget="checkout" options={options} />
</div>
<RightSection widget="checkout" options={options} />
</div>
);
}
@@ -10,6 +10,9 @@ const description =
const ogDescription =
"Accept fiat or crypto payments on any chain—direct to your wallet. Instant checkout, webhook support, and full control over post-sale actions.";
const validTabs = ["iframe", "react"] as const;
type ValidTabs = (typeof validTabs)[number];
export const metadata = createMetadata({
description: ogDescription,
title,
@@ -19,16 +22,28 @@ export const metadata = createMetadata({
},
});
export default function Page() {
export default async function Page(props: {
searchParams: Promise<{
tab?: string | undefined | string[];
}>;
}) {
const searchParams = await props.searchParams;
const tab =
typeof searchParams.tab === "string" ? searchParams.tab : undefined;
const validTab = validTabs.includes(tab as ValidTabs)
? (tab as ValidTabs)
: undefined;
return (
<ThirdwebProvider>
<PageLayout
icon={CreditCardIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/payments?utm_source=playground"
docsLink="https://portal.thirdweb.com/bridge/checkout-widget?utm_source=playground"
>
<CheckoutPlayground />
<CheckoutPlayground defaultTab={validTab} />
</PageLayout>
</ThirdwebProvider>
);
@@ -5,6 +5,8 @@ import {
stringifyImports,
stringifyProps,
} from "../../../lib/code-gen";
import { buildBuyIframeUrl } from "./buildBuyIframeUrl";
import { buildCheckoutIframeUrl } from "./buildCheckoutIframeUrl";
import type { BridgeComponentsPlaygroundOptions } from "./types";
const CodeClient = lazy(() =>
@@ -25,19 +27,48 @@ export function CodeGen(props: {
widget: "buy" | "checkout" | "transaction";
options: BridgeComponentsPlaygroundOptions;
}) {
const isIframe =
(props.widget === "checkout" || props.widget === "buy") &&
props.options.integrationType === "iframe";
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient
className="grow"
code={getCode(props.widget, props.options)}
lang="tsx"
code={
isIframe
? getIframeCode(props.widget as "buy" | "checkout", props.options)
: getCode(props.widget, props.options)
}
lang={isIframe ? "html" : "tsx"}
/>
</Suspense>
</div>
);
}
function getIframeCode(
widget: "buy" | "checkout",
options: BridgeComponentsPlaygroundOptions,
) {
const src =
widget === "buy"
? buildBuyIframeUrl(options)
: buildCheckoutIframeUrl(options);
const hasImage = src.includes("image=");
const height = hasImage ? "850px" : "700px";
return `\
<iframe
src="${src}"
height="${height}"
width="100%"
style="border: 0;"
/>`;
}
function getCode(
widget: "buy" | "checkout" | "transaction",
options: BridgeComponentsPlaygroundOptions,
@@ -129,6 +160,14 @@ tokenId: 2n,
? quotes(options.payOptions.buttonLabel)
: undefined,
transaction: transaction,
amountEditable:
widget === "buy" && options.payOptions.amountEditable === false
? false
: undefined,
tokenEditable:
widget === "buy" && options.payOptions.tokenEditable === false
? false
: undefined,
};
return `\
@@ -259,6 +259,46 @@ export function LeftSection(props: {
</div>
</div>
</div>
{/* Editability Options */}
<div className="flex flex-col gap-3">
<Label>Editability Options</Label>
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<Checkbox
checked={payOptions.tokenEditable}
id="token-editable"
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
tokenEditable: checked === true,
},
}));
}}
/>
<Label htmlFor="token-editable">Token Editable</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={payOptions.amountEditable}
id="amount-editable"
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
amountEditable: checked === true,
},
}));
}}
/>
<Label htmlFor="amount-editable">Amount Editable</Label>
</div>
</div>
</div>
</div>
)}
@@ -484,16 +524,21 @@ export function LeftSection(props: {
<div className="h-6" />
{/* Colors */}
<ColorFormGroup
onChange={(newTheme) => {
setOptions((v) => ({
...v,
theme: newTheme,
}));
}}
theme={options.theme}
/>
{/* Colors - disabled for iframe */}
{!(
(props.widget === "checkout" || props.widget === "buy") &&
options.integrationType === "iframe"
) && (
<ColorFormGroup
onChange={(newTheme) => {
setOptions((v) => ({
...v,
theme: newTheme,
}));
}}
theme={options.theme}
/>
)}
<div className="my-4 flex items-center gap-2">
<Checkbox
@@ -15,6 +15,8 @@ import {
import { Button } from "../../../components/ui/button";
import { THIRDWEB_CLIENT } from "../../../lib/client";
import { cn } from "../../../lib/utils";
import { buildBuyIframeUrl } from "./buildBuyIframeUrl";
import { buildCheckoutIframeUrl } from "./buildCheckoutIframeUrl";
import { CodeGen } from "./CodeGen";
import type { BridgeComponentsPlaygroundOptions } from "./types";
@@ -68,6 +70,8 @@ export function RightSection(props: {
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
receiverAddress={props.options.payOptions.receiverAddress}
amountEditable={props.options.payOptions.amountEditable}
tokenEditable={props.options.payOptions.tokenEditable}
key={JSON.stringify({
amount: props.options.payOptions.buyTokenAmount,
chain: props.options.payOptions.buyTokenChain,
@@ -154,9 +158,18 @@ export function RightSection(props: {
previewTab !== "code" && "items-center",
)}
>
<BackgroundPattern />
{previewTab === "ui" && embed}
{previewTab === "ui" &&
(props.options.integrationType === "iframe" ? (
props.widget === "checkout" ? (
<CheckoutIframePreview options={props.options} />
) : props.widget === "buy" ? (
<BuyWidgetIframePreview options={props.options} />
) : (
embed
)
) : (
embed
))}
{previewTab === "code" && (
<CodeGen widget={props.widget} options={props.options} />
@@ -166,7 +179,46 @@ export function RightSection(props: {
);
}
function BackgroundPattern() {
function CheckoutIframePreview(props: {
options: BridgeComponentsPlaygroundOptions;
}) {
const src = buildCheckoutIframeUrl(props.options);
return (
<iframe
src={src}
height="700px"
width="100%"
title="Checkout Widget"
className="fade-in-0 animate-in rounded-xl duration-500"
style={{
border: "0",
}}
/>
);
}
function BuyWidgetIframePreview(props: {
options: BridgeComponentsPlaygroundOptions;
}) {
const src = buildBuyIframeUrl(props.options);
const hasImage = src.includes("image=");
const height = hasImage ? "850px" : "700px";
return (
<iframe
src={src}
height={height}
width="100%"
title="Buy Widget"
className="fade-in-0 animate-in rounded-xl duration-500"
style={{
border: "0",
}}
/>
);
}
function _BackgroundPattern() {
const color = "hsl(var(--foreground)/15%)";
return (
<div
@@ -0,0 +1,81 @@
import type { BridgeComponentsPlaygroundOptions } from "./types";
const BUY_WIDGET_IFRAME_BASE_URL = "https://thirdweb.com/bridge/buy-widget";
export function buildBuyIframeUrl(options: BridgeComponentsPlaygroundOptions) {
const url = new URL(BUY_WIDGET_IFRAME_BASE_URL);
// Chain (optional)
if (options.payOptions.buyTokenChain?.id) {
url.searchParams.set("chain", String(options.payOptions.buyTokenChain.id));
}
// Token address (optional - if not set, native token is used)
if (options.payOptions.buyTokenAddress) {
url.searchParams.set("tokenAddress", options.payOptions.buyTokenAddress);
}
// Amount (optional)
if (options.payOptions.buyTokenAmount) {
url.searchParams.set("amount", options.payOptions.buyTokenAmount);
}
// Receiver address (optional)
if (options.payOptions.receiverAddress) {
url.searchParams.set("receiver", options.payOptions.receiverAddress);
}
// Theme (only add if light, dark is default)
if (options.theme.type === "light") {
url.searchParams.set("theme", "light");
}
// Currency (only add if not USD, USD is default)
if (options.payOptions.currency && options.payOptions.currency !== "USD") {
url.searchParams.set("currency", options.payOptions.currency);
}
// Branding
if (options.payOptions.showThirdwebBranding === false) {
url.searchParams.set("showThirdwebBranding", "false");
}
// Product info
if (options.payOptions.title) {
url.searchParams.set("title", options.payOptions.title);
}
if (options.payOptions.description) {
url.searchParams.set("description", options.payOptions.description);
}
if (options.payOptions.image) {
url.searchParams.set("image", options.payOptions.image);
}
if (options.payOptions.buttonLabel) {
url.searchParams.set("buttonLabel", options.payOptions.buttonLabel);
}
// Payment methods
if (
options.payOptions.paymentMethods &&
options.payOptions.paymentMethods.length === 1
) {
url.searchParams.set(
"paymentMethods",
options.payOptions.paymentMethods[0],
);
}
// Editability options
if (options.payOptions.amountEditable === false) {
url.searchParams.set("amountEditable", "false");
}
if (options.payOptions.tokenEditable === false) {
url.searchParams.set("tokenEditable", "false");
}
return url.toString();
}

Some files were not shown because too many files have changed in this diff Show More