Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c26fc676c9 Version Packages (#8110)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-24 15:59:23 +12:00
gregandGitHub e1cccd7a10 Feature: Displays the buy widget error message (#8108) 2025-09-23 20:29:51 -07:00
Joaquim VergesandGitHub 3646964f2e [SDK] Fix transaction parameter ordering in injected wallet (#8109) 2025-09-23 20:29:24 -07:00
MananTank db145b5621 [BLD-330] Playground: Add missing payments overview page (#8107)
<!--

## 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 `OverviewPage` component for the payments section, updates the layout of the `OverviewPage`, and adds a new payment feature card for the x402 payment protocol.

### Detailed summary
- Updated `OverviewPage` to allow a maximum width of 80% for the description.
- Created a new `Page` component in `page.tsx` that utilizes `OverviewPage` with a title, description, and feature cards.
- Added a new feature card for the x402 payment protocol in `pages-metadata.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

* **New Features**
  * Introduced a Payments page with a clear overview, icon, and quick links to payment options.
  * Added an x402 payment protocol card to the Payments overview, enabling API payments with any web3 wallet and direct navigation.

* **Style**
  * Refined overview layout by limiting description width for improved readability on wide screens.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 21:05:13 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
db3838032a Version Packages (#8102)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-23 03:42:43 -07:00
Joaquim VergesandGitHub 60021b3a44 [SDK] added settlePayment() utility for x402 payments (#8105) 2025-09-23 03:23:31 -07:00
a81899d60a [Dashboard] Add ai usage billing banner to dashboard (#8104)
Co-authored-by: Cursor Agent <[email protected]>
2025-09-22 19:19:09 -07:00
MananTank b366d56039 [MNY-195] Dashboard: Remove Dexscreener on erc20 public page, add token info card (#8103)
<!--

## 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 and functionality in the `ERC20` public page, improving styles, and refactoring the token info fetching logic.

### Detailed summary
- Removed `dex-screener.tsx` and `dex-screener-chains.ts`.
- Adjusted padding in `ContractHeader.tsx`.
- Added shadow to a div in `BuyAndSwapEmbed.tsx`.
- Updated border styles in `ERC20PublicPage.tsx`.
- Introduced `TokenInfoSection` for displaying token info.
- Refactored `fetchTokenInfoFromBridge` to use `ThirdwebClient`.
- Added icons in `ContractAnalyticsOverview` and `RecentTransfers`.
- Improved layout and styles for various 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**
  * Added token summary section (Price, Market Cap, 24h Volume) on ERC20 public pages.

* **Style**
  * Visual polish: added shadow, larger rounded corners, adjusted headers, spacing, and rounded chart containers.
  * Chart and analytics headers now support/display icons; some header background styling simplified.

* **Changes**
  * Removed embedded DexScreener integration and related chain-slug mapping.

* **Refactor**
  * Token info fetching switched to a client-based SDK call; formatting helpers added for USD display.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 00:10:29 +00:00
MananTank e568bcea60 [MNY-193] Dashboard: Update Client Ids for Buy and Swap widgets in chain, bridge and asset pages (#8101)
<!--

## 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 refactoring components and updating environment variable handling for the `BuyAndSwapEmbed` and `BuyFundsSection` components in a dashboard application. It enhances the usage of client IDs and improves the structure of props.

### Detailed summary
- Removed `client` prop from `BuyAndSwapEmbed` and `BuyFundsSection`.
- Introduced `clientId` handling in `getConfiguredThirdwebClient`.
- Updated `BuyAndSwapEmbed` to use `useMemo` for client configuration based on `pageType`.
- Added multiple `NEXT_PUBLIC_*_CLIENT_ID` constants in `public-envs.ts`.
- Cleaned up imports and types in various files 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

- New Features
  - Auto-configured client selection per page type (asset/bridge/chain) for Buy, Swap, and Bridge embeds, improving reliability without manual setup.
- Refactor
  - Simplified components by removing the need to pass a client prop; widgets now use an internal, memoized client.
- Chores
  - Added new public environment variables for page-specific client IDs to enable tailored configuration:
    - NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID
    - NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
    - NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-22 23:41:35 +00:00
MananTank a94f22928a [MNY-204] Dashboard: Add tracking for BuyWidget, SDK: update callback types in BuyWidget (#8100)
<!--

## 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 `BuyWidget` and related components by updating the callback props to include a `quote` object, allowing for better tracking of token buy events and improving error handling.

### Detailed summary
- Updated `onSuccess`, `onError`, and `onCancel` props in `BuyWidget` to accept a `quote` object.
- Enhanced error reporting with detailed information in `reportTokenBuyFailed`, `reportTokenBuyCancelled`, and `reportTokenBuySuccessful`.
- Modified `BuyAndSwapEmbed` component to handle the new callback signatures.
- Adjusted `BridgeOrchestrator` to utilize the `quote` object in its flow.

> ✨ 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
  - Buy and Bridge flows now provide quote details to success, error, and cancel callbacks.
  - Token-level analytics added for buy flows (success, failure, cancel).
- Breaking Changes
  - Callback signatures changed to include quote parameters; client implementations must be updated.
- Documentation
  - Examples updated to show new callback parameters and usage.
- Chores
  - Standardized analytics for asset purchases where contract type may be unspecified.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-22 22:53:37 +00:00
Joaquim VergesandGitHub 5249cb7409 [SDK] Add verifyPayment() backend utility for arbitrary chain x402 payments (#8091) 2025-09-22 15:35:05 -07:00
MananTank b37ed57365 Dashboard: Fix broken opengraph-image.tsx (#8094)
<!--

## 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 refactoring the use of `useId` in several components by replacing it with static string values for IDs, improving consistency and potentially performance in the components related to Open Graph images.

### Detailed summary
- Removed `useId` from `contract-wallet-analytics.ts`, replacing it with a static variable.
- Updated `TWLogo` component in `opengraph-image.tsx` to use static IDs instead of `useId`.
- Changed `OgBrandIcon` component in `opengraph-image.tsx` to use a static gradient ID.
- Updated `TWLogo` component in another `opengraph-image.tsx` file to use static IDs instead of `useId`.

> ✨ 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
  - None.
- Bug Fixes
  - Improved reliability of OpenGraph images by using stable SVG IDs for gradients and clip paths, ensuring consistent rendering across sharing platforms on chain, contract, and drop pages.
- Chores
  - Removed unnecessary debug logging from wallet analytics to reduce console noise.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-22 18:59:27 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c328aa13a6 Version Packages (#8081)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-22 15:19:02 +07:00
FirekeeperandGitHub a85ef0b222 Add Zephyr testnet to pre-1559 chains (#8092) 2025-09-22 15:07:13 +07:00
sashaodessaandGitHub 51177fbe17 Fix typos in comments and docs across thirdweb packages (#8033)
Signed-off-by: sashaodessa <[email protected]>
2025-09-19 18:59:29 -07:00
Jonas DanielsandGitHub 1338a0cf2c Remove taint from GROWTH_PLAN_SKU and PAYMENT_METHOD_CONFIGURATION (#8089) 2025-09-19 18:58:47 -07:00
MananTank 96ebf378ff Playground: Redirect corrections (#8087)
<!--

## 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 routing configuration in the `next.config.mjs` file to allow for dynamic path segments in specific routes.

### Detailed summary
- Changed `source` for `/insight` to `/insight/:path*`, allowing for dynamic paths.
- Changed `source` for `/payments/backend` to `/payments/backend/:path*`, enabling dynamic path handling.
- Both routes maintain their respective `destination` and `permanent` settings.

> ✨ 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 redirects for /insight and /payments/backend to handle nested paths, ensuring deep links (e.g., subpages) correctly route to their destinations without 404s.
  * Maintains existing base redirects and leaves other routes untouched, improving navigation reliability without altering overall site behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 01:10:38 +00:00
MananTank b7732d3a75 Dashboard: Add search in tokens page (#8088)
<!--

## 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 `Token` and `TokenPage` components by adding search functionality for tokens and improving the bridge network selection process.

### Detailed summary
- Added `query` parameter in `tokens` function for searching tokens by name or symbol.
- Updated `Page` component to fetch supported chains asynchronously.
- Modified `TokenPage` to accept `chains` as a prop and integrated search functionality.
- Enhanced `BridgeNetworkSelector` to utilize passed `chains` prop instead of querying.
- Added search input for token filtering in the `TokenPage`.

> ✨ 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 token search on the Tokens page; search by name or symbol with a new inline search field beside sort controls.
  * Search intelligently interacts with sorting: choosing Popular or Trending clears the search.

* **Improvements**
  * Faster, more reliable page load by fetching supported networks server‑side and passing them to the page.
  * Refined network selector UI with a consistent placeholder and smoother option rendering.

* **Refactor**
  * Streamlined components to receive pre-fetched chain data, reducing in-component data fetching and simplifying state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 00:54:37 +00:00
MananTank 6f88d38ad2 [BLD-325] Dashboard: Rename floating chat button label (#8086)
<!--

## 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 `CustomChatButton` component and its usage in various layouts by simplifying the button structure, changing icons, and removing unnecessary wrapper elements.

### Detailed summary
- Removed the wrapper `<div>` around the `CustomChatButton` in `layout.tsx` files.
- Changed the icon in `CustomChatButton` from `MessageCircleIcon` to `CircleQuestionMarkIcon`.
- Updated button label from dynamic to static "Need help?".
- Adjusted button class names for styling consistency.

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

<!-- end pr-codex -->
2025-09-20 00:03:01 +00:00
MananTank d53f8a25b4 [MNY-191] Dashboard: Add header in /bridge (#8085)
<!--

## 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 layout and components of the `Bridge` application by improving styling, adding new components, and refactoring existing structures for better usability and aesthetics.

### Detailed summary
- Updated `<body>` class in `layout.tsx` for improved layout.
- Changed width class in `UniversalBridgeEmbed.tsx` for full responsiveness.
- Added `relative z-10` class to the main `<div>` in `BuyAndSwapEmbed.tsx`.
- Introduced a new `PageHeader` component in `header.tsx` with navigation links.
- Refactored `BridgePage` in `page.tsx` to include `PageHeader` and remove unnecessary elements.
- Added `DotsBackgroundPattern` function for a decorative background effect.
- Integrated `AppFooter` in `BridgePage` for consistent footer display.

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

<!-- end pr-codex -->
2025-09-19 23:35:04 +00:00
MananTank dd693169bc Dashboard: Remove /routes (#8084)
<!--

## 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 the removal of several files related to the `bridge` feature in the dashboard and the addition of a new `tokens` page with metadata for improved SEO.

### Detailed summary
- Deleted files related to the `bridge` feature:
  - `utils.ts`
  - `layout.tsx`
  - `route.ts`
  - `page.tsx`
  - `opengraph-image.png`
  - Various components under `routes/components/client` and `routes/components/server`
- Added a new `tokens` page in `page.tsx` with:
  - Metadata for SEO, including title and description
  - Import of `PageHeader` and `TokenPage` 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
  - Redirects: Visiting /routes now automatically navigates to /tokens (temporary redirect).
  - Tokens page now includes enriched metadata for improved previews and sharing.

- Refactor
  - Removed the legacy Routes dashboard and associated UI controls, streamlining navigation to the Tokens experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 23:35:04 +00:00
MananTank 93f913c614 [MNY-189] SDK: SwapWidget UI improvements (#8080)
<!--

## 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 `SwapWidget`, enhancing responsiveness, styling, and functionality, particularly for mobile users. It also refines token selection and formatting for better user experience.

### Detailed summary
- Removed a changeset file related to `SwapWidget` UI improvements.
- Adjusted breakpoints and font sizes in `design-system/index.ts`.
- Enhanced `SearchInput` styling in `SearchInput.tsx`.
- Added `tokenAmountFormatter` utility in `utils.ts`.
- Made `children` prop optional in `basic.tsx`.
- Updated color values in `sdk-component-theme.ts`.
- Enhanced `Input` component styles in `formElements.tsx`.
- Improved `BuyAndSwapEmbed.tsx` with better token handling.
- Introduced `useIsMobile` hook for mobile detection.
- Updated `ArrowUpDownIcon.tsx` SVG for better rendering.
- Added hover background support in `buttons.tsx`.
- Renamed `WithData` to `WithDataDesktop` in `SelectChain.stories.tsx`.
- Added mobile versions of `WithData` and `Loading` functions in `SelectChain.stories.tsx`.
- Updated `SwapWidget` stories to manage token selections and themes.
- Refined `select-chain.tsx` for better mobile handling.
- Improved `swap-ui.tsx` with mobile responsiveness and token selection logic.
- Enhanced `select-token-ui.tsx` with mobile UI improvements and token display logic.
- Added `ActiveWalletDetails` for displaying wallet information.
- Updated various button and token display styles for consistency and better UX.

> ✨ 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**
  * Swap widget: onDisconnect callback, details modal, max-fill, improved wallet/account display, clearer loading/insufficient-balance states; stories now disable token-selection persistence.
  * Mobile detection hook enabling separate mobile/desktop flows.

* **UI/Style**
  * Enhanced token/chain visuals (gradients, smaller icon size), adjusted spacing and input sizing, customizable button hover backgrounds.

* **Refactor**
  * Unified token amount formatting and simplified numeric displays.

* **Chores**
  * Added changeset entry for UI improvements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 02:22:43 +05:30
MananTank 02502159b6 Dashboard: Add Tokens page (#8073)
<!--

## 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 token management system, focusing on adding price-related fields, sorting options, and improving the user interface for token display and selection.

### Detailed summary
- Added `marketCapUsd` and `volume24hUsd` fields to the `Token` type.
- Introduced `sortBy` option in token fetching.
- Updated `PublicPageConnectButton` to include a `detailsButton`.
- Created a new `Page` component for displaying token information.
- Implemented a `PageHeader` component for navigation.
- Added `TokenPage` component for token management with sorting capabilities.
- Developed `TokensTable` component for listing tokens with price, market cap, and volume.
- Introduced `BridgeNetworkSelector` for selecting blockchain networks.
- Enhanced UI with loading states and better token presentation.

> ✨ 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
  - Introduced a Tokens dashboard page with header and hero section.
  - Added network selector for choosing chains.
  - Implemented token list with columns for Token, Price, Market Cap, and 24h Volume.
  - Enabled sorting by Popular (market cap) and Trending (volume), plus pagination.
  - Improved Connect button with an additional details button style.

- Enhancements
  - Loading states and empty-state messaging for token lists.
  - Backend now supports token sorting and exposes market cap and 24h volume for richer displays.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 20:02:12 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e9225c3bad Version Packages (#8079)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 05:11:46 -07:00
Joaquim VergesandGitHub 5967fb8afa [SDK] Add x402 payment protocol utilities (#8076) 2025-09-19 05:00:05 -07:00
MananTank fddb791f0a Playground: Fix Pay components incomplete code gen, cleanup (#8078)
<!--

## 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 refactoring the payment components in the `playground-web` application, updating widget properties and enhancing code generation functions for better modularity and readability.

### Detailed summary
- Changed `lockedWidget` to `widget` in multiple components: `CheckoutPlayground`, `TransactionPlayground`, `BuyPlayground`, `LeftSection`, and `RightSection`.
- Added new functions in `code-gen.ts` for `stringifyProps`, `quotes`, `stringifyImports`, and `stringifyIgnoreFalsy`.
- Updated `CodeGen` components to accept `widget` as a prop.
- Refactored `getCode` function in `CodeGen` to use the new `widget` parameter.
- Removed unused imports and functions from `CodeGen` and `LeftSection`.

> ✨ 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
  - Code generator now supports explicit widget selection (buy, checkout, transaction) with unified, props-driven output.
  - Enhanced theming in generated snippets, including dark/light themes with overrides.

- Refactor
  - Migrated playground code generation to centralized utilities for imports and props.
  - Simplified widget configuration by removing it from options and passing it as a direct prop.
  - Streamlined Payments UI; removed “Sponsor gas fees” section.

- Style
  - Improved code snippet formatting with wider line wrap for cleaner display.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 10:12:08 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1732e63bb8 Version Packages (#8075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 02:02:04 -07:00
Joaquim VergesandGitHub 7b8ceebb63 [SDK] Show policy error for 7702 transactions (#8077) 2025-09-19 01:53:09 -07:00
MananTank 1d90437d34 Portal: Add SwapWidget in bridge/swap page (#8072)
<!--

## 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 documentation for the `SwapWidget` component, simplifying the example code, and enhancing the user experience by adding new features and usage guidelines.

### Detailed summary
- Added `DocImage` component to display `swap-dark.png`.
- Simplified the swap interface example to use the `SwapWidget`.
- Introduced a live playground section for the `SwapWidget`.
- Added configuration options for default token selection in the widget.
- Removed the complex swap interface code for clarity.
- Included tips for gas optimization and route caching.

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

<!-- end pr-codex -->
2025-09-19 00:32:32 +00:00
Joaquim VergesandGitHub 59616d0082 [Portal] Add documentation for third-party library adapters (#8074) 2025-09-18 17:31:46 -07:00
MananTank 32980f854b [MNY-190] Playground: Add SwapWidget (#8069)
<!--

## 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 significant updates to the `SwapWidget` component, including a new `persistTokenSelections` prop for managing token selection persistence. It also enhances the UI components and integrates a new bridge feature.

### Detailed summary
- Added `persistTokenSelections` prop to `SwapWidget`.
- Updated `SwapWidgetPlayground` for better token management.
- Introduced `BridgeNetworkSelector` and `CurrencySelector` components.
- Added `bridgeFeatureCards` to the feature metadata.
- Enhanced `LeftSection` for improved token and currency selection.
- Updated the sidebar to include the bridge section.
- Refactored network selection logic to use the bridge API.
- Improved code formatting and organization in multiple files.

> ✨ 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
  - SwapWidget gains a persistTokenSelections prop to opt out of saving token choices.
  - Added a Swap Widget Playground with live preview, generated code view, theme/currency/prefill/branding controls, and UI/Code tabs.
  - Playground is integrated into site navigation and feature cards under a new Bridge section.

- Chores
  - Playground now uses bridge-supported chains and improved TypeScript formatting for generated snippets.
  - Added a reusable currency selector for widget configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 00:17:37 +00:00
MananTank f3b3b89fca Dashboard: Add tracking for SwapWidget impression, chain pageview (#8071)
<!--

## 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 analytics tracking for chain page views and enhances the reporting for swap widgets in the dashboard application.

### Detailed summary
- Added `ChainPageView` component to track impressions of chain pages.
- Integrated `reportChainPageview` function in the analytics module.
- Enhanced `BuyAndSwapEmbed` to report when the swap widget is shown.

> ✨ 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
  - Added one-time telemetry when the Swap widget is displayed to improve usage insights.
  - Tracks the page context (asset, bridge, or chain) when the widget first appears.
  - Added one-time telemetry to record chain pageviews (captures the chain identifier) when visiting chain pages.
  - Integrated with existing UI; no impact on behavior, user action, or performance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-18 21:03:00 +00:00
Jonas DanielsandGitHub 44826925d0 Add Stripe checkout for Growth plan subscription (#8068) 2025-09-17 22:41:55 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
00dcc346bf Version Packages (#8063)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-18 10:58:25 +12:00
Manan TankandGitHub e0738d68d2 SDK, Dashboard: Fix SwapWidget setting same token for buy and sell in chain page (#8067) 2025-09-17 15:47:19 -07:00
Joaquim VergesandGitHub e160deb8b7 [SDK] Fix wallet reconnection for previously connected wallets (#8066) 2025-09-17 15:18:13 -07:00
Manan TankandGitHub a6f846fc1e [BLD-286] SDK: add stable classNames to elements in Connect UI (#8065) 2025-09-17 13:34:27 -07:00
Manan TankandGitHub 0e3f3460ad Add SwapWidget in SDK, add in dashboard, BuyWidget UI improvements (#8044) 2025-09-17 12:57:58 -07:00
MananTank 552f7025c1 Dashboard: Fix Import Engine form submit not working (#8064)
<!--

## 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 dialog for importing engine instances in the `ImportEngineButton` component, enhancing user interaction by managing the dialog state and updating UI elements.

### Detailed summary
- Added `useState` to manage the dialog's open state.
- Changed navigation from `router.push` to `router.refresh` after import.
- Wrapped the dialog in a conditional open state.
- Updated the layout and styling of the dialog content and links.
- Modified form field styles for better UI consistency.

> ✨ 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
  * Import dialog now closes automatically after a successful import and refreshes the current page.
  * Added an in-dialog warning beneath the URL field for clearer guidance.
  * Updated “Get started” link destination.

* Style
  * Redesigned import dialog with improved layout, spacing, rounded content, and a bottom action bar; inputs use card styling.
  * Refined labels, placeholders, and header padding for clearer readability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-17 15:58:39 +00:00
MananTank 15f083912c SDK: Fix onSuccess prop not called properly on CheckoutWidget and BuyWidget (#8054)
<!--

## 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 `onSuccess` callback not being triggered properly in the `CheckoutWidget` and `BuyWidget` components, improving the handling of buy completion and post-transaction actions.

### Detailed summary
- Renamed `handleBuyComplete` to `handleDoneOrContinueClick`.
- Adjusted the invocation of `onComplete` to only occur when `uiOptions.mode` is not "transaction".
- Updated the dependency array for `handleExecutionComplete` to include `onComplete` and `uiOptions.mode`.
- Changed the `onDone` prop in `SuccessScreen` to use `handleDoneOrContinueClick`.

> ✨ 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 where the success callback in checkout and buy widgets did not fire reliably.
  * Improved non-transaction flows so completion triggers after execution finishes (instead of on Done), ensuring proper state reset and more consistent behavior.

* **Chores**
  * Added a patch-level changeset entry documenting the fix.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-17 10:47:49 +00:00
GiselleNessi 04862de9e4 feat: add feedback collection UI to navigation (#7895)
<!--

## 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 feature for collecting user feedback within the application. It implements feedback submission forms in both desktop and mobile components, capturing user feedback and sending it to PostHog for analysis.

### Detailed summary
- Added `reportProductFeedback` function in `report.ts` to handle feedback submissions.
- Implemented feedback submission in the `SecondaryNavLinks` component.
- Added feedback dropdown with a form in `SecondaryNavLinks`.
- Integrated feedback functionality in the `MobileBurgerMenuButton` component.
- Included success notifications using `toast` for feedback submissions in both 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**
  * Replaced external Feedback link with an in-app feedback panel in the header and mobile menu.
  * Panel includes a textarea (max 1000 chars), Cancel and Submit actions; Submit is disabled when empty, shows a success message, clears input and closes the panel.
  * Adds an in-panel “Contact support” link for technical issues and preserves existing header controls.

* **Style**
  * Expandable/collapsible feedback UI with visual indicators, improved positioning and mobile menu scrolling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-16 16:37:22 +00:00
ea46572947 [Dashboard] Display eip7702 support status (#8060)
Co-authored-by: Cursor Agent <[email protected]>
2025-09-15 17:39:14 -07:00
Joaquim VergesandGitHub f276dcfd76 [SDK] Filter out AGW from searchable wallets (#8062) 2025-09-15 17:38:51 -07:00
Joaquim VergesandGitHub 6495b94910 [SDK] Simplify wallet connection code and add Abstract wallet support (#8061) 2025-09-15 16:29:01 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
210f631cba Version Packages (#8053)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-15 11:01:51 -07:00
gregandGitHub 557f0e29e8 Fix: Add brief pause after onramp (#8052) 2025-09-15 10:06:47 -07:00
Joaquim VergesandGitHub f9bbf8101c [Nebula] Add deprecation banner with migration information (#8051) 2025-09-14 17:10:33 -07:00
234 changed files with 11179 additions and 3084 deletions
+2
View File
@@ -64,3 +64,5 @@ ANALYTICS_SERVICE_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
GROWTH_PLAN_SKU=""
PAYMENT_METHOD_CONFIGURATION=""
+2
View File
@@ -21,6 +21,8 @@
"@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:*",
+5
View File
@@ -474,6 +474,11 @@ async function redirects() {
destination: "/team/~/~/support",
permanent: false,
},
{
source: "/routes",
destination: "/tokens",
permanent: false,
},
];
}
+62 -1
View File
@@ -1,8 +1,13 @@
import "server-only";
import { headers } from "next/headers";
import Stripe from "stripe";
import type { Team } from "@/api/team/get-team";
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
import {
GROWTH_PLAN_SKU,
PAYMENT_METHOD_CONFIGURATION,
STRIPE_SECRET_KEY,
} from "@/constants/server-envs";
let existingStripe: Stripe | undefined;
@@ -72,3 +77,59 @@ export async function getStripeBalance(customerId: string) {
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
return customer.balance === 0 ? 0 : customer.balance / -100;
}
export async function fetchClientSecret(team: Team) {
"use server";
const origin = (await headers()).get("origin");
const stripe = getStripe();
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
line_items: [
{
// Provide the exact Price ID (for example, price_1234) of
// the product you want to sell
price: GROWTH_PLAN_SKU,
quantity: 1,
},
],
mode: "subscription",
return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: "auto",
},
payment_method_collection: "always",
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
});
if (!session.client_secret) {
throw new Error("No client secret found");
}
return session.client_secret;
}
export async function getStripeSessionById(sessionId: string) {
const session = await getStripe().checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent"],
});
return session;
}
+174 -30
View File
@@ -81,33 +81,6 @@ export function reportOnboardingStarted() {
posthog.capture("onboarding started");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that select a paid plan during onboarding
* - To know **which** plan was selected
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelected(properties: {
plan: Team["billingPlan"];
}) {
posthog.capture("onboarding plan selected", properties);
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that skip the plan-selection step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelectionSkipped() {
posthog.capture("onboarding plan selection skipped");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that invite members during onboarding
@@ -161,6 +134,18 @@ export function reportOnboardingMembersUpsellPlanSelected(properties: {
posthog.capture("onboarding members upsell plan selected", properties);
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that completed the team member step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportTeamMemberStepCompleted() {
posthog.capture("onboarding members completed");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that completed onboarding
@@ -240,7 +225,7 @@ type AssetContractType =
*/
export function reportAssetBuySuccessful(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy successful", {
@@ -250,9 +235,113 @@ export function reportAssetBuySuccessful(properties: {
});
}
type TokenSwapParams = {
buyTokenChainId: number;
buyTokenAddress: string;
sellTokenChainId: number;
sellTokenAddress: string;
pageType: "asset" | "bridge" | "chain";
};
type TokenBuyParams = {
buyTokenChainId: number | undefined;
buyTokenAddress: string | undefined;
pageType: "asset" | "bridge" | "chain";
};
/**
* ### Why do we need to report this event?
* - To track number of failed asset purchases from the token page
* - To track number of successful token buys
* - To track which tokens are being bought the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenBuySuccessful(properties: TokenBuyParams) {
posthog.capture("token buy successful", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed token buys
* - To track which token buys are failing
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenBuyFailed(properties: TokenBuyParams) {
posthog.capture("token buy failed", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled token buys
* - To track which token buys are being cancelled
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenBuyCancelled(properties: TokenBuyParams) {
posthog.capture("token buy cancelled", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of successful token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
posthog.capture("token swap successful", properties);
}
/**
* ### Why do we need to report this event?
* - To track impressions of the swap widget
* - To create a funnel "swap widget shown" -> "swap widget successful" to understand the conversion rate
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportSwapWidgetShown(properties: {
pageType: "asset" | "bridge" | "chain";
}) {
posthog.capture("swap widget shown", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapFailed(
properties: TokenSwapParams & {
errorMessage: string;
},
) {
posthog.capture("token swap failed", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapCancelled(properties: TokenSwapParams) {
posthog.capture("token swap cancelled", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed asset purchases
* - To track the errors that users encounter when trying to purchase an asset
*
* ### Who is responsible for this event?
@@ -260,7 +349,7 @@ export function reportAssetBuySuccessful(properties: {
*/
export function reportAssetBuyFailed(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
error: string;
}) {
@@ -272,6 +361,26 @@ export function reportAssetBuyFailed(properties: {
});
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled asset purchases from the token page
* - To track the errors that users encounter when trying to purchase an asset
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportAssetBuyCancelled(properties: {
chainId: number;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy cancelled", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
});
}
// Assets Landing Page ----------------------------
/**
@@ -477,6 +586,17 @@ export function reportAssetPageview(properties: {
posthog.capture("asset pageview", properties);
}
/**
* ### Why do we need to report this event?
* - To understand which chains are being viewed the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportChainPageview(properties: { chainId: number }) {
posthog.capture("chain pageview", properties);
}
/**
* ### Why do we need to report this event?
* - To track the usage of fund wallet modal
@@ -547,3 +667,27 @@ export function reportChainInfraRpcOmissionAgreed(properties: {
}) {
posthog.capture("chain infra checkout rpc omission agreed", properties);
}
// ----------------------------
// FEEDBACK
// ----------------------------
/**
* ### Why do we need to report this event?
* - To track user feedback and sentiment about the product
* - To identify common issues or feature requests
* - To measure user satisfaction and engagement
* - To prioritize product improvements based on user input
*
* ### Who is responsible for this event?
* @gisellechacon
*/
export function reportProductFeedback(properties: {
feedback: string;
source: "desktop" | "mobile";
}) {
posthog.capture("product feedback submitted", {
feedback: properties.feedback,
source: properties.source,
});
}
@@ -0,0 +1,248 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Chain } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import {
reportAssetBuyCancelled,
reportAssetBuyFailed,
reportAssetBuySuccessful,
reportSwapWidgetShown,
reportTokenBuyCancelled,
reportTokenBuyFailed,
reportTokenBuySuccessful,
reportTokenSwapCancelled,
reportTokenSwapFailed,
reportTokenSwapSuccessful,
} from "@/analytics/report";
import { Button } from "@/components/ui/button";
import {
NEXT_PUBLIC_ASSET_PAGE_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 { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
type PageType = "asset" | "bridge" | "chain";
export function BuyAndSwapEmbed(props: {
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
pageType: PageType;
}) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
const isMounted = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (isMounted.current) {
return;
}
isMounted.current = true;
reportSwapWidgetShown({
pageType: props.pageType,
});
}, [props.pageType]);
const client = useMemo(() => {
return getConfiguredThirdwebClient({
clientId:
props.pageType === "asset"
? NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID
: props.pageType === "bridge"
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
: props.pageType === "chain"
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
: undefined,
secretKey: undefined,
teamId: undefined,
});
}, [props.pageType]);
return (
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl">
<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>
{tab === "buy" && (
<BuyWidget
amount={props.buyAmount || "1"}
chain={props.chain}
className="!rounded-2xl !w-full !border-none"
title=""
client={client}
connectOptions={{
autoConnect: false,
}}
onError={(e, quote) => {
const errorMessage = parseError(e);
reportTokenBuyFailed({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
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: props.chain.id,
error: errorMessage,
contractType: undefined,
});
}
}}
onCancel={(quote) => {
reportTokenBuyCancelled({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
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: props.chain.id,
contractType: undefined,
});
}
}}
onSuccess={(quote) => {
reportTokenBuySuccessful({
buyTokenChainId:
quote.type === "buy"
? quote.intent.destinationChainId
: quote.type === "onramp"
? quote.intent.chainId
: undefined,
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: props.chain.id,
contractType: undefined,
});
}
}}
theme={themeObj}
tokenAddress={props.tokenAddress as `0x${string}`}
paymentMethods={["card"]}
/>
)}
{tab === "swap" && (
<SwapWidget
client={client}
theme={themeObj}
className="!rounded-2xl !border-none !w-full"
prefill={{
// buy this token by default
buyToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// sell the native token by default (but if buytoken is a native token, don't set)
sellToken: props.tokenAddress
? {
chainId: props.chain.id,
}
: undefined,
}}
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}
className={cn(
"rounded-full text-muted-foreground px-5 text-base bg-accent",
props.isActive && "text-foreground border-foreground",
)}
variant="outline"
>
{props.label}
</Button>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Bridge, ThirdwebClient } from "thirdweb";
import { MultiSelect } from "@/components/blocks/multi-select";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { Badge } from "@/components/ui/badge";
@@ -296,3 +296,89 @@ export function SingleNetworkSelector(props: {
/>
);
}
type BridgeNetworkSelectorProps = {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
side?: "left" | "right" | "top" | "bottom";
align?: "center" | "start" | "end";
placeholder?: string;
client: ThirdwebClient;
chains: Bridge.chains.Result;
};
export function BridgeNetworkSelector(props: BridgeNetworkSelectorProps) {
const options = useMemo(() => {
return props.chains.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [props.chains]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = props.chains.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[props.chains],
);
const renderOption = useCallback(
(option: Option) => {
const chain = props.chains.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return option.label;
}
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
</div>
);
},
[props.chains, props.client],
);
return (
<SelectWithSearch
align={props.align}
className={props.className}
closeOnSelect={true}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={props.placeholder || "Select Chain"}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain ID"
showCheck={false}
side={props.side}
value={props.chainId?.toString()}
/>
);
}
@@ -30,6 +30,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
description?: string;
titleClassName?: string;
headerClassName?: string;
icon?: React.ReactNode;
};
customHeader?: React.ReactNode;
// chart config
@@ -70,6 +71,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
<Card className={props.className}>
{props.header && (
<CardHeader className={props.header.headerClassName}>
{props.header.icon}
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
{props.header.title}
</CardTitle>
@@ -0,0 +1,23 @@
import { GridPattern } from "@/components/ui/background-patterns";
export function GridPatternEmbedContainer(props: {
children: React.ReactNode;
}) {
return (
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-xl overflow-hidden relative">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70 hidden lg:block"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
/>
<div className="sm:w-[420px] z-10">{props.children}</div>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { MessageCircleIcon, XIcon } from "lucide-react";
import { CircleQuestionMarkIcon, XIcon } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { createThirdwebClient } from "thirdweb";
@@ -16,7 +16,6 @@ const client = createThirdwebClient({
});
export function CustomChatButton(props: {
label: string;
examplePrompts: string[];
authToken: string;
team: Team;
@@ -39,21 +38,25 @@ export function CustomChatButton(props: {
<>
{/* Inline Button (not floating) */}
<Button
className="gap-2 rounded-full shadow-lg"
className={cn(
"gap-2 rounded-full shadow-lg fixed right-6 bottom-6 z-50 bg-card",
isOpen && "opacity-0",
)}
size="sm"
onClick={() => {
setIsOpen(true);
setHasBeenOpened(true);
}}
variant="default"
variant="outline"
>
<MessageCircleIcon className="size-4" />
{props.label}
<CircleQuestionMarkIcon className="size-3.5 text-muted-foreground" />
Need help?
</Button>
{/* Popup/Modal */}
<div
className={cn(
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-2xl",
!isOpen && "hidden",
)}
ref={ref}
@@ -61,8 +64,7 @@ export function CustomChatButton(props: {
{/* Header with close button */}
<div className="flex items-center justify-between border-b px-4 py-4">
<div className="flex items-center gap-2 font-semibold text-lg">
<MessageCircleIcon className="size-5 text-muted-foreground" />
{props.label}
Need help?
</div>
<Button
aria-label="Close chat"
@@ -44,11 +44,12 @@ function AnnouncementBannerUI(props: {
}
export function AnnouncementBanner() {
return (
<AnnouncementBannerUI
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
trackingLabel="product-rebrand"
/>
);
// return (
// <AnnouncementBannerUI
// href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
// label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
// trackingLabel="product-rebrand"
// />
// );
return null;
}
@@ -32,3 +32,12 @@ export const NEXT_PUBLIC_DEMO_ENGINE_URL =
export const NEXT_PUBLIC_THIRDWEB_AI_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com";
export const NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID;
export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID;
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
@@ -91,3 +91,8 @@ if (REDIS_URL) {
REDIS_URL,
);
}
// DO NOT TAINT THESE VALUES (for now)
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
export const PAYMENT_METHOD_CONFIGURATION =
process.env.PAYMENT_METHOD_CONFIGURATION || "";
@@ -25,6 +25,7 @@ import { getVercelEnv } from "@/utils/vercel";
export function getConfiguredThirdwebClient(options: {
secretKey: string | undefined;
teamId: string | undefined;
clientId?: string;
}): ThirdwebClient {
if (getVercelEnv() !== "production") {
// if not on production: run this when creating a client to set the domains
@@ -77,7 +78,8 @@ export function getConfiguredThirdwebClient(options: {
}
// During build time, provide fallbacks if credentials are missing
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
const clientId =
options.clientId || NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
const secretKey = options.secretKey || undefined;
return createThirdwebClient({
@@ -31,9 +31,9 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
selectedTextBg: "hsl(var(--inverted))",
selectedTextColor: "hsl(var(--inverted-foreground))",
separatorLine: "hsl(var(--border))",
skeletonBg: "hsl(var(--muted))",
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
success: "hsl(var(--success-text))",
tertiaryBg: "hsl(var(--muted)/50%)",
tertiaryBg: "hsl(var(--muted)/30%)",
tooltipBg: "hsl(var(--popover))",
tooltipText: "hsl(var(--popover-foreground))",
},
@@ -1,12 +0,0 @@
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
{children}
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

@@ -1,37 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { useDashboardRouter } from "@/lib/DashboardRouter";
type ChainlistPaginationProps = {
totalPages: number;
activePage: number;
};
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
activePage,
totalPages,
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const createPageURL = useCallback(
(pageNumber: number) => {
const params = new URLSearchParams(searchParams || undefined);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return (
<PaginationButtons
activePage={activePage}
onPageClick={(page) => router.push(createPageURL(page))}
totalPages={totalPages}
/>
);
};
@@ -1,74 +0,0 @@
"use client";
import { SearchIcon, XCircleIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
function cleanUrl(url: string) {
if (url.endsWith("?")) {
return url.slice(0, -1);
}
return url;
}
export const SearchInput: React.FC = () => {
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
// reset the input if the query param is removed
if (inputRef.current?.value && !searchParams?.get("query")) {
inputRef.current.value = "";
}
}, [searchParams]);
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams ?? undefined);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
// always delete the page number when searching
params.delete("page");
const url = cleanUrl(`${pathname}?${params.toString()}`);
router.replace(url);
}, 300);
return (
<div className="group relative w-full">
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
<Input
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
defaultValue={searchParams?.get("query") || ""}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search by token name or symbol"
ref={inputRef}
/>
{searchParams?.has("query") && (
<Button
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
onClick={() => {
const params = new URLSearchParams(searchParams ?? undefined);
params.delete("query");
params.delete("page");
const url = cleanUrl(`${pathname}?${params.toString()}`);
router.replace(url);
}}
size="icon"
variant="outline"
>
<XCircleIcon className="size-5" />
</Button>
)}
</div>
);
};
@@ -1,55 +0,0 @@
"use client";
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
type QueryTypeProps = {
activeType: "origin" | "destination";
};
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const createPageURL = useCallback(
(type: "origin" | "destination") => {
const params = new URLSearchParams(searchParams || undefined);
params.set("type", type);
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return (
<div className="flex flex-row">
<ToolTipLabel contentClassName="w-full" label="Origin">
<Button
className="rounded-r-none"
onClick={() => {
router.replace(createPageURL("origin"));
}}
size="icon"
variant={activeType === "origin" ? "default" : "outline"}
>
<ArrowUpRightIcon strokeWidth={1} />
</Button>
</ToolTipLabel>
<ToolTipLabel contentClassName="w-full" label="Destination">
<Button
className="rounded-l-none"
onClick={() => {
router.replace(createPageURL("destination"));
}}
size="icon"
variant={activeType === "destination" ? "default" : "outline"}
>
<ArrowDownLeftIcon strokeWidth={1} />
</Button>
</ToolTipLabel>
</div>
);
};
@@ -1,50 +0,0 @@
"use client";
import { Grid2X2Icon, ListIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
type RouteListViewProps = {
activeView: "grid" | "table";
};
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const createPageURL = useCallback(
(view: "grid" | "table") => {
const params = new URLSearchParams(searchParams || undefined);
params.set("view", view);
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return (
<div className="flex flex-row">
<Button
className="rounded-r-none"
onClick={() => {
router.replace(createPageURL("table"));
}}
size="icon"
variant={activeView === "table" ? "default" : "outline"}
>
<ListIcon strokeWidth={1} />
</Button>
<Button
className="rounded-l-none"
onClick={() => {
router.replace(createPageURL("grid"));
}}
size="icon"
variant={activeView === "grid" ? "default" : "outline"}
>
<Grid2X2Icon strokeWidth={1} />
</Button>
</div>
);
};
@@ -1,141 +0,0 @@
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
type RouteListCardProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri?: string | null;
originTokenSymbol: string;
originTokenName: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri?: string | null;
destinationTokenSymbol: string;
destinationTokenName: string;
};
export async function RouteListCard({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenName,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenName,
}: RouteListCardProps) {
const [
originChain,
destinationChain,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
originTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: originTokenIconUri,
})
: undefined,
destinationTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: destinationTokenIconUri,
})
: undefined,
]);
return (
<div className="relative h-full">
<Card className="h-full w-full">
<CardHeader className="flex flex-row items-center justify-between p-4">
<div className="flex flex-row items-center gap-2">
{resolvedOriginTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={originTokenAddress}
className="size-8 rounded-full border border-muted-foreground"
src={resolvedOriginTokenIconUri}
/>
) : (
<div className="size-8 rounded-full bg-muted-foreground" />
)}
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={destinationTokenAddress}
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
src={resolvedDestinationTokenIconUri}
/>
) : (
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
)}
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4">
<div className="flex flex-col gap-1">
<div className="flex justify-between gap-2">
<TokenName
chainMetadata={originChain}
tokenAddress={originTokenAddress}
tokenName={originTokenName}
/>
<div className="text-right text-base text-muted-foreground">
{originChain.name}
</div>
</div>
<div className="flex justify-between gap-2">
<TokenName
chainMetadata={destinationChain}
tokenAddress={destinationTokenAddress}
tokenName={destinationTokenName}
/>
<div className="text-right text-base text-muted-foreground">
{destinationChain.name}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
function TokenName(props: {
tokenAddress: string;
tokenName: string;
chainMetadata: ChainMetadata;
}) {
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
if (isERC20) {
return (
<Link
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
target="_blank"
>
{props.tokenName}
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
</Link>
);
}
return (
<div className="text-left font-normal text-base">
{props.chainMetadata.nativeCurrency.name}
</div>
);
}
@@ -1,136 +0,0 @@
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import {
type ChainMetadata,
defineChain,
getChainMetadata,
} from "thirdweb/chains";
import { shortenAddress } from "thirdweb/utils";
import { Img } from "@/components/blocks/Img";
import { Button } from "@/components/ui/button";
import { TableCell, TableRow } from "@/components/ui/table";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
type RouteListRowProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri?: string | null;
originTokenSymbol?: string;
originTokenName?: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri?: string | null;
destinationTokenSymbol?: string;
destinationTokenName?: string;
};
export async function RouteListRow({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenSymbol,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenSymbol,
}: RouteListRowProps) {
const [
originChain,
destinationChain,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
originTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: originTokenIconUri,
})
: undefined,
destinationTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: destinationTokenIconUri,
})
: undefined,
]);
return (
<TableRow className="group">
<TableCell>
<TokenInfo
chainMetadata={originChain}
tokenAddress={originTokenAddress}
tokenIconUri={resolvedOriginTokenIconUri}
tokenSymbol={originTokenSymbol}
/>
</TableCell>
<TableCell className="text-muted-foreground/90">
<span className="font-medium">{originChain.name}</span>
</TableCell>
<TableCell>
<TokenInfo
chainMetadata={destinationChain}
tokenAddress={destinationTokenAddress}
tokenIconUri={resolvedDestinationTokenIconUri}
tokenSymbol={destinationTokenSymbol}
/>
</TableCell>
<TableCell className="text-muted-foreground/90">
<span className="font-medium">{destinationChain.name}</span>
</TableCell>
</TableRow>
);
}
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
function TokenInfo(props: {
tokenAddress: string;
tokenSymbol: string | undefined;
chainMetadata: ChainMetadata;
tokenIconUri: string | undefined;
}) {
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
return (
<div className="flex items-center gap-1.5">
{props.tokenIconUri ? (
<Img
alt={props.tokenAddress}
className="size-7 rounded-full border border-border/50"
src={props.tokenIconUri}
/>
) : (
<div className="size-7 rounded-full bg-muted-foreground/20" />
)}
{isERC20 ? (
<Button
asChild
className="h-auto w-auto py-1 px-1.5 gap-2"
variant="ghost"
>
<Link
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
target="_blank"
>
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
</Button>
) : (
<span className="font-medium text-sm px-1.5 py-1">
{props.chainMetadata.nativeCurrency.symbol}
</span>
)}
</div>
);
}
@@ -1,176 +0,0 @@
import type { Address } from "thirdweb";
import {
Table,
TableBody,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getRoutes } from "../../../utils";
import { ChainlistPagination } from "../client/pagination";
import { RouteListCard } from "./routelist-card";
import { RouteListRow } from "./routelist-row";
export type SearchParams = Partial<{
includeDeprecated: boolean;
query: string;
page: number;
type: "origin" | "destination";
// maybe later we'll have a page size param?
// pageSize: number;
view: "table" | "grid";
}>;
// 120 is divisible by 2, 3, and 4 so card layout looks nice
const DEFAULT_PAGE_SIZE = 120;
async function getRoutesToRender(params: SearchParams) {
const filters: Partial<{
originQuery?: string;
destinationQuery?: string;
originChainId?: number;
originTokenAddress?: Address;
destinationChainId?: number;
destinationTokenAddress?: Address;
}> = {};
if (params.type === "origin" || typeof params.type === "undefined") {
if (params.query?.startsWith("0x")) {
filters.originTokenAddress = params.query as Address;
} else if (Number.isInteger(Number(params.query))) {
filters.originChainId = Number(params.query);
} else if (params.query) {
filters.originQuery = params.query;
}
} else if (params.type === "destination") {
if (params.query?.startsWith("0x")) {
filters.destinationTokenAddress = params.query as Address;
} else if (Number.isInteger(Number(params.query))) {
filters.destinationChainId = Number(params.query);
} else if (params.query) {
filters.destinationQuery = params.query;
}
}
const routes = await getRoutes({
destinationQuery: filters.destinationQuery,
limit: DEFAULT_PAGE_SIZE,
offset: DEFAULT_PAGE_SIZE * ((params.page || 1) - 1),
originQuery: filters.originQuery,
});
return {
filteredCount: routes.meta.filteredCount,
routesToRender: routes.data,
totalCount: routes.meta.totalCount,
};
}
export async function RoutesData(props: {
searchParams: SearchParams;
activeView: "table" | "grid";
isLoggedIn: boolean;
}) {
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
props.searchParams,
);
const totalPages = Math.ceil(filteredCount / DEFAULT_PAGE_SIZE);
return (
<>
<main>
{/* empty state */}
{routesToRender.length === 0 ? (
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
<p className="text-2xl">No Results found</p>
</div>
) : props.activeView === "table" ? (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Origin Token</TableHead>
<TableHead>Origin Chain</TableHead>
<TableHead>Destination Token</TableHead>
<TableHead>Destination Chain</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{routesToRender.map((route) => (
<RouteListRow
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenName={route.destinationToken.name}
destinationTokenSymbol={route.destinationToken.symbol}
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenName={route.originToken.name}
originTokenSymbol={route.originToken.symbol}
/>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{routesToRender.map((route) => (
<li
className="h-full"
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
>
<RouteListCard
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenName={route.destinationToken.name}
destinationTokenSymbol={route.destinationToken.symbol}
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenName={route.originToken.name}
originTokenSymbol={route.originToken.symbol}
/>
</li>
))}
</ul>
)}
</main>
<div className="h-10" />
{totalPages > 1 && (
<ChainlistPagination
activePage={Number(props.searchParams.page) || 1}
totalPages={totalPages}
/>
)}
<div className="h-4" />
<p className="text-balance text-center text-muted-foreground text-sm">
Showing{" "}
<span className="text-accent-foreground">{routesToRender.length}</span>{" "}
out of{" "}
{filteredCount !== totalCount ? (
<>
<span className="text-accent-foreground">
{filteredCount.toLocaleString()}
</span>{" "}
routes that match filters. (Total:{" "}
<span className="text-accent-foreground">
{totalCount.toLocaleString()}
</span>
)
</>
) : (
<>
<span className="text-accent-foreground">
{totalCount.toLocaleString()}
</span>{" "}
routes.
</>
)}
</p>
</>
);
}
@@ -1,94 +0,0 @@
import { ArrowUpRightIcon } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getAuthToken } from "@/api/auth-token";
import { SearchInput } from "./components/client/search";
import { QueryType } from "./components/client/type";
import { RouteListView } from "./components/client/view";
import {
RoutesData,
type SearchParams,
} from "./components/server/routes-table";
const title = "Payments Routes: Swap, Bridge, and Onramp";
const description =
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
export default async function RoutesPage(props: {
searchParams: Promise<SearchParams>;
}) {
const authToken = await getAuthToken();
const headersList = await headers();
const viewportWithHint = Number(
headersList.get("Sec-Ch-Viewport-Width") || 0,
);
const searchParams = await props.searchParams;
const activeType = searchParams.type ?? "origin";
// default is driven by viewport hint
const activeView = searchParams.view
? searchParams.view
: viewportWithHint > 1000
? "table"
: "grid";
return (
<section className="container mx-auto flex h-full flex-col px-4 py-10">
<header className="flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-2">
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
Routes
</h1>
</div>
<div className="flex flex-row items-end gap-4 lg:flex-col">
<div className="flex w-full flex-row items-center gap-4">
<SearchInput />
<QueryType activeType={activeType} />
<RouteListView activeView={activeView} />
</div>
</div>
</div>
</header>
<div className="h-10" />
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with thirdweb Payments
</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
</p>
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/payments"
rel="noopener noreferrer"
target="_blank"
>
Learn More
<ArrowUpRightIcon className="size-4" />
</a>
</div>
</div>
<div className="h-10" />
<RoutesData
activeView={activeView}
isLoggedIn={!!authToken}
searchParams={searchParams}
/>
</section>
);
}
@@ -1,18 +0,0 @@
import type { Address } from "thirdweb";
export type Route = {
originToken: {
address: Address;
chainId: number;
iconUri?: string;
name: string;
symbol: string;
};
destinationToken: {
address: Address;
chainId: number;
iconUri?: string;
name: string;
symbol: string;
};
};
@@ -1,48 +0,0 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import type { Route } from "./types/route";
export async function getRoutes({
limit,
offset,
originQuery,
destinationQuery,
}: {
limit?: number;
offset?: number;
originQuery?: string;
destinationQuery?: string;
} = {}) {
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
if (limit) {
url.searchParams.set("limit", limit.toString());
}
if (offset) {
url.searchParams.set("offset", offset.toString());
}
if (originQuery) {
url.searchParams.set("originQuery", originQuery);
}
if (destinationQuery) {
url.searchParams.set("destinationQuery", destinationQuery);
}
url.searchParams.set("sortBy", "popularity");
const routesResponse = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
next: { revalidate: 60 * 60 },
});
if (!routesResponse.ok) {
throw new Error("Failed to fetch routes");
}
const routes: {
data: Route[];
meta: { totalCount: number; filteredCount: number };
} = await routesResponse.json();
return routes;
}
@@ -1,24 +1,19 @@
"use client";
import { useTheme } from "next-themes";
import { defineChain, type ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyWidget } from "thirdweb/react";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
export function BuyFundsSection(props: {
chain: ChainMetadata;
client: ThirdwebClient;
}) {
const { theme } = useTheme();
export function BuyFundsSection(props: { chain: ChainMetadata }) {
return (
<section className="flex flex-col gap-4 items-center justify-center">
<BuyWidget
amount="0"
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.chain.chainId)}
client={props.client}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
tokenAddress={undefined}
pageType="chain"
/>
</section>
</GridPatternEmbedContainer>
);
}
@@ -0,0 +1,13 @@
"use client";
import { reportChainPageview } from "@/analytics/report";
import { useEffectOnce } from "@/hooks/useEffectOnce";
export function ChainPageView(props: { chainId: number }) {
useEffectOnce(() => {
reportChainPageview({
chainId: props.chainId,
});
});
return null;
}
@@ -43,9 +43,7 @@ function useChainStatswithRPC(_rpcUrl: string) {
const latency = (performance.now() - startTimeStamp).toFixed(0);
const blockNumber = Number.parseInt(json.result.number, 16);
const blockGasLimit = Number.parseInt(json.result.gasLimit, 16);
return {
blockGasLimit,
blockNumber,
latency,
};
@@ -61,8 +59,70 @@ function useChainStatswithRPC(_rpcUrl: string) {
});
}
function useEIP7702Support(_rpcUrl: string) {
let rpcUrl = _rpcUrl.replace(
// eslint-disable-next-line no-template-curly-in-string
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is expected in this case
"${THIRDWEB_API_KEY}",
NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
);
// based on the environment hit dev or production
if (hostnameEndsWith(rpcUrl, "rpc.thirdweb.com")) {
if (!isProd) {
rpcUrl = rpcUrl.replace("rpc.thirdweb.com", "rpc.thirdweb-dev.com");
}
}
return useQuery({
enabled: !!rpcUrl,
queryFn: async () => {
try {
const res = await fetch(rpcUrl, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "eth_estimateGas",
params: [
{
from: "0xdeadbeef00000000000000000000000000000000",
to: "0xdeadbeef00000000000000000000000000000000",
data: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
value: "0x0",
},
"latest",
{
"0xdeadbeef00000000000000000000000000000000": {
code: "0xef01000000000000000000000000000000000000000001",
},
},
],
}),
method: "POST",
});
const json = await res.json();
// If the response has a valid result object, EIP-7702 is enabled
return {
isSupported: !!json.result,
};
} catch {
// If the request fails or errors, EIP-7702 is not supported
return {
isSupported: false,
};
}
},
queryKey: ["eip-7702-support", { rpcUrl }],
refetchInterval: false, // Don't refetch this as it's unlikely to change
refetchOnWindowFocus: false,
});
}
export function ChainLiveStats(props: { rpc: string }) {
const stats = useChainStatswithRPC(props.rpc);
const eip7702Support = useEIP7702Support(props.rpc);
return (
<>
@@ -107,8 +167,8 @@ export function ChainLiveStats(props: { rpc: string }) {
)}
</PrimaryInfoItem>
{/* Block Height */}
<PrimaryInfoItem title="Block Height" titleIcon={<PulseDot />}>
{/* Latest Block */}
<PrimaryInfoItem title="Latest Block" titleIcon={<PulseDot />}>
{stats.isError ? (
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
) : stats.data ? (
@@ -120,16 +180,37 @@ export function ChainLiveStats(props: { rpc: string }) {
)}
</PrimaryInfoItem>
{/* Block Gas Limit */}
<PrimaryInfoItem title="Block Gas Limit" titleIcon={<PulseDot />}>
{stats.isError ? (
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
) : stats.data ? (
<p className="fade-in-0 animate-in">
{stats.data.blockGasLimit ?? "N/A"}
</p>
{/* EIP-7702 */}
<PrimaryInfoItem
title="EIP-7702"
titleIcon={
eip7702Support.data ? (
eip7702Support.data.isSupported ? (
<ToolTipLabel label="Enabled">
<CircleCheckIcon className="size-4 text-success-text" />
</ToolTipLabel>
) : (
<ToolTipLabel label="Disabled">
<XIcon className="size-4 text-destructive-text" />
</ToolTipLabel>
)
) : eip7702Support.isError ? (
<ToolTipLabel label="Disabled">
<XIcon className="size-4 text-destructive-text" />
</ToolTipLabel>
) : null
}
>
{eip7702Support.data ? (
eip7702Support.data.isSupported ? (
"Available"
) : (
"Not Available"
)
) : eip7702Support.isError ? (
"Not Available"
) : (
<div className="flex h-[28px] w-[140px] py-1">
<div className="flex h-[28px] w-[80px] py-1">
<Skeleton className="h-full w-full" />
</div>
)}
@@ -24,6 +24,7 @@ import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
import { ChainPageView } from "./components/client/chain-pageview";
import { ChainHeader } from "./components/server/chain-header";
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
@@ -71,6 +72,7 @@ export default async function ChainPageLayout(props: {
return (
<div className="flex grow flex-col">
<ChainPageView chainId={chain.chainId} />
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
@@ -1,5 +1,4 @@
import { ImageResponse } from "next/og";
import { useId } from "react";
import { download } from "thirdweb/storage";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
@@ -18,8 +17,8 @@ export const size = {
export const contentType = "image/png";
const TWLogo: React.FC = () => {
const clipPathId = useId();
const linearGradientId = useId();
const clipPathId = "og-tw-logo-clip-path";
const linearGradientId = "og-tw-logo-linear-gradient";
return (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
<svg
@@ -44,7 +44,7 @@ export default async function Page(props: {
{chain.testnet ? (
<FaucetSection chain={chain} client={client} isLoggedIn={!!account} />
) : chain.services.find((c) => c.service === "pay" && c.enabled) ? (
<BuyFundsSection chain={chain} client={client} />
<BuyFundsSection chain={chain} />
) : null}
{/* Chain Overview */}
@@ -67,7 +67,6 @@ export async function getContractUniqueWalletAnalytics(params: {
}
const json = (await res.json()) as InsightResponse;
console.log("wallet analytics json", json);
const aggregations = Object.values(json.aggregations[0]);
const returnValue: TransactionAnalyticsEntry[] = [];
@@ -1,6 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import { ImageResponse } from "@vercel/og";
import { useId } from "react";
import { getContractMetadata } from "thirdweb/extensions/common";
import { isProd } from "@/constants/env-utils";
import { API_ROUTES_CLIENT_ID } from "@/constants/server-envs";
@@ -62,8 +61,9 @@ function shortenString(str: string) {
return `${str.substring(0, 7)}...${str.substring(str.length - 5)}`;
}
const gradientId = "og-brand-icon-gradient";
const OgBrandIcon: React.FC = () => {
const gradientId = useId();
return (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
<svg
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b bg-card">
<div className="border-b">
<header
className={cn(
"container flex max-w-8xl justify-between py-3",
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
connectButton={{
className: props.connectButtonClassName,
}}
detailsButton={{
className: props.connectButtonClassName,
}}
connectModal={{
privacyPolicyUrl: "/privacy-policy",
showThirdwebBranding: false,
@@ -76,7 +76,7 @@ export function ContractHeaderUI(props: {
return (
<div
className={cn(
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
"flex flex-col lg:flex-row lg:items-center gap-5 py-8 relative",
props.className,
)}
>
@@ -1,48 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import type { Chain, ThirdwebClient } from "thirdweb";
import { BuyWidget } from "thirdweb/react";
import {
reportAssetBuyFailed,
reportAssetBuySuccessful,
} from "@/analytics/report";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyTokenEmbed(props: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string;
}) {
const { theme } = useTheme();
return (
<BuyWidget
amount="1"
chain={props.chain}
className="!rounded-xl !w-full"
client={props.client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
const errorMessage = parseError(e);
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
error: errorMessage,
});
}}
onSuccess={() => {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
tokenAddress={props.tokenAddress as `0x${string}`}
/>
);
}
@@ -1,6 +1,7 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeftRightIcon,
ArrowRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
@@ -73,8 +74,13 @@ function RecentTransfersUI(props: {
return (
<div>
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-xl">
<div className="flex mb-3">
<div className="rounded-full border p-2 bg-background">
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight mb-1">
Recent Transfers
</h2>
<p className="text-muted-foreground text-sm">
@@ -194,7 +200,7 @@ function RecentTransfersUI(props: {
)}
</TableContainer>
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
<div className="flex items-center justify-end gap-3 rounded-b-xl border-x border-b bg-card px-6 py-5">
<Button
className="gap-1.5 bg-background rounded-full"
disabled={props.page === 0 || props.isPending}
@@ -1,5 +1,6 @@
"use client";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import {
@@ -35,10 +36,18 @@ export function ContractAnalyticsOverview(props: {
margin={{
bottom: 20,
}}
className="rounded-xl"
header={{
headerClassName: "p-4 lg:p-6",
title: "Analytics",
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
icon: (
<div className="flex mb-3">
<div className="rounded-full border p-2 bg-background">
<BarChart3Icon className="size-4 text-muted-foreground" />
</div>
</div>
),
description:
"View trends of transactions, events and unique wallets interacting with this contract over time",
}}
@@ -1,77 +0,0 @@
export const mapChainIdToDexScreenerChainSlug = {
8453: "base",
1: "ethereum",
56: "bsc",
369: "pulsechain",
137: "polygon",
2741: "abstract",
43114: "avalanche",
999: "hyperliquid",
480: "worldchain",
42161: "arbitrum",
388: "cronos",
59144: "linea",
1514: "story",
397: "near",
295: "hedera",
146: "sonic",
10: "optimism",
80094: "berachain",
57073: "ink",
130: "unichain",
5000: "mantle",
324: "zksync",
466: "apechain",
1116: "core",
250: "fantom",
1868: "soneium",
81457: "blast",
2000: "dogechain",
14: "flare",
2040: "vana",
4337: "beam",
109: "shibarium",
747: "flowevm",
1088: "metis",
1030: "conflux",
43113: "avalanchedfk",
534352: "scroll",
747474: "katana",
42220: "celo",
1284: "moonbeam",
4200: "merlinchain",
2222: "kava",
39797: "energi",
34443: "mode",
252: "fraxtal",
48900: "zircuit",
20: "elastos",
100: "gnosischain",
204: "opbnb",
169: "manta",
1313161554: "aurora",
3073: "movement",
4689: "iotex",
23294: "oasissapphire",
6001: "bouncebit",
42170: "arbitrumnova",
1101: "polygonzkevm",
40: "telos",
592: "astar",
42262: "oasisemerald",
1285: "moonriver",
245022934: "neonevm",
7777777: "zora",
122: "fuse",
321: "kcc",
1234: "stepnetwork",
106: "velas",
167000: "taiko",
288: "boba",
42766: "zkfair",
32520: "bitgert",
82: "meter",
} as const;
export type DexScreenerChainSlug =
(typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug];
@@ -1,58 +0,0 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { ClientOnly } from "@/components/blocks/client-only";
import type { DexScreenerChainSlug } from "./dex-screener-chains";
function DexScreenerIframe(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
const { theme } = useTheme();
const iframeUrl = useMemo(() => {
const resolvedTheme = theme === "light" ? "light" : "dark";
const url = new URL("https://dexscreener.com");
url.pathname = `${props.chain}/${props.contractAddress}`;
url.searchParams.set("embed", "1");
url.searchParams.set("loadChartSettings", "0");
url.searchParams.set("chartTheme", resolvedTheme);
url.searchParams.set("theme", resolvedTheme);
url.searchParams.set("trades", "1");
url.searchParams.set("chartStyle", "1");
url.searchParams.set("chartLeftToolbar", "0");
url.searchParams.set("chartType", "usd");
url.searchParams.set("interval", "15");
url.searchParams.set("chartDefaultOnMobile", "1");
return url.toString();
}, [theme, props.chain, props.contractAddress]);
return (
<iframe
className="w-full h-[500px] lg:h-[1000px] rounded-lg overflow-hidden border bg-card"
src={iframeUrl}
title="DexScreener"
></iframe>
);
}
export function DexScreener(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
return (
<ClientOnly
ssr={
<div className="w-full h-[500px] lg:h-[1000px] rounded-lg border bg-card items-center justify-center flex">
<Spinner className="size-10" />
</div>
}
>
<DexScreenerIframe
chain={props.chain}
contractAddress={props.contractAddress}
/>
</ClientOnly>
);
}
@@ -1,42 +1,21 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { Bridge, type ThirdwebClient } from "thirdweb";
export async function fetchTokenInfoFromBridge(params: {
chainId: number;
tokenAddress: string;
clientId: string;
client: ThirdwebClient;
}) {
try {
const res = await fetch(
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
{
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
},
);
if (!res.ok) {
console.error(
`Failed to fetch token info from bridge: ${await res.text()}`,
);
return null;
}
const data = (await res.json()) as {
data: Array<{
iconUri: string;
address: string;
decimals: number;
name: string;
symbol: string;
priceUsd: number;
}>;
};
return data.data[0];
const res = await Bridge.tokens({
client: params.client,
chainId: params.chainId,
tokenAddress: params.tokenAddress,
includePrices: true,
limit: 1,
});
return res[0];
} catch {
return null;
return undefined;
}
}
@@ -1,9 +1,11 @@
import { BarChart3Icon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
import { cookies } from "next/headers";
import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { getContractMetadata } from "thirdweb/extensions/common";
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
import { GridPattern } from "@/components/ui/background-patterns";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { HAS_USED_DASHBOARD } from "@/constants/cookie";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { AssetPageView } from "../_components/asset-page-view";
@@ -12,9 +14,6 @@ import { PageHeader } from "../_components/PageHeader";
import { ContractHeaderUI } from "./_components/ContractHeader";
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
import { DexScreener } from "./_components/dex-screener";
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
import { RecentTransfers } from "./_components/RecentTransfers";
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
@@ -40,7 +39,7 @@ export async function ERC20PublicPage(props: {
}),
fetchTokenInfoFromBridge({
chainId: props.serverContract.chain.id,
clientId: props.clientContract.client.clientId,
client: props.serverContract.client,
tokenAddress: props.serverContract.address,
}),
resolveFunctionSelectors(props.serverContract),
@@ -80,7 +79,7 @@ export async function ERC20PublicPage(props: {
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
<PageHeader containerClassName="max-w-7xl" />
<div className="border-b">
<div className="border-b border-dashed">
<div className="container max-w-7xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
@@ -102,47 +101,57 @@ export async function ERC20PublicPage(props: {
<div className="h-8" />
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<div className=" sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
{tokenInfoFromUB && (
<div className="container max-w-7xl mb-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 p-6 border rounded-xl bg-card relative">
<TokenInfoSection
icon={DollarSignIcon}
label="Price"
value={
tokenInfoFromUB.prices.USD
? formatPrice(tokenInfoFromUB.prices.USD)
: "N/A"
}
/>
<div className="sm:w-[420px] z-10">
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</div>
</div>{" "}
<TokenInfoSection
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
icon={TrendingUpIcon}
label="Market Cap"
value={
tokenInfoFromUB.marketCapUsd
? formatCompactUSD(tokenInfoFromUB.marketCapUsd)
: "N/A"
}
/>
<TokenInfoSection
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
icon={BarChart3Icon}
label="Volume (24h)"
value={
tokenInfoFromUB.volume24hUsd
? formatCompactUSD(tokenInfoFromUB.volume24hUsd)
: "N/A"
}
/>
</div>
</div>
)}
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<DexScreener
chain={
mapChainIdToDexScreenerChainSlug[
props.chainMetadata
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
]
}
contractAddress={props.clientContract.address}
/>
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
@@ -185,10 +194,11 @@ function BuyEmbed(props: {
}) {
if (!props.claimConditionMeta) {
return (
<BuyTokenEmbed
<BuyAndSwapEmbed
chain={props.clientContract.chain}
client={props.clientContract.client}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
pageType="asset"
/>
);
}
@@ -218,3 +228,61 @@ async function getActiveClaimConditionWithErrorHandler(
}
type ActiveClaimCondition = Awaited<ReturnType<typeof getActiveClaimCondition>>;
function TokenInfoSection(props: {
label: string;
value: string;
icon: React.FC<{ className?: string }>;
className?: string;
}) {
return (
<div className={props.className}>
<div className="flex mb-5">
<div className="border rounded-full bg-background p-2">
<props.icon className="size-4 text-muted-foreground" />
</div>
</div>
<dl className="space-y-1.5">
<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">
{props.value}
</dd>
</dl>
</div>
);
}
function formatPrice(value: number): string {
if (value < 100) {
return smallValueUSDFormatter.format(value);
}
return largeValueUSDFormatter.format(value);
}
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 6,
roundingMode: "halfEven",
});
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
function formatCompactUSD(value: number): string {
return compactValueUSDFormatter.format(value);
}
@@ -0,0 +1,39 @@
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { cn } from "@/lib/utils";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b">
<header
className={cn(
"container flex max-w-7xl justify-between py-3",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link className="flex items-center gap-2" href="/team">
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
</span>
</Link>
</div>
<div className="flex items-center gap-5">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground"
>
Docs
</Link>
<ToggleThemeButton />
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
</div>
</header>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { ActivityIcon, SearchIcon, TrendingUpIcon } from "lucide-react";
import { useState } from "react";
import { Bridge } from "thirdweb";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TokensTable } from "./tokens-table";
const client = getClientThirdwebClient();
const pageSize = 20;
export function TokenPage(props: { chains: Bridge.chains.Result }) {
const [page, setPage] = useState(1);
const [chainId, setChainId] = useState(1);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
const tokensQuery = useQuery({
queryKey: [
"tokens",
{
page,
chainId,
sortBy,
search,
},
],
queryFn: () => {
return Bridge.tokens({
client: client,
chainId: chainId,
limit: pageSize,
offset: (page - 1) * pageSize,
sortBy: search ? undefined : sortBy,
query: search ? search : undefined,
});
},
refetchOnMount: false,
refetchOnWindowFocus: false,
});
return (
<div className="pb-20 pt-8">
<div className="container max-w-7xl">
<div className="mb-4 flex gap-4 lg:gap-3 flex-col lg:flex-row">
<BridgeNetworkSelector
client={client}
chainId={chainId}
onChange={setChainId}
className="rounded-xl bg-card lg:w-fit min-w-[280px]"
popoverContentClassName="!w-[300px] rounded-xl overflow-hidden"
chains={props.chains}
/>
<div className="flex gap-3">
<SortButton
label="Popular"
onClick={() => {
setSortBy("market_cap");
setSearch("");
}}
isSelected={sortBy === "market_cap" && !search}
icon={ActivityIcon}
/>
<SortButton
label="Trending"
onClick={() => {
setSortBy("volume");
setSearch("");
}}
isSelected={sortBy === "volume" && !search}
icon={TrendingUpIcon}
/>
</div>
<div className="relative w-full lg:w-[420px] ml-auto">
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search Tokens"
value={search}
className="rounded-xl bg-card flex-1 pl-9"
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<TokensTable
pageSize={pageSize}
tokens={tokensQuery.data ?? []}
isFetching={tokensQuery.isFetching}
pagination={{
onNext: () => setPage(page + 1),
onPrevious: () => setPage(page - 1),
nextDisabled: !!(
tokensQuery.data && tokensQuery.data.length < pageSize
),
previousDisabled: page === 1,
}}
/>
</div>
</div>
);
}
function SortButton(props: {
label: string;
onClick: () => void;
isSelected: boolean;
icon: React.FC<{ className?: string }>;
}) {
return (
<Button
variant="outline"
onClick={props.onClick}
className={cn(
"rounded-xl gap-2 bg-card",
props.isSelected && "bg-accent border-active-border",
)}
>
<props.icon className="size-3.5" />
{props.label}
</Button>
);
}
@@ -0,0 +1,195 @@
"use client";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function TokensTable(props: {
tokens: Bridge.TokenWithPrices[];
pageSize: number;
isFetching: boolean;
pagination: {
onNext: () => void;
onPrevious: () => void;
nextDisabled: boolean;
previousDisabled: boolean;
};
}) {
const { tokens, isFetching } = props;
return (
<div className="border rounded-xl overflow-hidden">
<TableContainer className="border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Token</TableHead>
<TableHead>Price</TableHead>
<TableHead>Market cap</TableHead>
<TableHead>Volume (24h)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isFetching
? new Array(props.pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TableRow key={i}>
<TableCell>
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-3 w-36" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
</TableRow>
))
: tokens.length > 0
? tokens.map((token) => {
const price = token.prices.USD;
return (
<TableRow
key={`${token.chainId}-${token.address}`}
className="hover:bg-accent/50"
linkBox
>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarImage
src={token.iconUri ?? undefined}
alt={token.symbol}
/>
<AvatarFallback>
{token.symbol?.slice(0, 2)?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1.5">
<span className="font-medium leading-none text-base">
{token.symbol}
</span>
<Link
href={
token.address.toLowerCase() ===
NATIVE_TOKEN_ADDRESS.toLowerCase()
? `/${token.chainId}`
: `/${token.chainId}/${token.address}`
}
prefetch={false}
className="before:absolute before:inset-0"
>
<span className="text-sm text-muted-foreground leading-none">
{token.name}
</span>
</Link>
</div>
</div>
</TableCell>
<TableCell>
{price ? formatPrice(price) : "N/A"}
</TableCell>
<TableCell>
{token.marketCapUsd
? formatUsdCompact(token.marketCapUsd)
: "N/A"}
</TableCell>
<TableCell>
{token.volume24hUsd
? formatUsdCompact(token.volume24hUsd)
: "N/A"}
</TableCell>
</TableRow>
);
})
: null}
</TableBody>
</Table>
</TableContainer>
{tokens.length === 0 && !isFetching && (
<div className="flex items-center justify-center py-20 text-muted-foreground w-full text-sm ">
No tokens found
</div>
)}
<div className="flex justify-end p-4 gap-3 border-t bg-card">
<Button
variant="outline"
className="gap-2 rounded-full"
size="sm"
onClick={() => props.pagination.onPrevious()}
disabled={props.pagination.previousDisabled || isFetching}
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
variant="outline"
className="gap-2 rounded-full"
size="sm"
onClick={() => props.pagination.onNext()}
disabled={props.pagination.nextDisabled || isFetching}
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
);
}
function formatPrice(value: number): string {
if (value < 100) {
return smallValueUSDFormatter.format(value);
}
return largeValueUSDFormatter.format(value);
}
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 6,
roundingMode: "halfEven",
});
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
function formatUsdCompact(value: number): string {
return compactValueUSDFormatter.format(value);
}
@@ -0,0 +1,61 @@
import { BringToFrontIcon } from "lucide-react";
import type { Metadata } from "next";
import { unstable_cache } from "next/cache";
import Link from "next/link";
import { Bridge } from "thirdweb";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { PageHeader } from "./components/header";
import { TokenPage } from "./components/token-page";
const title = "Tokens | thirdweb";
const description = "Discover and swap any tokens on any chain, instantly";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
export default async function Page() {
const chains = await getBridgeSupportedChains();
return (
<div>
<PageHeader />
<div className="border-b py-10 ">
<div className="container max-w-7xl">
<div className="flex mb-4">
<div className="rounded-full bg-card p-2.5 border">
<BringToFrontIcon className="size-6 text-muted-foreground" />
</div>
</div>
<h1 className="text-4xl font-bold tracking-tighter mb-1">
Discover and swap any tokens on any chain, instantly
</h1>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://thirdweb.com/monetize/bridge"
target="_blank"
>
Powered by thirdweb bridge
</Link>
</div>
</div>
<TokenPage chains={chains} />
</div>
);
}
const getBridgeSupportedChains = unstable_cache(
async () => {
const chains = await Bridge.chains({ client: serverThirdwebClient });
return chains;
},
["bridge-supported-chains"],
{
revalidate: 60 * 60, // 1 hour
},
);
@@ -1,7 +1,13 @@
"use client";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { reportProductFeedback } from "@/analytics/report";
import { NotificationsButton } from "@/components/notifications/notification-button";
import { NavLink } from "@/components/ui/NavLink";
import type { Account } from "@/hooks/useApi";
import { AccountButton } from "./account-button.client";
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
@@ -31,6 +37,32 @@ export function SecondaryNav(props: {
}
export function SecondaryNavLinks() {
const [showFeedbackDropdown, setShowFeedbackDropdown] = useState(false);
const [modalFeedback, setModalFeedback] = useState("");
const handleModalSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
// Report feedback to PostHog
reportProductFeedback({
feedback: modalFeedback,
source: "desktop",
});
// Show success notification
toast.success("Feedback submitted successfully!", {
description: "Thank you for your feedback. We'll review it shortly.",
});
setModalFeedback("");
setShowFeedbackDropdown(false);
};
const handleModalCancel = () => {
setModalFeedback("");
setShowFeedbackDropdown(false);
};
return (
<div className="flex items-center gap-6">
<ResourcesDropdownButton />
@@ -44,14 +76,77 @@ export function SecondaryNavLinks() {
Docs
</Link>
<Link
className="text-muted-foreground text-sm hover:text-foreground"
href="https://feedback.thirdweb.com"
rel="noopener noreferrer"
target="_blank"
>
Feedback
</Link>
<div className="relative">
<button
type="button"
onClick={() => setShowFeedbackDropdown(!showFeedbackDropdown)}
className="text-muted-foreground text-sm hover:text-foreground border border-border px-3 py-1.5 rounded-full hover:bg-muted transition-colors"
>
Feedback
</button>
{showFeedbackDropdown && (
<div
id="feedback-dropdown"
role="dialog"
aria-labelledby="feedback-heading"
className="absolute top-full right-0 mt-2 bg-background border border-border rounded-2xl p-3 w-96 z-50"
>
<h2
id="feedback-heading"
className="text-foreground text-base font-sans mb-2"
>
Share your feedback with us:
</h2>
<form onSubmit={handleModalSubmit} className="contents">
<label htmlFor="feedback-text" className="sr-only">
Feedback
</label>
<textarea
id="feedback-text"
value={modalFeedback}
onChange={(e) => setModalFeedback(e.target.value)}
maxLength={1000}
aria-describedby="feedback-help"
className="w-full bg-background text-foreground rounded-lg p-4 min-h-[120px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans mb-4 text-sm"
placeholder="Tell us what you think..."
/>
<div className="flex items-start justify-between gap-4">
<div className="text-muted-foreground text-xs font-sans">
<div>Have a technical issue?</div>
<div>
<NavLink
href="/team/~/support"
className="underline hover:text-foreground transition-colors"
>
Contact support
</NavLink>
.
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
type="button"
onClick={handleModalCancel}
className="bg-transparent text-foreground px-4 py-1.5 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!modalFeedback.trim()}
aria-disabled={!modalFeedback.trim()}
className="bg-primary text-primary-foreground px-4 py-1.5 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Submit
</button>
</div>
</div>
</form>
</div>
)}
</div>
</div>
);
}
@@ -1,6 +1,8 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
LogOutIcon,
MenuIcon,
MoonIcon,
@@ -12,8 +14,11 @@ import {
import Link from "next/link";
import { useTheme } from "next-themes";
import { useLayoutEffect, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { reportProductFeedback } from "@/analytics/report";
import { Button } from "@/components/ui/button";
import { NavLink } from "@/components/ui/NavLink";
import { Separator } from "@/components/ui/separator";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { useEns } from "@/hooks/contract-hooks";
@@ -36,6 +41,8 @@ export function MobileBurgerMenuButton(
},
) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showFeedbackSection, setShowFeedbackSection] = useState(false);
const [modalFeedback, setModalFeedback] = useState("");
const { setTheme, theme } = useTheme();
const ensQuery = useEns({
addressOrEnsName:
@@ -44,6 +51,29 @@ export function MobileBurgerMenuButton(
});
// const [isCMDSearchModalOpen, setIsCMDSearchModalOpen] = useState(false);
const handleModalSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
// Report feedback to PostHog
reportProductFeedback({
feedback: modalFeedback,
source: "mobile",
});
// Show success notification
toast.success("Feedback submitted successfully!", {
description: "Thank you for your feedback. We'll review it shortly.",
});
setModalFeedback("");
setShowFeedbackSection(false);
};
const handleModalCancel = () => {
setModalFeedback("");
setShowFeedbackSection(false);
};
useLayoutEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = "hidden";
@@ -71,7 +101,7 @@ export function MobileBurgerMenuButton(
</Button>
{isMenuOpen && (
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200">
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200 overflow-y-auto">
<Button
className="!h-auto absolute top-4 right-4 p-1"
onClick={() => setIsMenuOpen(false)}
@@ -191,14 +221,78 @@ export function MobileBurgerMenuButton(
Docs
</Link>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://feedback.thirdweb.com"
rel="noopener noreferrer"
target="_blank"
>
Feedback
</Link>
<div className="flex flex-col gap-3">
<button
type="button"
className="flex items-center justify-between text-muted-foreground hover:text-foreground"
onClick={() => setShowFeedbackSection(!showFeedbackSection)}
>
<span>Feedback</span>
{showFeedbackSection ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</button>
{showFeedbackSection && (
<div className="pl-0 pr-4 space-y-4 mb-6">
<h3
id="mobile-feedback-heading"
className="text-sm font-medium text-foreground mb-2"
>
Share your feedback with us:
</h3>
<form onSubmit={handleModalSubmit} className="contents">
<label htmlFor="mobile-feedback-text" className="sr-only">
Feedback
</label>
<textarea
id="mobile-feedback-text"
value={modalFeedback}
onChange={(e) => setModalFeedback(e.target.value)}
maxLength={1000}
aria-describedby="mobile-feedback-help"
className="w-full bg-background text-foreground rounded-lg p-3 min-h-[100px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans text-sm"
placeholder="Tell us what you think..."
/>
<div className="flex flex-col gap-3">
<p
id="mobile-feedback-help"
className="text-muted-foreground text-xs"
>
Have a technical issue?{" "}
<NavLink
href="/team/~/support"
className="underline hover:text-foreground transition-colors"
>
Contact support
</NavLink>
.
</p>
<div className="flex gap-3">
<button
type="button"
onClick={handleModalCancel}
className="flex-1 bg-transparent text-foreground px-3 py-2 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!modalFeedback.trim()}
aria-disabled={!modalFeedback.trim()}
className="flex-1 bg-primary text-primary-foreground px-3 py-2 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Submit
</button>
</div>
</div>
</form>
</div>
)}
</div>
</div>
<div className="h-6" />
@@ -1,5 +1,4 @@
import { ImageResponse } from "next/og";
import { useId } from "react";
import { download } from "thirdweb/storage";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
@@ -19,8 +18,8 @@ export const size = {
export const contentType = "image/png";
const TWLogo: React.FC = () => {
const cipId = useId();
const linearGradientId = useId();
const cipId = "og-tw-logo-clip-path";
const linearGradientId = "og-tw-logo-linear-gradient";
return (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
@@ -0,0 +1,452 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { type ProjectService, SERVICES } from "@thirdweb-dev/service-utils";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { reportOnboardingCompleted } from "@/analytics/report";
import type { Project } from "@/api/project/projects";
import type { CreateProjectPrefillOptions } from "@/components/project/create-project-modal";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card } from "@/components/ui/card";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { createProjectClient } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
);
export function CreateProjectFormOnboarding(props: {
prefill?: CreateProjectPrefillOptions;
enableNebulaServiceByDefault: boolean;
teamSlug: string;
teamId: string;
}) {
const [screen, setScreen] = useState<
{ id: "create" } | { id: "api-details"; project: Project; secret: string }
>({ id: "create" });
return (
<Card>
<DynamicHeight>
{screen.id === "create" && (
<CreateProjectForm
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
console.error(
"Failed to create vault account and access token",
error,
);
throw error;
});
return {
project: res.project,
secret: res.secret,
};
}}
enableNebulaServiceByDefault={props.enableNebulaServiceByDefault}
onProjectCreated={(params) => {
setScreen({
id: "api-details",
project: params.project,
secret: params.secret,
});
}}
prefill={props.prefill}
/>
)}
{screen.id === "api-details" && (
<CreatedProjectDetails
onComplete={() => {
setScreen({ id: "create" });
}}
project={screen.project}
secret={screen.secret}
teamSlug={props.teamSlug}
/>
)}
</DynamicHeight>
</Card>
);
}
const createProjectFormSchema = z.object({
domains: projectDomainsSchema,
name: projectNameSchema,
});
type CreateProjectFormSchema = z.infer<typeof createProjectFormSchema>;
function CreateProjectForm(props: {
createProject: (param: Partial<Project>) => Promise<{
project: Project;
secret: string;
}>;
prefill?: CreateProjectPrefillOptions;
enableNebulaServiceByDefault: boolean;
onProjectCreated: (params: { project: Project; secret: string }) => void;
}) {
const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">();
const createProject = useMutation({
mutationFn: props.createProject,
});
const form = useForm<CreateProjectFormSchema>({
defaultValues: {
domains: props.prefill?.domains || "",
name: props.prefill?.name || "",
},
resolver: zodResolver(createProjectFormSchema),
});
function handleAPICreation(values: { name: string; domains: string }) {
const servicesToEnableByDefault = props.enableNebulaServiceByDefault
? ALL_PROJECT_SERVICES
: ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula");
const formattedValues: Partial<Project> = {
domains: toArrFromList(values.domains),
name: values.name,
// enable all services
services: servicesToEnableByDefault.map((srv) => {
if (srv.name === "storage") {
return {
actions: srv.actions.map((sa) => sa.name),
name: srv.name,
} satisfies ProjectService;
}
if (srv.name === "pay") {
return {
actions: [],
name: "pay",
payoutAddress: null,
} satisfies ProjectService;
}
return {
actions: [],
name: srv.name,
} satisfies ProjectService;
}),
};
createProject.mutate(formattedValues, {
onError: () => {
toast.error("Failed to create a project");
},
onSuccess: (data) => {
props.onProjectCreated(data);
toast.success("Project created successfully");
},
});
}
const handleSubmit = form.handleSubmit((values) => {
if (!values.domains) {
setShowAlert("no-domain");
} else if (values.domains === "*") {
setShowAlert("any-domain");
} else {
handleAPICreation({
domains: values.domains,
name: values.name,
});
}
});
if (showAlert) {
return (
<DomainsAlert
isCreating={createProject.isPending}
onGoBack={() => setShowAlert(undefined)}
onProceed={() => {
handleAPICreation({
domains: form.getValues("domains"),
name: form.getValues("name"),
});
}}
type={showAlert}
/>
);
}
return (
<Form {...form}>
<form autoComplete="off" onSubmit={handleSubmit}>
<div className="p-6">
<header className="mb-4">
<h2 className="text-2xl">Create Project</h2>
</header>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input
className="bg-card"
placeholder="My Project"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="relative">
<CheckboxWithLabel className="absolute top-0 right-0 text-foreground">
<Checkbox
checked={form.watch("domains") === "*"}
onCheckedChange={(checked) => {
form.setValue("domains", checked ? "*" : "", {
shouldDirty: true,
});
}}
/>
Allow all domains
</CheckboxWithLabel>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>Allowed Domains</FormLabel>
<FormControl>
<Textarea
className="bg-card"
placeholder="thirdweb.com, *.example.com, localhost:3000"
{...field}
/>
</FormControl>
<FormMessage />
<div className="pt-1">
<FormDescription className="mb-2 text-sm leading-relaxed">
Prevent third-parties from using your Client ID by
restricting access to allowed domains. Highly
recommended for frontend applications.
</FormDescription>
<ul className="flex list-disc flex-col gap-2 pl-3 text-muted-foreground text-sm [&>li]:pl-1 ">
<li>
Authorize all domains with{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
*
</span>
<br />
<span>
Example:{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
*.thirdweb.com
</span>{" "}
accepts all{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
.thirdweb.com
</span>{" "}
sites
</span>
</li>
<li>
Authorize local URLs with{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
{"localhost:<port>"}
</span>
</li>
<li>Separate domains by commas or new lines</li>
</ul>
</div>
</FormItem>
)}
/>
</div>
</div>
</div>
<footer className="flex gap-4 border-border border-t p-6 md:gap-2">
<Button
className="min-w-28 gap-2 ml-auto"
disabled={createProject.isPending}
type="submit"
>
{createProject.isPending && <Spinner className="size-4" />}
Create Projecct
</Button>
</footer>
</form>
</Form>
);
}
function DomainsAlert(props: {
type: "no-domain" | "any-domain";
isCreating: boolean;
onProceed: () => void;
onGoBack: () => void;
}) {
return (
<div>
<div className="mb-4 p-6">
<header>
<h2 className="text-2xl">
{props.type === "no-domain" && "No Domains Configured"}
{props.type === "any-domain" && "Unrestricted Web Access"}
</h2>
<p>
{props.type === "no-domain" &&
"This will deny requests from all origins, rendering the key unusable in frontend applications. Proceed only if you intend to use this key in server or native apps environments."}
{props.type === "any-domain" &&
"Requests from all origins will be authorized. Your key can be used by any website without restriction"}
</p>
</header>
</div>
<footer className="!justify-between flex gap-4 border-border border-t bg-card p-6">
<Button
className="min-w-28 gap-2"
onClick={props.onGoBack}
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Update Domains
</Button>
<Button
className="min-w-28 gap-2"
disabled={props.isCreating}
onClick={props.onProceed}
type="button"
variant="destructive"
>
{props.isCreating && <Spinner className="size-4" />}
Proceed
</Button>
</footer>
</div>
);
}
function CreatedProjectDetails(props: {
project: Project;
secret: string;
onComplete: () => void;
teamSlug: string | undefined;
}) {
const [secretStored, setSecretStored] = useState(false);
const router = useDashboardRouter();
const clientId = props.project.publishableKey;
return (
<div>
<div className="p-6">
<header>
<h2 className="text-2xl">{props.project.name}</h2>
</header>
<div className="h-3" />
<section>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application.
</p>
<CopyTextButton
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
copyIconPosition="right"
textToCopy={clientId}
textToShow={clientId}
tooltip="Copy Client ID"
/>
</section>
<div className="h-6" />
<section>
<h3>Secret Key</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies and authenticates your application from a backend.
</p>
<CopyTextButton
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
copyIconPosition="right"
textToCopy={props.secret || ""}
textToShow={props.secret || ""}
tooltip="Copy Secret Key"
/>
</section>
<div className="h-3" />
<Alert variant="destructive">
<AlertTitle>Do not share or expose your secret key</AlertTitle>
<AlertDescription>
<div className="mb-5">
Secret keys cannot be recovered. If you lose your secret key, you
will need to rotate the secret key or create a new Project.
</div>
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={secretStored}
onCheckedChange={(v) => {
setSecretStored(!!v);
}}
/>
I confirm that I've securely stored my secret key
</CheckboxWithLabel>
</AlertDescription>
</Alert>
</div>
<footer className="flex border-border border-t bg-card p-6">
{props.teamSlug && (
<Button
className="min-w-28 gap-2 ml-auto"
disabled={!secretStored}
onClick={() => {
reportOnboardingCompleted();
router.push(`/team/${props.teamSlug}/${props.project.slug}`);
}}
>
View Project
<ArrowRightIcon className="size-4" />
</Button>
)}
</footer>
</div>
);
}
@@ -0,0 +1,29 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team/get-team";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { CreateProjectFormOnboarding } from "./_components/create-project-form";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team || !authToken) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={4}>
<CreateProjectFormOnboarding
enableNebulaServiceByDefault={true}
teamSlug={team.slug}
teamId={team.id}
/>
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,31 @@
"use client";
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import type { Team } from "@/api/team/get-team";
const stripePromise = loadStripe(
// biome-ignore lint/style/noNonNullAssertion: TODO: fix this later
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
export function GrowthPlanCheckout(props: {
team: Team;
fetchClientSecret: (team: Team) => Promise<string>;
}) {
return (
<div id="checkout">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{
fetchClientSecret: () => props.fetchClientSecret(props.team),
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
@@ -1,13 +1,23 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import {
fetchClientSecret,
getStripeSessionById,
} from "@/actions/stripe-actions";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team/get-team";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { pollWithTimeout } from "@/utils/pollWithTimeout";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { PlanSelector } from "./_components/plan-selector";
import { GrowthPlanCheckout } from "./_components/stripe-checkout";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
searchParams: Promise<{ session_id?: string }>;
}) {
const params = await props.params;
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
@@ -17,18 +27,47 @@ export default async function Page(props: {
notFound();
}
async function getTeam() {
"use server";
const resolvedTeam = await getTeamBySlug(params.team_slug);
if (!resolvedTeam) {
return team as Team;
// IF we have a session id, check if the session was completed successfully
let error: string | null = null;
if (searchParams.session_id) {
const session = await getStripeSessionById(searchParams.session_id);
switch (session.status) {
case "complete": {
// poll the team until it shows as "growth" plan (to handle race condition with stripe webhook)
await pollWithTimeout({
shouldStop: async () => {
const refreshedTeam = await getTeamBySlug(team.slug);
return refreshedTeam?.billingPlan === "growth";
},
timeoutMs: 5000,
});
redirect(`/get-started/team/${team.slug}/add-members`);
break;
}
case "open": {
error =
"Something went wrong when trying to process your payment method. Please try again.";
break;
}
default: {
error = "Please try again later.";
break;
}
}
return resolvedTeam;
}
return (
<TeamOnboardingLayout currentStep={2}>
<PlanSelector getTeam={getTeam} team={team} />
<div className="rounded-lg border bg-card overflow-hidden py-4 md:py-8">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to proces subscription</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<GrowthPlanCheckout team={team} fetchClientSecret={fetchClientSecret} />
</div>
</TeamOnboardingLayout>
);
}
@@ -1,6 +1,7 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { useEffect, useMemo } from "react";
@@ -24,6 +25,9 @@ export function AppRouterProviders(props: {
children: React.ReactNode;
autoConnect: boolean;
}) {
// get the current pathname
const pathname = usePathname();
return (
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
@@ -36,6 +40,9 @@ export function AppRouterProviders(props: {
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
forcedTheme={
pathname.startsWith("/get-started") ? "dark" : undefined
}
>
<Toaster richColors />
<SanctionedAddressesChecker>
@@ -103,15 +103,12 @@ export default async function TeamLayout(props: {
>
{props.children}
</TeamSidebarLayout>
<div className="fixed right-4 bottom-4 z-50">
<CustomChatButton
authToken={authToken}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
label="Get Help"
team={team}
/>
</div>
<CustomChatButton
authToken={authToken}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
team={team}
/>
</div>
</SidebarProvider>
);
@@ -1,6 +1,6 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import { ArrowUpRightIcon, InfoIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { Button } from "@/components/ui/button";
import { NebulaIcon } from "@/icons/NebulaIcon";
@@ -24,6 +24,7 @@ export function EmptyStateChatPageContent(props: {
}) {
return (
<div className="overflow-hidden py-10 lg:py-16">
<AIUsageBanner />
{props.showAurora && (
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--primary-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--primary-foreground)/10%)]" />
)}
@@ -165,3 +166,35 @@ const Aurora: React.FC<AuroraProps> = ({ className }) => {
/>
);
};
function AIUsageBanner() {
return (
<div className="absolute top-0 left-0 right-0 z-10 flex justify-center px-4 pt-4">
<div className="max-w-4xl">
<div className="relative overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-blue-50/50 p-3 shadow-sm dark:border-blue-800 dark:from-blue-950/50 dark:to-blue-950/20">
<div className="relative flex items-center gap-3 px-2">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<InfoIcon className="size-3.5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 text-sm">
<p className="text-blue-800 dark:text-blue-200">
thirdweb AI usage is billed based on number of tokens used. See
the{" "}
<a
href="https://thirdweb.com/pricing"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-700 underline decoration-blue-300 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-500 dark:text-blue-300 dark:decoration-blue-600 dark:hover:text-blue-200 dark:hover:decoration-blue-400"
>
pricing page
</a>
.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -8,6 +8,7 @@ import {
ExternalLinkIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -17,7 +18,6 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -70,6 +70,7 @@ export function ImportEngineButton(props: {
teamSlug: string;
projectSlug: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const router = useDashboardRouter();
const form = useForm<ImportEngineParams>({
resolver: zodResolver(formSchema),
@@ -82,7 +83,7 @@ export function ImportEngineButton(props: {
const importMutation = useMutation({
mutationFn: async (importParams: ImportEngineParams) => {
await importEngine({ ...importParams, teamIdOrSlug: props.teamSlug });
router.push(`/team/${props.teamSlug}/${props.projectSlug}/engine`);
router.refresh();
},
});
@@ -90,6 +91,7 @@ export function ImportEngineButton(props: {
try {
await importMutation.mutateAsync(data);
toast.success("Engine imported successfully");
setIsOpen(false);
} catch (e) {
const message = e instanceof Error ? e.message : undefined;
toast.error(
@@ -102,42 +104,40 @@ export function ImportEngineButton(props: {
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Dialog>
<DialogTrigger asChild>
<Button
className="gap-2 rounded-full bg-card"
size="sm"
variant="outline"
>
<ArrowDownToLineIcon className="size-3.5" />
Import Engine
</Button>
</DialogTrigger>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
className="gap-2 rounded-full bg-card"
size="sm"
variant="outline"
>
<ArrowDownToLineIcon className="size-3.5" />
Import Engine
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogContent className="p-0 rounded-lg overflow-hidden">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Import Engine Instance</DialogTitle>
<DialogDescription>
Import an Engine instance hosted on your infrastructure.
</DialogDescription>
</DialogHeader>
<div>
<div>
<Link
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent"
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
rel="noopener noreferrer"
target="_blank"
>
Get help setting up Engine for free
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</div>
<div className="px-4 lg:px-6 pb-6">
<Link
className="mb-4 flex items-center justify-between gap-2 rounded-full border border-border bg-card p-3 text-sm hover:bg-accent"
href="https://portal.thirdweb.com/engine/v2/get-started"
rel="noopener noreferrer"
target="_blank"
>
Get help setting up Engine for free
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
<div className="mt-6 flex flex-col gap-4">
<div className="space-y-5">
<FormField
control={form.control}
name="name"
@@ -146,6 +146,7 @@ export function ImportEngineButton(props: {
<FormLabel>Name</FormLabel>
<FormControl>
<Input
className="bg-card"
autoFocus
placeholder="Enter a descriptive label"
{...field}
@@ -164,6 +165,7 @@ export function ImportEngineButton(props: {
<FormLabel>URL</FormLabel>
<FormControl>
<Input
className="bg-card"
placeholder="Enter your Engine URL"
type="url"
{...field}
@@ -182,8 +184,8 @@ export function ImportEngineButton(props: {
</div>
</div>
<DialogFooter>
<Button className="min-w-28 gap-1.5 rounded-full" type="submit">
<div className="border-t bg-card p-6 flex justify-end">
<Button className="gap-2 rounded-full" type="submit">
{importMutation.isPending ? (
<Spinner className="size-4" />
) : (
@@ -191,10 +193,10 @@ export function ImportEngineButton(props: {
)}
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
</Form>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -77,15 +77,12 @@ export default async function ProjectLayout(props: {
{props.children}
</ProjectSidebarLayout>
</div>
<div className="fixed right-4 bottom-4 z-50">
<CustomChatButton
authToken={authToken}
clientId={project.publishableKey}
examplePrompts={siwaExamplePrompts}
label="Ask AI Assistant"
team={team}
/>
</div>
<CustomChatButton
authToken={authToken}
clientId={project.publishableKey}
examplePrompts={siwaExamplePrompts}
team={team}
/>
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
</SidebarProvider>
);
@@ -1,11 +1,8 @@
"use client";
import { useTheme } from "next-themes";
import type { Address } from "thirdweb";
import { BuyWidget, type TokenInfo } from "thirdweb/react";
import type { TokenInfo } from "thirdweb/react";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { bridgeAppThirdwebClient } from "../../constants";
export function UniversalBridgeEmbed({
chainId,
@@ -16,16 +13,16 @@ export function UniversalBridgeEmbed({
token: TokenInfo | undefined;
amount?: string;
}) {
const { theme } = useTheme();
const chain = useV5DashboardChain(chainId || 1);
return (
<BuyWidget
client={bridgeAppThirdwebClient}
amount={amount || "0"}
chain={chain}
tokenAddress={token?.address as Address | undefined}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
/>
<div className="w-full lg:w-[400px]">
<BuyAndSwapEmbed
chain={chain}
buyAmount={amount}
tokenAddress={token?.address}
pageType="bridge"
/>
</div>
);
}
@@ -0,0 +1,39 @@
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { cn } from "@/lib/utils";
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b border-dashed">
<header
className={cn(
"container flex max-w-7xl justify-between py-3",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link className="flex items-center gap-2" href="/team">
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
</span>
</Link>
</div>
<div className="flex items-center gap-5">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground"
>
Docs
</Link>
<ToggleThemeButton />
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
</div>
</header>
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export default function BridgeLayout({
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"h-screen w-screen bg-background font-sans antialiased",
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
+28 -37
View File
@@ -1,10 +1,12 @@
import { ArrowUpRightIcon } from "lucide-react";
import { cn } from "@workspace/ui/lib/utils";
import type { Metadata } from "next";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb/chains";
import { getContract } from "thirdweb/contract";
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
import { AppFooter } from "@/components/footers/app-footer";
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
import { PageHeader } from "./components/header";
import { bridgeAppThirdwebClient } from "./constants";
const title = "thirdweb Payments: Swap, Bridge, and Onramp";
@@ -49,8 +51,11 @@ export default async function BridgePage({
}
return (
<div className="relative mx-auto flex h-screen w-full flex-col items-center justify-center overflow-hidden border py-10">
<main className="container z-10 flex justify-center">
<div className="grow flex flex-col relative overflow-hidden">
<PageHeader />
<div className="flex grow flex-col items-center justify-center py-36 px-4 min-h-dvh relative overflow-hidden">
<DotsBackgroundPattern />
<UniversalBridgeEmbed
amount={amount as string}
chainId={chainId ? Number(chainId) : undefined}
@@ -64,42 +69,28 @@ export default async function BridgePage({
: undefined
}
/>
</main>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
src="/assets/login/background.svg"
/>
<div className="absolute inset-x-0 bottom-8 z-20">
<div className="container mx-auto px-4">
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with thirdweb Payments
</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and
chain.
</p>
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/payments"
rel="noopener noreferrer"
target="_blank"
>
Learn More
<ArrowUpRightIcon className="size-4" />
</a>
</div>
</div>
</div>
<div className="bg-background relative z-10">
<AppFooter />
</div>
</div>
);
}
function DotsBackgroundPattern(props: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
props.className,
)}
style={{
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
backgroundSize: "24px 24px",
maskImage:
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
}}
/>
);
}
@@ -3,6 +3,7 @@ import {
BoxIcon,
LogOutIcon,
MailIcon,
PenIcon,
RocketIcon,
UserIcon,
UsersIcon,
@@ -73,33 +74,39 @@ export function AccountOnboardingLayout(props: {
const teamOnboardingSteps: OnboardingStep[] = [
{
description: "Provide team details",
icon: BoxIcon,
icon: PenIcon,
number: 1,
title: "Team Details",
},
{
description: "Choose a plan that fits your needs",
description: "Activate your 14 day free trial",
icon: RocketIcon,
number: 2,
title: "Plan Selection",
title: "Payment Details",
},
{
description: "Invite members to your team",
description: "Invite your team mates",
icon: UsersIcon,
number: 3,
title: "Team Members",
},
{
description: "Create your first project",
icon: BoxIcon,
number: 4,
title: "Create Project",
},
];
export function TeamOnboardingLayout(props: {
children: React.ReactNode;
currentStep: 1 | 2 | 3;
currentStep: 1 | 2 | 3 | 4;
}) {
return (
<OnboardingLayout
currentStep={props.currentStep}
steps={teamOnboardingSteps}
title="Setup your team"
title="Get started with thirdweb"
>
{props.children}
</OnboardingLayout>
@@ -5,8 +5,8 @@ import { upload } from "thirdweb/storage";
import { apiServerProxy } from "@/actions/proxies";
import { sendTeamInvites } from "@/actions/team/sendTeamInvite";
import {
reportOnboardingCompleted,
reportOnboardingStarted,
reportTeamMemberStepCompleted,
} from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import { useEffectOnce } from "@/hooks/useEffectOnce";
@@ -123,8 +123,8 @@ export function InviteTeamMembers(props: {
}}
onComplete={() => {
// at this point the team onboarding is complete
reportOnboardingCompleted();
router.replace(`/team/${props.team.slug}`);
reportTeamMemberStepCompleted();
router.push(`/get-started/team/${props.team.slug}/create-project`);
}}
team={props.team}
/>
@@ -23,7 +23,7 @@ export function EmptyStateChatPageContent(props: {
onLoginClick: undefined | (() => void);
}) {
return (
<div className="overflow-hidden py-10 lg:py-16">
<div className="relative overflow-hidden py-10 lg:py-16">
{props.showAurora && (
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--nebula-pink-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--nebula-pink-foreground)/10%)]" />
)}
@@ -81,6 +81,38 @@ export function NebulaLoggedOutStatePage(props: {
</div>
</header>
{/* Deprecation banner */}
<div className="bg-destructive p-6">
<div className="container text-md text-destructive-foreground">
<h4 className="font-bold text-lg">
Nebula is now{" "}
<Link
className="underline text-destructive-foreground hover:text-destructive-foreground/80"
href="https://thirdweb.com/ai"
target="_blank"
>
thirdweb AI.
</Link>{" "}
This site will be deprecated on September 30, 2025.
</h4>
<p>
To continue using thirdweb AI, please sign in to the{" "}
<Link
className="underline text-destructive-foreground hover:text-destructive-foreground/80"
href="https://thirdweb.com/login"
target="_blank"
>
thirdweb dashboard
</Link>{" "}
and create a new project.
</p>
<p>
If you have any funds left in your Nebula account, please withdraw
them before September 30, 2025.
</p>
</div>
</div>
{showPage === "connect" && (
<NebulaLoginPage redirectPath={`/?${redirectPathParams}`} />
)}
+2 -2
View File
@@ -148,12 +148,12 @@ const nextConfig = {
permanent: false,
},
{
source: "/insight",
source: "/insight/:path*",
destination: "https://insight.thirdweb.com/reference",
permanent: false,
},
{
source: "/payments/backend",
source: "/payments/backend/:path*",
destination: "/reference#tag/payments",
permanent: false,
},
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
// Allow streaming responses up to 5 minutes
export const maxDuration = 300;
export async function GET(_req: Request) {
return NextResponse.json({
success: true,
message: "Congratulations! You have accessed the protected route.",
});
}
@@ -0,0 +1,85 @@
import { lazy, Suspense } from "react";
import { LoadingDots } from "@/components/ui/LoadingDots";
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
import type { SwapWidgetPlaygroundOptions } 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: SwapWidgetPlaygroundOptions }) {
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient className="grow" code={getCode(props.options)} lang="ts" />
</Suspense>
</div>
);
}
function getCode(options: SwapWidgetPlaygroundOptions) {
const imports = {
thirdweb: ["createThirdwebClient"] as string[],
"thirdweb/react": ["SwapWidget"] 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");
}
}
const props: Record<string, string | undefined | boolean> = {
theme: themeProp,
prefill:
options.prefill?.buyToken || options.prefill?.sellToken
? JSON.stringify(options.prefill, null, 2)
: undefined,
currency:
options.currency !== "USD" && options.currency
? quotes(options.currency)
: undefined,
showThirdwebBranding:
options.showThirdwebBranding === false ? false : undefined,
client: "client",
};
return `\
${stringifyImports(imports)}
const client = createThirdwebClient({
clientId: "....",
});
function Example() {
return (
<SwapWidget ${stringifyProps(props)} />
);
}`;
}
@@ -0,0 +1,226 @@
"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 { SwapWidgetPlaygroundOptions } from "./types";
export function LeftSection(props: {
options: SwapWidgetPlaygroundOptions;
setOptions: React.Dispatch<React.SetStateAction<SwapWidgetPlaygroundOptions>>;
}) {
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 */}
<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: SwapWidgetPlaygroundOptions;
setOptions: React.Dispatch<React.SetStateAction<SwapWidgetPlaygroundOptions>>;
}) {
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,118 @@
"use client";
import { useState } from "react";
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 { CodeGen } from "./code";
import type { SwapWidgetPlaygroundOptions } from "./types";
type Tab = "ui" | "code";
export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
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: "UI",
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",
)}
>
<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 === "code" && <CodeGen options={props.options} />}
</div>
</div>
);
}
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;
isActive: boolean;
onClick: () => void;
}>;
}) {
return (
<div>
<div className="flex justify-start gap-1 rounded-lg border bg-card p-2 shadow-md md:inline-flex">
{props.tabs.map((tab) => (
<Button
className={cn(
"gap-2 px-4 text-base",
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,45 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { LeftSection } from "./left-section";
import { RightSection } from "./right-section";
import type { SwapWidgetPlaygroundOptions } from "./types";
const defaultOptions: SwapWidgetPlaygroundOptions = {
prefill: undefined,
currency: "USD",
showThirdwebBranding: true,
theme: {
darkColorOverrides: {},
lightColorOverrides: {},
type: "dark",
},
};
export function SwapWidgetPlayground() {
const { theme } = useTheme();
const [options, setOptions] =
useState<SwapWidgetPlaygroundOptions>(defaultOptions);
// change theme on global theme change
useEffect(() => {
setOptions((prev) => ({
...prev,
theme: {
...prev.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
}, [theme]);
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>
<RightSection options={options} />
</div>
);
}
@@ -0,0 +1,12 @@
import type { SwapWidgetProps, ThemeOverrides } from "thirdweb/react";
export type SwapWidgetPlaygroundOptions = {
theme: {
type: "dark" | "light";
darkColorOverrides: ThemeOverrides["colors"];
lightColorOverrides: ThemeOverrides["colors"];
};
currency?: SwapWidgetProps["currency"];
prefill?: SwapWidgetProps["prefill"];
showThirdwebBranding?: SwapWidgetProps["showThirdwebBranding"];
};
@@ -0,0 +1,35 @@
import { BringToFrontIcon } from "lucide-react";
import { PageLayout } from "@/components/blocks/APIHeader";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { SwapWidgetPlayground } from "./components/swap-widget-playground";
const title = "Swap Widget Component";
const description =
"Embeddable component for users to swap any cryptocurrency with cross-chain support";
const ogDescription =
"Configure a component to swap cryptocurrency with specified amounts, customization, and more. This interactive playground shows how to customize the component.";
export const metadata = createMetadata({
description: ogDescription,
title,
image: {
icon: "payments",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={BringToFrontIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/references/typescript/v5/SwapWidget?utm_source=playground"
>
<SwapWidgetPlayground />
</PageLayout>
</ThirdwebProvider>
);
}
@@ -3,6 +3,8 @@ import {
BlocksIcon,
BotIcon,
BoxIcon,
BringToFrontIcon,
CircleDollarSignIcon,
CircleUserIcon,
CreditCardIcon,
DollarSignIcon,
@@ -185,6 +187,13 @@ export const paymentsFeatureCards: FeatureCardMetadata[] = [
description:
"Enable users to pay for onchain transactions with fiat or crypto",
},
{
icon: CircleDollarSignIcon,
title: "x402",
link: "/payments/x402",
description:
"Use the x402 payment protocol to pay for API calls using any web3 wallet",
},
];
export const accountAbstractionsFeatureCards: FeatureCardMetadata[] = [
@@ -247,3 +256,12 @@ export const aiFeatureCards: FeatureCardMetadata[] = [
description: "Use the thirdweb blockchain models with the Vercel AI SDK",
},
];
export const bridgeFeatureCards: FeatureCardMetadata[] = [
{
icon: BringToFrontIcon,
title: "Swap Widget",
link: "/bridge/swap-widget",
description: "A widget for swapping tokens with cross-chain support",
},
];
+24 -1
View File
@@ -1,6 +1,11 @@
"use client";
import { ArrowLeftRightIcon, BotIcon, Code2Icon } from "lucide-react";
import {
ArrowLeftRightIcon,
BotIcon,
BringToFrontIcon,
Code2Icon,
} from "lucide-react";
import type { ShadcnSidebarLink } from "@/components/blocks/full-width-sidebar-layout";
import { ContractIcon } from "../icons/ContractIcon";
import { PayIcon } from "../icons/PayIcon";
@@ -201,6 +206,23 @@ const payments: ShadcnSidebarLink = {
href: "/payments/transactions",
label: "Onchain Transaction",
},
{
href: "/payments/x402",
label: "x402",
},
],
};
const bridge: ShadcnSidebarLink = {
subMenu: {
label: "Bridge",
icon: BringToFrontIcon,
},
links: [
{
href: "/bridge/swap-widget",
label: "Swap Widget",
},
],
};
@@ -240,6 +262,7 @@ export const sidebarLinks: ShadcnSidebarLink[] = [
transactions,
contracts,
payments,
bridge,
tokens,
accountAbstractions,
{
+2
View File
@@ -3,6 +3,7 @@ import { ThirdwebIcon } from "../icons/ThirdwebMiniLogo";
import {
accountAbstractionsFeatureCards,
aiFeatureCards,
bridgeFeatureCards,
contractsFeatureCards,
type FeatureCardMetadata,
paymentsFeatureCards,
@@ -47,6 +48,7 @@ export default function Page() {
title="Contracts"
/>
<FeatureSection featureCards={paymentsFeatureCards} title="Payments" />
<FeatureSection featureCards={bridgeFeatureCards} title="Bridge" />
<FeatureSection
featureCards={accountAbstractionsFeatureCards}
title="Account Abstraction"
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
sellerAddress: "0x0000000000000000000000000000000000000000",
title: "",
transactionData: "",
widget: "checkout",
currency: "USD",
showThirdwebBranding: true,
},
@@ -37,12 +36,12 @@ export function CheckoutPlayground() {
<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
lockedWidget="checkout"
widget="checkout"
options={options}
setOptions={setOptions}
/>
</div>
<RightSection lockedWidget="checkout" options={options} />
<RightSection widget="checkout" options={options} />
</div>
);
}
@@ -1,5 +1,10 @@
import { lazy, Suspense } from "react";
import { LoadingDots } from "../../../components/ui/LoadingDots";
import { LoadingDots } from "@/components/ui/LoadingDots";
import {
quotes,
stringifyImports,
stringifyProps,
} from "../../../lib/code-gen";
import type { BridgeComponentsPlaygroundOptions } from "./types";
const CodeClient = lazy(() =>
@@ -16,58 +21,114 @@ function CodeLoading() {
);
}
export function CodeGen(props: { options: BridgeComponentsPlaygroundOptions }) {
export function CodeGen(props: {
widget: "buy" | "checkout" | "transaction";
options: BridgeComponentsPlaygroundOptions;
}) {
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient className="grow" code={getCode(props.options)} lang="tsx" />
<CodeClient
className="grow"
code={getCode(props.widget, props.options)}
lang="tsx"
/>
</Suspense>
</div>
);
}
function getCode(options: BridgeComponentsPlaygroundOptions) {
function getCode(
widget: "buy" | "checkout" | "transaction",
options: BridgeComponentsPlaygroundOptions,
) {
const componentName =
widget === "buy"
? "BuyWidget"
: widget === "checkout"
? "CheckoutWidget"
: widget === "transaction"
? "TransactionWidget"
: "";
const imports = {
chains: [] as string[],
react: ["PayEmbed"] as string[],
thirdweb: [] as string[],
wallets: [] as string[],
"thirdweb/chains": [] as string[],
"thirdweb/react": [componentName] as string[],
thirdweb: ["createThirdwebClient", "defineChain"] as string[],
"thirdweb/wallets": ["createWallet"] as string[],
};
// Check if we have a custom chain (not base chain which has id 8453)
const isCustomChain =
options.payOptions.buyTokenChain &&
options.payOptions.buyTokenChain.id !== 8453;
if (isCustomChain) {
// Add defineChain to imports if using a custom chain
imports.thirdweb.push("defineChain");
} else {
// Otherwise use the base chain
imports.chains.push("base");
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");
}
imports.wallets.push("createWallet");
const componentName = (() => {
switch (options.payOptions.widget) {
case "buy":
return "BuyWidget";
case "checkout":
return "CheckoutWidget";
case "transaction":
return "TransactionWidget";
default:
return "PayEmbed";
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");
}
})();
imports.react.push(componentName);
imports.chains.push("defineChain");
}
const transaction =
widget === "transaction"
? `claimTo({
contract: nftContract,
quantity: 1n,
to: account?.address || "",
tokenId: 2n,
})`
: undefined;
const props: Record<string, string | undefined | boolean> = {
theme: themeProp,
client: "client",
description: options.payOptions.description
? quotes(options.payOptions.description)
: undefined,
image: options.payOptions.image
? quotes(options.payOptions.image)
: undefined,
name: options.payOptions.title
? quotes(options.payOptions.title)
: undefined,
paymentMethods:
options.payOptions.paymentMethods.length === 2
? undefined
: JSON.stringify(options.payOptions.paymentMethods),
currency: options.payOptions.currency
? quotes(options.payOptions.currency)
: undefined,
chain: `defineChain(${options.payOptions.buyTokenChain.id})`,
showThirdwebBranding:
options.payOptions.showThirdwebBranding === false ? false : undefined,
amount: options.payOptions.buyTokenAmount
? quotes(options.payOptions.buyTokenAmount)
: undefined,
tokenAddress: options.payOptions.buyTokenAddress
? quotes(options.payOptions.buyTokenAddress)
: undefined,
seller: options.payOptions.sellerAddress
? quotes(options.payOptions.sellerAddress)
: undefined,
buttonLabel: options.payOptions.buttonLabel
? quotes(options.payOptions.buttonLabel)
: undefined,
transaction: transaction,
};
return `\
import { createThirdwebClient } from "thirdweb";
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
${imports.thirdweb.length > 0 ? `import { ${imports.thirdweb.join(", ")} } from "thirdweb";` : ""}
${stringifyImports(imports)}
const client = createThirdwebClient({
clientId: "....",
@@ -75,20 +136,7 @@ const client = createThirdwebClient({
function Example() {
return (
<${componentName}
client={client}
chain={defineChain(${options.payOptions.buyTokenChain.id})}
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.buttonLabel ? `\n\t buttonLabel="${options.payOptions.buttonLabel}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
options.payOptions.widget === "transaction"
? `\n\t transaction={claimTo({
contract: nftContract,
quantity: 1n,
tokenId: 2n,
to: account?.address || "",
})}`
: ""
}
/>
<${componentName} ${stringifyProps(props)} />
);
}`;
}
@@ -1,32 +1,19 @@
"use client";
import {
CreditCardIcon,
ExternalLinkIcon,
FuelIcon,
PaletteIcon,
Settings2Icon,
} from "lucide-react";
import Link from "next/link";
import { CreditCardIcon, PaletteIcon, Settings2Icon } from "lucide-react";
import type React from "react";
import { useId, useState } from "react";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb/chains";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import type { TokenMetadata } from "@/lib/types";
import { CurrencySelector } from "../../../components/blocks/CurrencySelector";
import { CollapsibleSection } from "../../wallets/sign-in/components/CollapsibleSection";
import { ColorFormGroup } from "../../wallets/sign-in/components/ColorFormGroup";
import type { BridgeComponentsPlaygroundOptions } from "./types";
@@ -36,11 +23,10 @@ export function LeftSection(props: {
setOptions: React.Dispatch<
React.SetStateAction<BridgeComponentsPlaygroundOptions>
>;
lockedWidget?: "buy" | "checkout" | "transaction";
widget: "buy" | "checkout" | "transaction";
}) {
const { options, setOptions } = props;
const { theme, payOptions } = options;
const effectiveWidget = props.lockedWidget || payOptions.widget || "buy";
const setThemeType = (themeType: "dark" | "light") => {
setOptions((v) => ({
...v,
@@ -68,7 +54,7 @@ export function LeftSection(props: {
return undefined;
});
const payModeId = useId();
const _payModeId = useId();
const buyTokenAmountId = useId();
const sellerAddressId = useId();
const paymentAmountId = useId();
@@ -119,87 +105,30 @@ export function LeftSection(props: {
title="Payment Options"
>
<div className="flex flex-col gap-6 pt-5">
{props.lockedWidget === undefined && (
<section className="flex flex-col gap-3">
<Label htmlFor={payModeId}>Widget</Label>
<CustomRadioGroup
id={payModeId}
onValueChange={(value) => {
setOptions(
(v) =>
({
...v,
payOptions: {
...v.payOptions,
widget: value as "buy" | "checkout" | "transaction",
},
}) satisfies BridgeComponentsPlaygroundOptions,
);
}}
options={[
{ label: "Buy", value: "buy" },
{ label: "Checkout", value: "checkout" },
{ label: "Transaction", value: "transaction" },
]}
value={payOptions.widget || "buy"}
/>
</section>
)}
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<Select
value={payOptions.currency || "USD"}
onValueChange={(value) => {
<CurrencySelector
value={payOptions.currency}
onChange={(currency) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
currency: value,
currency: currency,
},
}));
}}
>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
/>
</section>
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
{(effectiveWidget === "buy" || effectiveWidget === "checkout") && (
{(props.widget === "buy" || props.widget === "checkout") && (
<div className="space-y-4">
{/* Chain selection */}
<div className="flex flex-col gap-2">
<Label>Chain</Label>
<SingleNetworkSelector
<BridgeNetworkSelector
chainId={selectedChain}
disableTestnets={true}
onChange={handleChainChange}
placeholder="Select a chain"
className="bg-card"
@@ -211,7 +140,6 @@ export function LeftSection(props: {
<div className="flex flex-col gap-2">
<Label>Token</Label>
<TokenSelector
addNativeTokenIfMissing={true}
chainId={selectedChain}
client={THIRDWEB_CLIENT}
enabled={true}
@@ -228,7 +156,7 @@ export function LeftSection(props: {
{/* Mode-specific form fields */}
<div className="my-2">
{/* Buy Mode - Amount and Payment Methods */}
{effectiveWidget === "buy" && (
{props.widget === "buy" && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label htmlFor={buyTokenAmountId}>Amount</Label>
@@ -309,7 +237,7 @@ export function LeftSection(props: {
)}
{/* Checkout Mode - Seller Address, Price and Payment Methods */}
{effectiveWidget === "checkout" && (
{props.widget === "checkout" && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label htmlFor={sellerAddressId}>Seller Address</Label>
@@ -411,7 +339,7 @@ export function LeftSection(props: {
)}
{/* Transaction Mode Options */}
{effectiveWidget === "transaction" && (
{props.widget === "transaction" && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label>Transaction</Label>
@@ -559,26 +487,6 @@ export function LeftSection(props: {
<Label htmlFor={"branding"}>Show Branding</Label>
</div>
</CollapsibleSection>
<CollapsibleSection icon={FuelIcon} title="Sponsor gas fees">
<div className="mt-4 flex items-start gap-6">
<div className="flex flex-col gap-2">
<p className="">
Abstract away gas fees for users of your app by enabling ERC-4337
Account Abstraction
</p>
<Link
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
href="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
target="_blank"
>
Learn more about Account Abstraction
<ExternalLinkIcon className="size-4" />
</Link>
</div>
</div>
</CollapsibleSection>
</div>
);
}
@@ -29,7 +29,7 @@ type Tab = "ui" | "code";
export function RightSection(props: {
options: BridgeComponentsPlaygroundOptions;
tab?: string;
lockedWidget?: "buy" | "checkout" | "transaction";
widget: "buy" | "checkout" | "transaction";
}) {
const pathname = usePathname();
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
@@ -52,10 +52,8 @@ export function RightSection(props: {
colors: props.options.theme.lightColorOverrides,
});
const effectiveWidget = props.lockedWidget || props.options.payOptions.widget;
let embed: React.ReactNode;
if (effectiveWidget === "buy") {
if (props.widget === "buy") {
embed = (
<BuyWidget
amount={props.options.payOptions.buyTokenAmount}
@@ -76,7 +74,7 @@ export function RightSection(props: {
);
}
if (effectiveWidget === "checkout") {
if (props.widget === "checkout") {
embed = (
<CheckoutWidget
amount={props.options.payOptions.buyTokenAmount}
@@ -104,7 +102,7 @@ export function RightSection(props: {
);
}
if (effectiveWidget === "transaction") {
if (props.widget === "transaction") {
embed = (
<TransactionWidget
client={THIRDWEB_CLIENT}
@@ -155,7 +153,9 @@ export function RightSection(props: {
{previewTab === "ui" && embed}
{previewTab === "code" && <CodeGen options={props.options} />}
{previewTab === "code" && (
<CodeGen widget={props.widget} options={props.options} />
)}
</div>
</div>
);
@@ -28,7 +28,7 @@ const CURRENCIES = [
"ISK",
] as const;
type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
type SupportedFiatCurrency = (typeof CURRENCIES)[number];
export type BridgeComponentsPlaygroundOptions = {
theme: {
@@ -37,7 +37,6 @@ export type BridgeComponentsPlaygroundOptions = {
lightColorOverrides: ThemeOverrides["colors"];
};
payOptions: {
widget?: "buy" | "checkout" | "transaction";
title: string | undefined;
image: string | undefined;
description: string | undefined;
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
sellerAddress: "0x0000000000000000000000000000000000000000",
title: "",
transactionData: "",
widget: "buy",
currency: "USD",
showThirdwebBranding: true,
},
@@ -36,13 +35,9 @@ export function BuyPlayground() {
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
lockedWidget="buy"
options={options}
setOptions={setOptions}
/>
<LeftSection widget="buy" options={options} setOptions={setOptions} />
</div>
<RightSection lockedWidget="buy" options={options} />
<RightSection widget="buy" options={options} />
</div>
);
}
@@ -1,19 +1,14 @@
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { PayIcon } from "../../icons/PayIcon";
import { PayIcon } from "@/icons/PayIcon";
import { paymentsFeatureCards } from "../data/pages-metadata";
export default function Page() {
return (
<OverviewPage
icon={PayIcon}
title="Payments"
description={
<>
Allow developers and users to receive and spend any token on any EVM
chain
</>
}
description="thirdweb Payments allow developers to create advanced payment flows to monetize their apps through product sales, peer to peer payments, token sales, and more"
featureCards={paymentsFeatureCards}
icon={PayIcon}
/>
);
}
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
sellerAddress: "0x0000000000000000000000000000000000000000",
title: "",
transactionData: "",
widget: "transaction",
currency: "USD",
showThirdwebBranding: true,
},
@@ -37,12 +36,12 @@ export function TransactionPlayground() {
<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
lockedWidget="transaction"
widget="transaction"
options={options}
setOptions={setOptions}
/>
</div>
<RightSection lockedWidget="transaction" options={options} />
<RightSection widget="transaction" options={options} />
</div>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { CodeClient } from "@workspace/ui/components/code/code.client";
import { CodeIcon, LockIcon } from "lucide-react";
import { arbitrumSepolia } from "thirdweb/chains";
import {
ConnectButton,
getDefaultToken,
useActiveAccount,
useActiveWallet,
} from "thirdweb/react";
import { wrapFetchWithPayment } from "thirdweb/x402";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { THIRDWEB_CLIENT } from "../../../../lib/client";
const chain = arbitrumSepolia;
const token = getDefaultToken(chain, "USDC");
export function X402ClientPreview() {
const activeWallet = useActiveWallet();
const activeAccount = useActiveAccount();
const paidApiCall = useMutation({
mutationFn: async () => {
if (!activeWallet) {
throw new Error("No active wallet");
}
const fetchWithPay = wrapFetchWithPayment(
fetch,
THIRDWEB_CLIENT,
activeWallet,
);
const response = await fetchWithPay("/api/paywall");
return response.json();
},
});
const handlePayClick = async () => {
paidApiCall.mutate();
};
return (
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={chain}
detailsButton={{
displayBalanceToken: {
[chain.id]: token!.address,
},
}}
supportedTokens={{
[chain.id]: [token!],
}}
/>
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<LockIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">Paid API Call</span>
<span className="text-xl font-bold text-red-600">$0.01</span>
</div>
<Button
onClick={handlePayClick}
className="w-full mb-4"
size="lg"
disabled={paidApiCall.isPending || !activeAccount}
>
Pay Now
</Button>
<p className="text-sm text-muted-foreground">
{" "}
<a
className="underline"
href={"https://faucet.circle.com/"}
target="_blank"
rel="noopener noreferrer"
>
Click here to get USDC on {chain.name}
</a>
</p>
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-2">
<CodeIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">API Call Response</span>
</div>
{paidApiCall.isPending && <div className="text-center">Loading...</div>}
{paidApiCall.isError && (
<div className="text-center">Error: {paidApiCall.error.message}</div>
)}
{paidApiCall.data && (
<CodeClient
code={JSON.stringify(paidApiCall.data, null, 2)}
lang="json"
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,140 @@
import { CodeServer } from "@workspace/ui/components/code/code.server";
import { CircleDollarSignIcon, Code2Icon } from "lucide-react";
import { CodeExample, TabName } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
import { X402ClientPreview } from "./components/x402-client-preview";
const title = "x402 Payments";
const description =
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
const ogDescription =
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "payments",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={CircleDollarSignIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/payments/x402?utm_source=playground"
>
<X402Example />
<div className="h-8" />
<ServerCodeExample />
</PageLayout>
</ThirdwebProvider>
);
}
function ServerCodeExample() {
return (
<>
<div className="mb-4">
<h2 className="font-semibold text-xl tracking-tight">
Next.js Server Code Example
</h2>
<p className="max-w-4xl text-muted-foreground text-balance text-sm md:text-base">
Create middleware with the thirdweb facilitator to settle transactions
with your server wallet.
</p>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<div className="flex grow flex-col border-b md:border-r md:border-b-0">
<TabName icon={Code2Icon} name="Server Code" />
<CodeServer
className="h-full rounded-none border-none"
code={`// src/middleware.ts
import { facilitator, settlePayment } from "thirdweb/x402";
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({ secretKey: "your-secret-key" });
const thirdwebX402Facilitator = facilitator({
client,
serverWalletAddress: "0xYourWalletAddress",
});
export async function middleware(request: NextRequest) {
const method = request.method.toUpperCase();
const resourceUrl = request.nextUrl.toString();
const paymentData = request.headers.get("X-PAYMENT");
const result = await settlePayment({
resourceUrl,
method,
paymentData,
payTo: "0xYourWalletAddress",
network: "eip155:11155111", // or any other chain id
price: "$0.01", // can also be a ERC20 token amount
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// payment successful, execute the request
return NextResponse.next();
}
// otherwise, request payment
return NextResponse.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
// Configure which paths the middleware should run on
export const config = {
matcher: ["/api/paid-endpoint"],
};
`}
lang="tsx"
/>
</div>
</div>
</>
);
}
function X402Example() {
return (
<CodeExample
header={{
title: "Client Code Example",
description:
"Wrap your fetch requests with the `wrapFetchWithPayment` function to enable x402 payments.",
}}
code={`import { createThirdwebClient } from "thirdweb";
import { wrapFetchWithPayment } from "thirdweb/x402";
import { useActiveWallet } from "thirdweb/react";
const client = createThirdwebClient({ clientId: "your-client-id" });
export default function Page() {
const wallet = useActiveWallet();
const onClick = async () => {
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
const response = await fetchWithPay('/api/paid-endpoint');
}
return (
<Button onClick={onClick}>Pay Now</Button>
);
}`}
lang="tsx"
preview={<X402ClientPreview />}
/>
);
}
@@ -59,13 +59,7 @@ export function ConnectButtonPage(props: {
}) {
const { theme } = useTheme();
const [connectOptions, setConnectOptions] =
useState<ConnectPlaygroundOptions>(() => ({
...defaultConnectOptions,
theme: {
...defaultConnectOptions.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
useState<ConnectPlaygroundOptions>(defaultConnectOptions);
// change theme on global theme change
useEffect(() => {
@@ -1,4 +1,10 @@
import { lazy, Suspense } from "react";
import {
quotes,
stringifyIgnoreFalsy,
stringifyImports,
stringifyProps,
} from "@/lib/code-gen";
import { LoadingDots } from "../../../../components/ui/LoadingDots";
import type { ConnectPlaygroundOptions } from "./types";
@@ -34,10 +40,10 @@ export function CodeGen(props: { connectOptions: ConnectPlaygroundOptions }) {
function getCode(connectOptions: ConnectPlaygroundOptions) {
const walletCodes: string[] = [];
const imports = {
chains: [] as string[],
react: [] as string[],
thirdweb: [] as string[],
wallets: [] as string[],
"thirdweb/chains": [] as string[],
"thirdweb/react": ["ConnectButton"] as string[],
thirdweb: ["createThirdwebClient"] as string[],
"thirdweb/wallets": [] as string[],
};
if (
@@ -49,7 +55,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
options: ${JSON.stringify(connectOptions.inAppWallet.methods)},
},
})`);
imports.wallets.push("inAppWallet");
imports["thirdweb/wallets"].push("inAppWallet");
}
for (const wallet of connectOptions.walletIds) {
@@ -57,7 +63,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
}
if (connectOptions.walletIds.length > 0) {
imports.wallets.push("createWallet");
imports["thirdweb/wallets"].push("createWallet");
}
let themeProp: string | undefined;
@@ -68,7 +74,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
themeProp = `darkTheme({
colors: ${JSON.stringify(connectOptions.theme.darkColorOverrides)},
})`;
imports.react.push("darkTheme");
imports["thirdweb/react"].push("darkTheme");
}
if (connectOptions.theme.type === "light") {
@@ -78,14 +84,14 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
themeProp = `lightTheme({
colors: ${JSON.stringify(connectOptions.theme.lightColorOverrides)},
})`;
imports.react.push("lightTheme");
imports["thirdweb/react"].push("lightTheme");
} else {
themeProp = quotes("light");
}
}
if (connectOptions.enableAccountAbstraction) {
imports.chains.push("ethereum");
imports["thirdweb/chains"].push("ethereum");
}
const props: Record<string, string | undefined> = {
@@ -118,11 +124,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
};
return `\
import { createThirdwebClient } from "thirdweb";
import { ConnectButton } from "thirdweb/react";
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
${imports.wallets.length > 0 ? `import { ${imports.wallets.join(", ")} } from "thirdweb/wallets";` : ""}
${imports.chains.length > 0 ? `import { ${imports.chains.join(", ")} } from "thirdweb/chains";` : ""}
${stringifyImports(imports)}
const client = createThirdwebClient({
clientId: "....",
@@ -160,35 +162,3 @@ const accountAbstractCode = `\
sponsorGas: true
}
`;
function stringifyIgnoreFalsy(
value: Record<string, string | undefined | boolean>,
) {
const _value: Record<string, string | boolean> = {};
for (const key in value) {
if (value[key] !== undefined && value[key] !== "") {
_value[key] = value[key];
}
}
return JSON.stringify(_value);
}
function stringifyProps(props: Record<string, string | undefined | boolean>) {
const _props: Record<string, string | undefined | boolean> = {};
for (const key in props) {
if (props[key] !== undefined && props[key] !== "") {
_props[key] = props[key];
}
}
return Object.entries(_props)
.map(([key, value]) => `${key}={${value}}`)
.join("\n");
}
function quotes(value: string) {
return `"${value}"`;
}
@@ -0,0 +1,53 @@
"use client";
import type { SwapWidgetProps } from "thirdweb/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function CurrencySelector(props: {
value: SwapWidgetProps["currency"];
onChange: (value: SwapWidgetProps["currency"]) => void;
}) {
return (
<Select
value={props.value}
onValueChange={(value) => {
props.onChange(value as SwapWidgetProps["currency"]);
}}
>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
);
}
@@ -2,10 +2,8 @@
import { useCallback, useMemo } from "react";
import { ChainIcon } from "@/components/blocks/ChainIcon";
import { MultiSelect } from "@/components/blocks/multi-select";
import { Badge } from "@/components/ui/badge";
import { SelectWithSearch } from "@/components/ui/select-with-search";
import { useAllChainsData } from "@/hooks/chains";
import { useBridgeSupportedChains } from "@/hooks/chains";
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
@@ -13,164 +11,31 @@ function cleanChainName(chainName: string) {
type Option = { label: string; value: string };
export function MultiNetworkSelector(props: {
selectedChainIds: number[];
onChange: (chainIds: number[]) => void;
disableChainId?: boolean;
className?: string;
priorityChains?: number[];
popoverContentClassName?: string;
chainIds?: number[];
selectedBadgeClassName?: string;
}) {
const { allChains, idToChain } = useAllChainsData().data;
const chainsToShow = useMemo(() => {
if (!props.chainIds) {
return allChains;
}
const chainIdSet = new Set(props.chainIds);
return allChains.filter((chain) => chainIdSet.has(chain.chainId));
}, [allChains, props.chainIds]);
const options = useMemo(() => {
let sortedChains = chainsToShow;
if (props.priorityChains) {
const priorityChainsSet = new Set();
for (const chainId of props.priorityChains || []) {
priorityChainsSet.add(chainId);
}
const priorityChains = (props.priorityChains || [])
.map((chainId) => {
return idToChain.get(chainId);
})
.filter((v) => !!v);
const otherChains = chainsToShow.filter(
(chain) => !priorityChainsSet.has(chain.chainId),
);
sortedChains = [...priorityChains, ...otherChains];
}
return sortedChains.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [chainsToShow, props.priorityChains, idToChain]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[idToChain],
);
const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return option.label;
}
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge className="gap-2" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
);
return (
<MultiSelect
className={props.className}
disabled={allChains.length === 0}
onSelectedValuesChange={(chainIds) => {
props.onChange(chainIds.map(Number));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={
allChains.length === 0 ? "Loading Chains..." : "Select Chains"
}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain Id"
selectedBadgeClassName={props.selectedBadgeClassName}
selectedValues={props.selectedChainIds.map(String)}
/>
);
}
export function SingleNetworkSelector(props: {
export function BridgeNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
disableTestnets?: boolean;
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData().data;
const chainsToShow = useMemo(() => {
let chains = allChains;
if (props.disableTestnets) {
chains = chains.filter((chain) => !chain.testnet);
}
if (props.chainIds) {
const chainIdSet = new Set(props.chainIds);
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
}
return chains;
}, [allChains, props.chainIds, props.disableTestnets]);
const chainsQuery = useBridgeSupportedChains();
const options = useMemo(() => {
return chainsToShow.map((chain) => {
return (chainsQuery.data || [])?.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [chainsToShow]);
}, [chainsQuery.data]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(Number(option.value));
const chain = chainsQuery.data?.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return false;
}
@@ -180,12 +45,14 @@ export function SingleNetworkSelector(props: {
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[idToChain],
[chainsQuery.data],
);
const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(Number(option.value));
const chain = chainsQuery.data?.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return option.label;
}
@@ -193,27 +60,16 @@ export function SingleNetworkSelector(props: {
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
<ChainIcon className="size-5" ipfsSrc={chain.icon} loading="lazy" />
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge className="gap-2 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
[chainsQuery.data],
);
const isLoadingChains = allChains.length === 0;
const isLoadingChains = chainsQuery.isPending;
return (
<SelectWithSearch
@@ -19,7 +19,7 @@ export function OverviewPage(props: {
<h1 className="mb-2 font-bold text-5xl tracking-tight">
{props.title}
</h1>
<p className="text-base text-muted-foreground leading-normal text-pretty">
<p className="text-base text-muted-foreground leading-normal text-pretty max-w-[80%]">
{props.description}
</p>
</div>
@@ -1,332 +0,0 @@
/* eslint-disable no-restricted-syntax */
/** biome-ignore-all lint/a11y/useSemanticElements: TODO */
"use client";
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useShowMore } from "../../lib/useShowMore";
import { Input } from "../ui/input";
import { ScrollShadow } from "../ui/ScrollShadow";
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
options: {
label: string;
value: string;
}[];
selectedValues: string[];
onSelectedValuesChange: (value: string[]) => void;
placeholder: string;
searchPlaceholder?: string;
popoverContentClassName?: string;
selectedBadgeClassName?: string;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
className?: string;
overrideSearchFn?: (
option: { value: string; label: string },
searchTerm: string,
) => boolean;
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
}
export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
(
{
options,
onSelectedValuesChange,
placeholder,
maxCount = Number.POSITIVE_INFINITY,
className,
selectedValues,
overrideSearchFn,
renderOption,
popoverContentClassName,
selectedBadgeClassName,
searchPlaceholder,
...props
},
ref,
) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
onSelectedValuesChange(newSelectedValues);
}
},
[selectedValues, onSelectedValuesChange],
);
const toggleOption = useCallback(
(option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
onSelectedValuesChange(newSelectedValues);
},
[selectedValues, onSelectedValuesChange],
);
const handleClear = useCallback(() => {
onSelectedValuesChange([]);
}, [onSelectedValuesChange]);
const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};
const clearExtraOptions = useCallback(() => {
const newSelectedValues = selectedValues.slice(0, maxCount);
onSelectedValuesChange(newSelectedValues);
}, [selectedValues, onSelectedValuesChange, maxCount]);
// show 50 initially and then 20 more when reaching the end
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
const optionsToShow = useMemo(() => {
const filteredOptions: {
label: string;
value: string;
}[] = [];
const searchValLowercase = searchValue.toLowerCase();
for (let i = 0; i <= options.length - 1; i++) {
if (filteredOptions.length >= itemsToShow) {
break;
}
const option = options[i];
if (!option) {
continue;
}
if (overrideSearchFn) {
if (overrideSearchFn(option, searchValLowercase)) {
filteredOptions.push(option);
}
} else {
if (option.label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(option);
}
}
}
return filteredOptions;
}, [options, searchValue, itemsToShow, overrideSearchFn]);
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: ok
useEffect(() => {
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}, [searchValue]);
return (
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
className={cn(
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
)}
onClick={handleTogglePopover}
>
{selectedValues.length > 0 ? (
<div className="flex w-full justify-between">
{/* badges */}
<div className="flex flex-wrap items-center gap-1.5">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
if (!option) {
return null;
}
return (
<ClosableBadge
className={selectedBadgeClassName}
key={value}
label={option.label}
onClose={() => toggleOption(value)}
/>
);
})}
{/* +X more */}
{selectedValues.length > maxCount && (
<ClosableBadge
label={`+ ${selectedValues.length - maxCount} more`}
onClose={clearExtraOptions}
/>
)}
</div>
<div className="flex items-center justify-between gap-2">
{/* Clear All */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: TODO */}
{/* biome-ignore lint/a11y/useFocusableInteractive: TODO */}
<div
className="rounded p-1 hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
role="button"
>
<XIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
<Separator
className="flex h-full min-h-6"
orientation="vertical"
/>
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex w-full items-center justify-between">
<span className="text-muted-foreground text-sm">
{placeholder}
</span>
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
className={cn("p-0", popoverContentClassName)}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
ref={popoverElRef}
sideOffset={10}
style={{
maxHeight: "var(--radix-popover-content-available-height)",
width: "var(--radix-popover-trigger-width)",
}}
>
<div>
{/* Search */}
<div className="relative">
<Input
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={searchPlaceholder || "Search"}
value={searchValue}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
</div>
<ScrollShadow
className="rounded"
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
>
{/* List */}
<div>
{optionsToShow.length === 0 && (
<div className="flex justify-center py-10">
No results found
</div>
)}
{optionsToShow.map((option, i) => {
const isSelected = selectedValues.includes(option.value);
return (
<Button
aria-selected={isSelected}
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
key={option.value}
onClick={() => toggleOption(option.value)}
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
role="option"
variant="ghost"
>
<div
className={cn(
"flex size-4 items-center justify-center rounded-sm border border-foreground",
isSelected
? "bg-inverted text-inverted-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className="size-4" />
</div>
<div className="min-w-0 grow">
{renderOption ? renderOption(option) : option.label}
</div>
</Button>
);
})}
</div>
</ScrollShadow>
</div>
</PopoverContent>
</Popover>
);
},
);
function ClosableBadge(props: {
label: string;
onClose: () => void;
className?: string;
}) {
return (
<span
className={cn(
"flex items-center gap-3 rounded-xl border border-border bg-muted px-2 py-1 text-foreground text-xs",
props.className,
)}
>
{props.label}
<XIcon
className="h-3 w-3 cursor-pointer text-muted-foreground"
onClick={(e) => {
e.stopPropagation();
props.onClose();
}}
/>
</span>
);
}
MultiSelect.displayName = "MultiSelect";
@@ -2,22 +2,15 @@
import { CoinsIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import type { ThirdwebClient } from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { Badge } from "@/components/ui/badge";
import { Img } from "@/components/ui/Img";
import { SelectWithSearch } from "@/components/ui/select-with-search";
import { useAllChainsData } from "@/hooks/chains";
import { useTokensData } from "@/hooks/useTokensData";
import type { TokenMetadata } from "@/lib/types";
import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils";
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
export function TokenSelector(props: {
selectedToken: { chainId: number; address: string } | undefined;
onChange: (token: TokenMetadata) => void;
@@ -28,49 +21,13 @@ export function TokenSelector(props: {
client: ThirdwebClient;
disabled?: boolean;
enabled?: boolean;
addNativeTokenIfMissing: boolean;
}) {
const tokensQuery = useTokensData({
chainId: props.chainId,
enabled: props.enabled,
});
const { idToChain } = useAllChainsData().data;
const tokens = useMemo(() => {
if (!tokensQuery.data) {
return [];
}
if (props.addNativeTokenIfMissing) {
const hasNativeToken = tokensQuery.data.some(
(token) => token.address === checksummedNativeTokenAddress,
);
if (!hasNativeToken && props.chainId) {
return [
{
address: checksummedNativeTokenAddress,
chainId: props.chainId,
decimals: 18,
name:
idToChain.get(props.chainId)?.nativeCurrency.name ??
"Native Token",
symbol:
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
} satisfies TokenMetadata,
...tokensQuery.data,
];
}
}
return tokensQuery.data;
}, [
tokensQuery.data,
props.chainId,
props.addNativeTokenIfMissing,
idToChain,
]);
const tokens = tokensQuery.data || [];
const addressChainToToken = useMemo(() => {
const value = new Map<string, TokenMetadata>();
for (const token of tokens) {
+27 -23
View File
@@ -1,39 +1,43 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ChainMetadata } from "thirdweb/chains";
import { THIRDWEB_CLIENT } from "../lib/client";
import { isProd } from "../lib/env";
async function fetchChainsFromApi() {
const res = await fetch("https://api.thirdweb.com/v1/chains");
type BridgeChain = {
chainId: number;
name: string;
icon: string;
nativeCurrency: {
name: string;
symbol: string;
decimals: number;
};
};
async function fetchBridgeSupportedChainsFromApi() {
const res = await fetch(
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/chains`,
{
headers: {
"x-client-id": THIRDWEB_CLIENT.clientId,
},
},
);
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data as ChainMetadata[];
return json.data as BridgeChain[];
}
export function useAllChainsData() {
const query = useQuery({
export function useBridgeSupportedChains() {
return useQuery({
queryFn: async () => {
const idToChain = new Map<number, ChainMetadata>();
const chains = await fetchChainsFromApi();
for (const c of chains) {
idToChain.set(c.chainId, c);
}
return {
allChains: chains,
idToChain,
};
return fetchBridgeSupportedChainsFromApi();
},
queryKey: ["all-chains"],
queryKey: ["bridge-supported-chains"],
});
return {
data: query.data || { allChains: [], idToChain: new Map() },
isPending: query.isLoading,
};
}
+43
View File
@@ -0,0 +1,43 @@
export function stringifyProps(
props: Record<string, string | undefined | boolean>,
) {
const _props: Record<string, string | undefined | boolean> = {};
for (const key in props) {
if (props[key] !== undefined && props[key] !== "") {
_props[key] = props[key];
}
}
return Object.entries(_props)
.map(([key, value]) => `${key}={${value}}`)
.join("\n");
}
export function quotes(value: string) {
return `"${value}"`;
}
export function stringifyImports(imports: Record<string, string[]>) {
let code = "";
for (const key in imports) {
if (imports[key].length > 0) {
code += `import { ${imports[key].join(", ")} } from "${key}";\n`;
}
}
return code;
}
export function stringifyIgnoreFalsy(
value: Record<string, string | undefined | boolean>,
) {
const _value: Record<string, string | boolean> = {};
for (const key in value) {
if (value[key] !== undefined && value[key] !== "") {
_value[key] = value[key];
}
}
return JSON.stringify(_value);
}

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