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
437 changed files with 17512 additions and 5829 deletions
+54
View File
@@ -161,6 +161,60 @@ const baseNextConfig: NextConfig = {
],
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*",
},
];
},
images: {
+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: 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.",
@@ -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,13 +2,8 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import { defineChain } from "thirdweb";
import {
BuyWidget,
type SupportedFiatCurrency,
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,
@@ -22,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";
@@ -71,11 +64,11 @@ export type BuyAndSwapEmbedProps = {
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);
@@ -108,211 +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
currency={props.currency || "USD"}
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
currency={props.currency || "USD"}
persistTokenSelections={props.persistTokenSelections}
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,
);
},
});
}
@@ -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: "",
@@ -75,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.",
@@ -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",
}}
/>
@@ -22,7 +22,6 @@ export function ProjectSidebarLayout(props: {
children: React.ReactNode;
hasEngines: boolean;
showContracts: boolean;
teamId: string;
}) {
const contentSidebarLinks = [
{
@@ -52,14 +51,14 @@ export function ProjectSidebarLayout(props: {
href: `${props.layoutPath}/wallets/sponsored-gas`,
label: "Gas Sponsorship",
},
...(props.teamId === "team_clmb33q9w00gn1x0u2ri8z0k0"
? [
{
href: `${props.layoutPath}/wallets/dedicated-relayer`,
label: "Dedicated Relayer",
},
]
: []),
{
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`,
},
],
}}
@@ -107,7 +107,6 @@ export default async function ProjectLayout(props: {
layoutPath={layoutPath}
hasEngines={hasLegacyDedicatedEngines}
showContracts={showContracts}
teamId={team.id}
>
{props.children}
</ProjectSidebarLayout>
@@ -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,
@@ -13,7 +13,6 @@ 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 { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@@ -111,23 +110,43 @@ export function DedicatedRelayerActiveState(
/>
</div>
{/* Executors Info */}
<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 Executors
</h2>
{/* 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>
<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>
))}
{/* 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>
@@ -135,7 +154,7 @@ export function DedicatedRelayerActiveState(
{/* 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">Fleet Transactions</h2>
<h2 className="font-semibold text-xl">Transaction History</h2>
<div className="flex gap-2">
<ChainFilter
chainId={chainIdFilter}
@@ -295,37 +314,20 @@ function SkeletonRow() {
}
function TransactionHashCell(props: { hash: string; chainId: string }) {
const { idToChain } = useAllChainsData();
const chain = idToChain.get(Number(props.chainId));
const explorerUrl = chain?.explorers?.[0]?.url;
const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`;
if (explorerUrl) {
return (
<Button asChild size="sm" variant="ghost">
<Link
className="-translate-x-2 gap-2 font-mono"
href={`${explorerUrl}/tx/${props.hash}`}
rel="noopener noreferrer"
target="_blank"
>
{txHashToShow}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
</Button>
);
}
return (
<CopyTextButton
className="-translate-x-2 font-mono"
copyIconPosition="right"
textToCopy={props.hash}
textToShow={txHashToShow}
tooltip="Transaction Hash"
variant="ghost"
/>
<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>
);
}
@@ -362,7 +364,7 @@ function TransactionFeeCell(props: { usdValue: number | null }) {
}
return (
<span className="font-mono text-sm">
${props.usdValue < 0.01 ? "<0.01" : props.usdValue.toFixed(2)}
{props.usdValue < 0.001 ? "< $0.001" : `$${props.usdValue.toFixed(3)}`}
</span>
);
}
@@ -379,7 +381,7 @@ function ChainFilter(props: {
client={props.client}
chainId={props.chainId}
onChange={(chainId) => props.setChainId(chainId)}
popoverContentClassName="z-[10001]"
popoverContentClassName="z-[10001] !w-[280px]"
align="end"
placeholder="All Chains"
className="min-w-[150px]"
@@ -71,8 +71,8 @@ export function DedicatedRelayerEmptyState(
}
};
const requiredChains =
selectedTier === "product:dedicated_relayer_standard" ? 2 : 4;
const maxChains =
selectedTier === "product:dedicated_relayer_standard" ? 1 : 2;
return (
<div className={cn("flex flex-col gap-8 pt-2", props.className)}>
@@ -90,7 +90,7 @@ export function DedicatedRelayerEmptyState(
</DialogHeader>
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
Select {requiredChains} chains for your dedicated relayer.
Select up to {maxChains} chains for your dedicated relayer.
</p>
<MultiNetworkSelector
selectedChainIds={selectedChainIds}
@@ -102,7 +102,11 @@ export function DedicatedRelayerEmptyState(
<DialogFooter>
<Button
onClick={handlePurchase}
disabled={selectedChainIds.length !== requiredChains || isLoading}
disabled={
selectedChainIds.length === 0 ||
selectedChainIds.length > maxChains ||
isLoading
}
>
{isLoading ? "Processing..." : "Continue"}
</Button>
@@ -73,6 +73,7 @@ export function DedicatedRelayerPageClient(
refetchInterval: 5000,
});
const isCheckingActivity = hasActivityQuery.isPending;
const totalTransactions = hasActivityQuery.data?.data.totalTransactions ?? 0;
const hasTransactions = totalTransactions > 0;
@@ -117,23 +118,28 @@ export function DedicatedRelayerPageClient(
<DedicatedRelayerPendingState fleet={fleet} />
)}
{fleetStatus === "active" && fleet && !hasTransactions && (
<DedicatedRelayerPendingState
fleet={fleet}
hasTransactions={hasTransactions}
/>
)}
{fleetStatus === "active" &&
fleet &&
!hasTransactions &&
!isCheckingActivity && (
<DedicatedRelayerPendingState
fleet={fleet}
hasTransactions={hasTransactions}
/>
)}
{fleetStatus === "active" && fleet && hasTransactions && (
<DedicatedRelayerActiveState
fleet={fleet}
teamId={props.teamId}
fleetId={props.fleetId}
client={props.client}
range={dateRange}
setRange={setDateRange}
/>
)}
{fleetStatus === "active" &&
fleet &&
(hasTransactions || isCheckingActivity) && (
<DedicatedRelayerActiveState
fleet={fleet}
teamId={props.teamId}
fleetId={props.fleetId}
client={props.client}
range={dateRange}
setRange={setDateRange}
/>
)}
</div>
);
}
@@ -33,12 +33,12 @@ const TIERS: TierConfig[] = [
icon: FolderIcon,
name: "Standard",
description:
"Most suitable for small startups and apps doing less than 10,000 transactions per day",
"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 up to 2 chains" },
{ icon: BoxesIcon, label: "Support for 1 chain" },
],
cta: "Select",
},
@@ -47,7 +47,7 @@ const TIERS: TierConfig[] = [
icon: FoldersIcon,
name: "Premium",
description:
"Best for large enterprise companies and apps doing 100,000+ transactions per day",
"Best for enterprise companies and applications with high transaction volume",
price: "$299",
isPerMonth: true,
features: [
@@ -55,7 +55,7 @@ const TIERS: TierConfig[] = [
icon: WalletProductIcon,
label: "10 executor wallets (10x throughput)",
},
{ icon: BoxesIcon, label: "Support for up to 4 chains" },
{ icon: BoxesIcon, label: "Support for up to 2 chains" },
],
cta: "Select",
isRecommended: true,
@@ -65,7 +65,7 @@ const TIERS: TierConfig[] = [
icon: FolderCogIcon,
name: "Custom",
description:
"Contact us for more throughput with custom number of chains and executor wallets",
"Contact us for applications operating at a global scale with custom requirements",
price: "Custom",
features: [
{ icon: WalletProductIcon, label: "Unlimited executor wallets" },
@@ -171,7 +171,7 @@ function PlanCard(props: {
className="w-full rounded-full py-3 h-auto text-base bg-background gap-2"
>
<Link
href="https://thirdweb.com/contact-us"
href="https://ea58c.share.hsforms.com/2o8qbJVozSX2Q4oukYfYGuA"
target="_blank"
rel="noopener noreferrer"
>
@@ -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>
);
}
@@ -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>
);
}
@@ -25,6 +25,7 @@ export function UniversalBridgeEmbed(props: {
swapTab: BuyAndSwapEmbedProps["swapTab"];
pageType: "bridge" | "bridge-iframe";
currency?: SupportedFiatCurrency;
showThirdwebBranding?: boolean;
}) {
return (
<BuyAndSwapEmbed
@@ -34,6 +35,7 @@ export function UniversalBridgeEmbed(props: {
swapTab={props.swapTab}
pageType={props.pageType}
wallets={bridgeWallets}
showThirdwebBranding={props.showThirdwebBranding}
/>
);
}
@@ -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,
};
@@ -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,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>
);
}
@@ -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>
);
}
+45 -53
View File
@@ -1,10 +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 type { SupportedFiatCurrency } from "thirdweb/react";
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`;
@@ -28,30 +33,44 @@ 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 =
parse(searchParams.persistTokenSelections, (v) =>
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 = parse(searchParams.currency, (v) =>
VALID_CURRENCIES.includes(v as SupportedFiatCurrency)
? (v as SupportedFiatCurrency)
: undefined,
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? v : undefined,
);
return (
@@ -61,11 +80,14 @@ export default async function Page(props: {
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,
}}
@@ -74,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,
}}
@@ -89,42 +117,6 @@ export default async function Page(props: {
);
}
const VALID_CURRENCIES: SupportedFiatCurrency[] = [
"USD",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
];
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:
@@ -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");
@@ -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();
}
@@ -0,0 +1,65 @@
import type { BridgeComponentsPlaygroundOptions } from "./types";
const CHECKOUT_WIDGET_IFRAME_BASE_URL =
"https://thirdweb.com/bridge/checkout-widget";
export function buildCheckoutIframeUrl(
options: BridgeComponentsPlaygroundOptions,
) {
const url = new URL(CHECKOUT_WIDGET_IFRAME_BASE_URL);
// Required params
url.searchParams.set("chain", String(options.payOptions.buyTokenChain.id));
url.searchParams.set("amount", options.payOptions.buyTokenAmount);
url.searchParams.set("seller", options.payOptions.sellerAddress);
// Token address (optional - if not set, native token is used)
if (options.payOptions.buyTokenAddress) {
url.searchParams.set("tokenAddress", options.payOptions.buyTokenAddress);
}
// 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],
);
}
return url.toString();
}
@@ -31,6 +31,7 @@ const CURRENCIES = [
type SupportedFiatCurrency = (typeof CURRENCIES)[number];
export type BridgeComponentsPlaygroundOptions = {
integrationType?: "iframe" | "react";
theme: {
type: "dark" | "light";
darkColorOverrides: ThemeOverrides["colors"];
@@ -58,5 +59,9 @@ export type BridgeComponentsPlaygroundOptions = {
currency?: SupportedFiatCurrency;
showThirdwebBranding: boolean;
// Editability options
amountEditable: boolean;
tokenEditable: boolean;
};
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { BringToFrontIcon } from "lucide-react";
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { PayIcon } from "@/icons/PayIcon";
import { bridgeFeatureCards } from "../data/pages-metadata";
export default function Page() {
@@ -8,7 +8,7 @@ export default function Page() {
title="Bridge"
description="thirdweb Bridge lets developers swap and transfer any token across any chain instantly"
featureCards={bridgeFeatureCards}
icon={PayIcon}
icon={BringToFrontIcon}
/>
);
}
@@ -0,0 +1,67 @@
import type { SwapWidgetPlaygroundOptions } from "./types";
const SWAP_WIDGET_IFRAME_BASE_URL = "https://thirdweb.com/bridge/swap-widget";
export function buildSwapIframeUrl(
options: SwapWidgetPlaygroundOptions,
type: "code" | "preview" = "preview",
) {
const url = new URL(SWAP_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");
}
// Buy token params
if (options.prefill?.buyToken?.chainId) {
url.searchParams.set("buyChain", String(options.prefill.buyToken.chainId));
if (options.prefill.buyToken.tokenAddress) {
url.searchParams.set(
"buyTokenAddress",
options.prefill.buyToken.tokenAddress,
);
}
if (options.prefill.buyToken.amount) {
url.searchParams.set("buyAmount", options.prefill.buyToken.amount);
}
}
// Sell token params
if (options.prefill?.sellToken?.chainId) {
url.searchParams.set(
"sellChain",
String(options.prefill.sellToken.chainId),
);
if (options.prefill.sellToken.tokenAddress) {
url.searchParams.set(
"sellTokenAddress",
options.prefill.sellToken.tokenAddress,
);
}
if (options.prefill.sellToken.amount) {
url.searchParams.set("sellAmount", options.prefill.sellToken.amount);
}
}
// 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");
}
return url.toString();
}
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { LoadingDots } from "@/components/ui/LoadingDots";
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
import { buildSwapIframeUrl } from "./buildSwapIframeUrl";
import type { SwapWidgetPlaygroundOptions } from "./types";
const CodeClient = lazy(() =>
@@ -18,16 +19,35 @@ function CodeLoading() {
}
export function CodeGen(props: { options: SwapWidgetPlaygroundOptions }) {
const code =
props.options.integrationType === "iframe"
? getIframeCode(props.options)
: getReactCode(props.options);
const lang = props.options.integrationType === "iframe" ? "html" : "ts";
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient className="grow" code={getCode(props.options)} lang="ts" />
<CodeClient className="grow" code={code} lang={lang} />
</Suspense>
</div>
);
}
function getCode(options: SwapWidgetPlaygroundOptions) {
function getIframeCode(options: SwapWidgetPlaygroundOptions) {
// Use "code" type to exclude persistTokenSelections from the generated code
const iframeUrl = buildSwapIframeUrl(options, "code");
return `<iframe
src="${iframeUrl}"
height="700px"
width="100%"
style="border: 0;"
/>`;
}
function getReactCode(options: SwapWidgetPlaygroundOptions) {
const imports = {
thirdweb: ["createThirdwebClient"] as string[],
"thirdweb/react": ["SwapWidget"] as string[],
@@ -85,16 +85,18 @@ export function LeftSection(props: {
<div className="h-6" />
{/* Colors */}
<ColorFormGroup
onChange={(newTheme) => {
setOptions((v) => ({
...v,
theme: newTheme,
}));
}}
theme={options.theme}
/>
{/* 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
@@ -4,6 +4,7 @@ import { darkTheme, lightTheme, SwapWidget } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { cn } from "@/lib/utils";
import { buildSwapIframeUrl } from "./buildSwapIframeUrl";
import { CodeGen } from "./code";
import type { SwapWidgetPlaygroundOptions } from "./types";
@@ -50,21 +51,31 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
previewTab !== "code" && "items-center",
)}
>
<BackgroundPattern />
{previewTab === "ui" && (
<SwapWidget
client={THIRDWEB_CLIENT}
theme={themeObj}
prefill={props.options.prefill}
currency={props.options.currency}
showThirdwebBranding={props.options.showThirdwebBranding}
key={JSON.stringify({
prefill: props.options.prefill,
})}
persistTokenSelections={false}
/>
)}
{previewTab === "ui" &&
(props.options.integrationType === "iframe" ? (
<iframe
src={buildSwapIframeUrl(props.options, "preview")}
height="700px"
width="100%"
title="Swap Widget"
className="fade-in-0 animate-in rounded-xl duration-500"
style={{
border: "0",
}}
/>
) : (
<SwapWidget
client={THIRDWEB_CLIENT}
theme={themeObj}
prefill={props.options.prefill}
currency={props.options.currency}
showThirdwebBranding={props.options.showThirdwebBranding}
key={JSON.stringify({
prefill: props.options.prefill,
})}
persistTokenSelections={false}
/>
))}
{previewTab === "code" && <CodeGen options={props.options} />}
</div>
@@ -72,21 +83,6 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
);
}
function BackgroundPattern() {
const color = "hsl(var(--foreground)/15%)";
return (
<div
className="absolute inset-0 z-[-1]"
style={{
backgroundImage: `radial-gradient(${color} 1px, transparent 1px)`,
backgroundSize: "24px 24px",
maskImage:
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 60%)",
}}
/>
);
}
function TabButtons(props: {
tabs: Array<{
name: string;
@@ -2,11 +2,13 @@
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 { SwapWidgetPlaygroundOptions } from "./types";
const defaultOptions: SwapWidgetPlaygroundOptions = {
integrationType: "react",
prefill: undefined,
currency: "USD",
showThirdwebBranding: true,
@@ -17,11 +19,26 @@ const defaultOptions: SwapWidgetPlaygroundOptions = {
},
};
export function SwapWidgetPlayground() {
function updatePageUrl(tab: SwapWidgetPlaygroundOptions["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 SwapWidgetPlayground(props: {
defaultTab?: "iframe" | "react";
}) {
const { theme } = useTheme();
const [options, setOptions] =
useState<SwapWidgetPlaygroundOptions>(defaultOptions);
const [options, setOptions] = useState<SwapWidgetPlaygroundOptions>(() => ({
...defaultOptions,
integrationType: props.defaultTab || defaultOptions.integrationType,
}));
// change theme on global theme change
useEffect(() => {
@@ -34,12 +51,36 @@ export function SwapWidgetPlayground() {
}));
}, [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 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 options={options} setOptions={setOptions} />
</div>
<RightSection options={options} />
</div>
<RightSection options={options} />
</div>
);
}
@@ -1,6 +1,7 @@
import type { SwapWidgetProps, ThemeOverrides } from "thirdweb/react";
export type SwapWidgetPlaygroundOptions = {
integrationType: "iframe" | "react";
theme: {
type: "dark" | "light";
darkColorOverrides: ThemeOverrides["colors"];
@@ -19,7 +19,9 @@ export const metadata = createMetadata({
},
});
export default function Page() {
export default function Page(props: {
searchParams: Promise<{ tab?: string }>;
}) {
return (
<ThirdwebProvider>
<PageLayout
@@ -28,8 +30,20 @@ export default function Page() {
description={description}
docsLink="https://portal.thirdweb.com/references/typescript/v5/SwapWidget?utm_source=playground"
>
<SwapWidgetPlayground />
<SwapWidgetPlaygroundAsync searchParams={props.searchParams} />
</PageLayout>
</ThirdwebProvider>
);
}
async function SwapWidgetPlaygroundAsync(props: {
searchParams: Promise<{ tab?: string }>;
}) {
const searchParams = await props.searchParams;
const defaultTab =
searchParams.tab === "iframe" || searchParams.tab === "react"
? searchParams.tab
: undefined;
return <SwapWidgetPlayground defaultTab={defaultTab} />;
}
@@ -25,7 +25,7 @@ export default function Page() {
icon={ArrowLeftRightIcon}
containerClassName="space-y-12"
description={description}
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionButton"
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionButton?utm_source=playground"
title={title}
>
<Example />
@@ -21,6 +21,8 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
currency: "USD",
showThirdwebBranding: true,
receiverAddress: undefined,
amountEditable: true,
tokenEditable: true,
},
theme: {
darkColorOverrides: {},
@@ -26,7 +26,7 @@ export default function Page() {
icon={ArrowLeftRightIcon}
containerClassName="space-y-12"
description={description}
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionWidget?utm_source=playground"
title={title}
>
<TransactionPlayground />
@@ -3,7 +3,6 @@ import {
BotIcon,
BoxIcon,
CircleUserIcon,
CreditCardIcon,
DollarSignIcon,
GlobeIcon,
ImageIcon,
@@ -14,7 +13,7 @@ import {
RectangleHorizontalIcon,
RssIcon,
ShieldIcon,
ShoppingBagIcon,
SquareIcon,
SquircleDashedIcon,
StampIcon,
UserIcon,
@@ -118,27 +117,34 @@ export const headlessComponentsFeatureCards: FeatureCardMetadata[] = [
export const bridgeFeatureCards: FeatureCardMetadata[] = [
{
icon: ArrowLeftRightIcon,
icon: SquareIcon,
title: "Bridge Widget",
link: "/bridge/bridge-widget",
description:
"A widget for bridging tokens with cross-chain support and fiat onramp",
},
{
icon: SquareIcon,
title: "Swap Widget",
link: "/bridge/swap-widget",
description: "A widget for swapping tokens with cross-chain support",
},
{
icon: ShoppingBagIcon,
icon: SquareIcon,
title: "Buy Widget",
link: "/bridge/buy-widget",
description:
"Buy any token with ability to customize theme, amounts, and more",
"A widget to buy tokens with fiat onramp support or cross-chain swaps",
},
{
icon: CreditCardIcon,
icon: SquareIcon,
title: "Checkout Widget",
link: "/bridge/checkout-widget",
description:
"Enable crypto payments for services and get notified on each sale",
},
{
icon: ArrowLeftRightIcon,
icon: SquareIcon,
title: "Transaction Widget",
link: "/bridge/transaction-widget",
description:
+4
View File
@@ -187,6 +187,10 @@ const bridge: ShadcnSidebarLink = {
href: "/bridge",
exactMatch: true,
},
{
href: "/bridge/bridge-widget",
label: "Bridge Widget",
},
{
href: "/bridge/swap-widget",
label: "Swap Widget",
@@ -31,9 +31,8 @@ export default function Page() {
function UserTransactions() {
return (
<>
<CodeExample
code={`\
<CodeExample
code={`\
import { inAppWallet } from "thirdweb/wallets/in-app";
import { ConnectButton, useActiveWallet } from "thirdweb/react";
@@ -88,14 +87,13 @@ function App() {
</>
);
}`}
header={{
description:
"Queue, monitor, and retry transactions from your users in-app wallets. All transactions and analytics will be displayed in your developer dashboard.",
title: "Transactions from User Wallets",
}}
lang="tsx"
preview={<GatewayPreview />}
/>
</>
header={{
description:
"Queue, monitor, and retry transactions from your users in-app wallets. All transactions and analytics will be displayed in your developer dashboard.",
title: "Transactions from User Wallets",
}}
lang="tsx"
preview={<GatewayPreview />}
/>
);
}
@@ -66,6 +66,7 @@ export function X402LeftSection(props: {
const handleTokenChange = (token: TokenMetadata) => {
setSelectedToken({
address: token.address,
// biome-ignore lint/style/noNonNullAssertion: ok
chainId: selectedChain!,
});
@@ -127,6 +128,7 @@ export function X402LeftSection(props: {
onChange={handleChainChange}
placeholder="Select a chain"
className="bg-card"
disableDeprecated
/>
</div>
@@ -13,7 +13,7 @@ export function FeatureCard(props: {
<props.icon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-medium mb-0.5 text-lg tracking-tight">
<h3 className="font-semibold mb-0.5 text-lg tracking-tight">
<Link className="before:absolute before:inset-0" href={props.href}>
{props.title}
</Link>
@@ -49,6 +49,14 @@ export function LinkAccount() {
});
};
const linkGithub = async () => {
linkProfile({
client: THIRDWEB_CLIENT,
strategy: "github",
mode: "redirect",
});
};
return (
<div className="flex flex-col gap-4 p-6">
{account ? (
@@ -83,6 +91,14 @@ export function LinkAccount() {
>
Link Passkey
</Button>
<Button
className="rounded-full p-6"
disabled={isPending}
onClick={linkGithub}
variant="default"
>
Link Github
</Button>
</>
)}
{error && <p className="text-red-500">Error: {error.message}</p>}
@@ -0,0 +1,139 @@
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollShadow } from "@/components/ui/ScrollShadow";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export function TabButtons(props: {
tabs: {
name: React.ReactNode;
onClick: () => void;
isActive: boolean;
icon?: React.FC<{ className?: string }>;
toolTip?: string;
}[];
tabClassName?: string;
activeTabClassName?: string;
tabContainerClassName?: string;
containerClassName?: string;
shadowColor?: string;
tabIconClassName?: string;
hideBottomLine?: boolean;
bottomLineClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLButtonElement>();
return (
<div className={cn("relative", props.containerClassName)}>
{/* Bottom line */}
{!props.hideBottomLine && (
<div
className={cn(
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
props.bottomLineClassName,
)}
/>
)}
<ScrollShadow
scrollableClassName="pb-[8px] relative"
shadowColor={props.shadowColor}
>
<div
className={cn("flex", props.tabContainerClassName)}
ref={containerRef}
>
{props.tabs.map((tab, index) => {
return (
<ToolTipLabel
key={typeof tab.name === "string" ? tab.name : index}
label={tab.toolTip}
>
<Button
className={cn(
"relative inline-flex h-auto items-center gap-1.5 rounded-lg font-medium hover:bg-accent !px-3",
!tab.isActive &&
"text-muted-foreground hover:text-foreground",
props.tabClassName,
tab.isActive && props.activeTabClassName,
)}
onClick={tab.onClick}
ref={tab.isActive ? activeTabRef : undefined}
variant="ghost"
>
{tab.icon && (
<tab.icon
className={cn("size-6", props.tabIconClassName)}
/>
)}
{tab.name}
</Button>
</ToolTipLabel>
);
})}
</div>
{/* Active line */}
<div
className="fade-in-0 absolute bottom-0 left-0 h-[2px] animate-in rounded-lg bg-foreground"
ref={lineRef}
/>
</ScrollShadow>
</div>
);
}
function useUnderline<El extends HTMLElement>() {
const containerRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const [activeTabEl, setActiveTabEl] = useState<El | null>(null);
const activeTabRef = useCallback((el: El | null) => {
setActiveTabEl(el);
}, []);
useLayoutEffect(() => {
function update() {
if (activeTabEl && containerRef.current && lineRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const lineEl = lineRef.current;
const rect = activeTabEl.getBoundingClientRect();
const containerPaddingLeft =
containerRect.left - containerRef.current.offsetLeft;
lineEl.style.width = `${rect.width}px`;
lineEl.style.transform = `translateX(${
rect.left - containerPaddingLeft
}px)`;
setTimeout(() => {
lineEl.style.transition = "transform 0.3s, width 0.3s";
}, 0);
} else if (lineRef.current) {
lineRef.current.style.width = "0px";
}
}
update();
let resizeObserver: ResizeObserver | undefined;
if (containerRef.current) {
resizeObserver = new ResizeObserver(() => {
setTimeout(() => {
update();
}, 100);
});
resizeObserver.observe(containerRef.current);
}
// add event listener for resize
window.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
resizeObserver?.disconnect();
};
}, [activeTabEl]);
return { activeTabRef, containerRef, lineRef };
}
+16 -1
View File
@@ -30,7 +30,22 @@ async function fetchBridgeSupportedChainsFromApi() {
throw new Error(json.error.message);
}
return json.data as BridgeChain[];
const data = json.data as BridgeChain[];
return data.sort((a, b) => {
const aStartsWithNumber = a.name[0]?.match(/^\d/);
const bStartsWithNumber = b.name[0]?.match(/^\d/);
if (aStartsWithNumber && !bStartsWithNumber) {
return 1;
}
if (!aStartsWithNumber && bStartsWithNumber) {
return -1;
}
return a.name.localeCompare(b.name);
});
}
export function useBridgeSupportedChains() {
-21
View File
@@ -1,21 +0,0 @@
export function PayIcon(props: { className?: string }) {
return (
<svg
className={props.className}
fill="none"
height="24"
role="presentation"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 10H2M2 8.2L2 15.8C2 16.9201 2 17.4802 2.21799 17.908C2.40973 18.2843 2.71569 18.5903 3.09202 18.782C3.51984 19 4.07989 19 5.2 19L18.8 19C19.9201 19 20.4802 19 20.908 18.782C21.2843 18.5903 21.5903 18.2843 21.782 17.908C22 17.4802 22 16.9201 22 15.8V8.2C22 7.0799 22 6.51984 21.782 6.09202C21.5903 5.7157 21.2843 5.40974 20.908 5.21799C20.4802 5 19.9201 5 18.8 5L5.2 5C4.0799 5 3.51984 5 3.09202 5.21799C2.7157 5.40973 2.40973 5.71569 2.21799 6.09202C2 6.51984 2 7.07989 2 8.2Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
}
+3 -3
View File
@@ -3,7 +3,7 @@
"@dirtycajunrice/klee": "^1.0.6",
"@mdx-js/loader": "3.1.0",
"@mdx-js/react": "3.1.0",
"@next/mdx": "15.3.6",
"@next/mdx": "15.3.8",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-select": "^2.2.5",
@@ -21,7 +21,7 @@
"github-slugger": "^2.0.0",
"he": "^1.2.0",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next": "15.3.8",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"node-html-markdown": "^1.3.0",
@@ -46,7 +46,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@next/eslint-plugin-next": "15.3.6",
"@next/eslint-plugin-next": "15.3.8",
"@types/flexsearch": "^0.7.42",
"@types/he": "^1.2.3",
"@types/mdx": "^2.0.13",

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