Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
95412c75b9 Version Packages (#8114)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-25 10:12:22 -07:00
MananTank 7bc513c770 [BLD-328] Portal: Update changelog page layout (#8124)
<!--

## 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 and code refactoring across various components in the application, enhancing styling consistency, accessibility, and overall user experience.

### Detailed summary
- Updated grid spacing in `Grid.tsx`.
- Increased max-width in `layout.tsx`.
- Modified border styles in multiple components.
- Enhanced button styles and variants in `chat-button.tsx` and `Subscribe.tsx`.
- Improved text styles for better readability.
- Refactored `AuthMethodsTabs.tsx` to use `Button` component.
- Adjusted `DocLayout.tsx` for more responsive design.
- Improved date formatting in `RenderData.tsx`.
- Added animations to `TableOfContents.tsx`.
- Updated feedback dialog in `Feedback.tsx` for better UX.
- General styling updates for consistency across 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

- Style
  - Widespread visual refinements: typography, spacing, tighter headings, dashed borders for dividers/images/code, smaller author avatar, absolute dates, wider content container, updated buttons (ghost/outline), adjusted icons and shadows, updated link/inline-code/grid/card styles, tabs and header/menu visual updates.
  - Footer, sidebar, docs search, subscribe, feedback, and table-of-contents presentational tweaks (including fade-in and conditional hiding).

- Refactor
  - Simplified changelog and post layout to a single-column header-first structure; removed changelog index TOC.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-25 08:00:13 +00:00
Joaquim VergesandGitHub 9069351e1a [SDK] Support Chain objects in x402 payment utilities (#8126) 2025-09-25 00:53:22 -07:00
MananTank 8f6363f4e4 SDK: Fix toUnits throwing error for values in scientific notation (#8112)
<!--

## 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 `toUnits` function to handle both scientific notation and fixed-point notation, ensuring accurate conversion of token values with specified decimals. It also adds tests to verify the new functionality.

### Detailed summary
- Modified `toUnits` function to check for scientific notation (e.g., "1e-18").
- Converted scientific notation to fixed-point notation using `Number(tokens).toFixed(decimals)`.
- Added tests for `toUnits` to cover various scientific notation cases, including negative and positive exponents.

> ✨ 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**
  * Unit conversion now accepts scientific notation (positive and negative exponents), normalizing values to the configured decimal precision for consistent fixed-point results.

* **Tests**
  * Added comprehensive tests covering positive and negative exponent cases to validate parsing and conversion from scientific notation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-24 23:07:53 +00:00
MananTank a83104efff [MNY-200] SDK: Fix onSuccess callback not called when tx is sent in TransactionWidget (#8119)
<!--

## 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 simplifies the `handlePostBuyTransactionComplete` function in the `BridgeOrchestrator` component by removing the `quote` parameter and the associated call to `onComplete`. It streamlines the transaction completion process and adjusts the `closeModal` prop accordingly.

### Detailed summary
- Removed the `quote` parameter from the `handlePostBuyTransactionComplete` function.
- Eliminated the call to `onComplete?.(quote)` within the `handlePostBuyTransactionComplete` function.
- Updated the `closeModal` prop in the `ExecutingTxScreen` component to directly use `handlePostBuyTransactionComplete` without arguments.

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

<!-- end pr-codex -->
2025-09-24 21:43:56 +00:00
MananTank 7936ebb6e9 [MNY-180] SDK: Fix TransactionWidget not respecting currency prop (#8118)
<!--

## 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 handling of the `currency` property in various components and adding a new story for a transaction mode in light theme that showcases an ERC20 token transfer.

### Detailed summary
- Changed `currency` from optional to required in `useTransactionDetails.ts`.
- Added `currency` to the props in `PaymentOverview.tsx` and `TransactionPayment.tsx`.
- Introduced a new story `TransactionJPYCurrency` in `BridgeOrchestrator.stories.tsx` for displaying an ERC20 token transfer in light theme.

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

<!-- end pr-codex -->
2025-09-24 21:43:56 +00:00
MananTank 3b986d52e5 [MNY-201] SDK: Rename leftover of Universal Bridge to Bridge (#8117)
<!--

## 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 renaming references from `Universal Bridge` to `Bridge` across multiple files to standardize terminology and improve clarity.

### Detailed summary
- Updated comments and documentation to replace `Universal Bridge` with `Bridge`.
- Modified error messages in `apps/dashboard/src/@/api/analytics.ts` to reflect the new terminology.
- Changed descriptions in various files related to `Chain`, `Token`, `Quote`, and webhooks to use `Bridge`.

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

<!-- end pr-codex -->
2025-09-24 21:43:56 +00:00
MananTank ee4e13e098 [MNY-198] SDK: Fix StepRunner autoStart not working reliably, Remove extra prepare call (#8116)
<!--

## 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 enhancing the `StepRunner` component in the Bridge UI by integrating a `preparedQuote` prop, improving the handling of swap transactions, and refining the relevant hooks and stories.

### Detailed summary
- Added `preparedQuote` prop to `SwapWidget` and `StepRunner`.
- Updated `StepRunner` to utilize `preparedQuote` for executing steps.
- Modified `useStepExecutor` to accept `preparedQuote` instead of `request`.
- Cleaned up imports in `useStepExecutor`.
- Updated stories to reflect the new `preparedQuote` prop.

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

<!-- end pr-codex -->
2025-09-24 19:29:55 +00:00
MananTank 8dc9e4102d [MNY-197] SDK: SwapWidget chain selection UI improvements (#8115)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the user experience in the `SwapUI` and `SelectTokenUI` components by updating callback functions and enhancing token selection logic.

### Detailed summary
- Changed `onBack` prop to `onClose` in `SelectToken` and `SelectTokenUI` components.
- Updated modal state management to close modals on token selection.
- Introduced `initiallySelectedChain` to prioritize the selected chain in `SelectBridgeChainUI`.
- Enhanced filtering logic for token selection to consider chain IDs.

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

<!-- end pr-codex -->
2025-09-24 19:29:55 +00:00
MananTank 33fc3e5226 [MNY-196] SDK: Add SwapWidget render event (#8113)
<!--

## 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 query using `useQuery` from `@tanstack/react-query` in the `SwapWidget` component to track the rendering event of the swap widget.

### Detailed summary
- Added `useQuery` to the `SwapWidget` component.
- Implemented a query function that calls `trackPayEvent` to log the rendering of the swap widget.
- The tracking event includes the `client` from `props` and an event identifier `"ub:ui:swap_widget:render"`.

> ✨ 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 background telemetry for the Swap Widget to record render events for analytics. It runs automatically when the widget is displayed, requires no configuration, and does not change the UI or user experience. This helps track usage and adoption to inform future improvements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-24 19:29:54 +00:00
MananTank ed47e93f1d [BLD-338] Dashboard: Fix NFTs table owner row not showing data (#8120)
<!--

## 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 temporary change to the `table.tsx` file in the NFTs component of the dashboard. It sets the `useIndexer` property to `false`, indicating a workaround until a future fix is implemented.

### Detailed summary
- Added `useIndexer: false` to the settings in `table.tsx`.
- Marked the addition as temporary with a comment: `// TEMPORARY, TODO: Remove when this is fixed in insight`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved reliability of NFT lists in the dashboard, reducing intermittent errors and missing items when viewing NFTs for specific contracts.
  * Ensures more consistent, up-to-date NFT data is displayed during browsing and refreshes.
  * Enhances stability of data loading in environments with inconsistent indexing, minimizing failed loads and retries.
  * No visual/UI changes; behavior is more dependable without altering the interface or workflows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-24 19:19:38 +00:00
MananTank 1f7cda6698 [MNY-210] SDK: export a script to render BridgeEmbed (#8106)
<!--

## 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 `BridgeWidget` component, allowing users to render a widget for token buying and swapping. It also updates related files for better integration and testing, while modifying build configurations and dependencies.

### Detailed summary
- Added `BridgeWidget` component.
- Created a browser script in `dist/scripts/bridge-widget.js`.
- Updated `BuyOrOnrampPrepareResult` type in `BuyWidget.tsx`.
- Excluded test files in `tsconfig.build.tsup.json`.
- Modified `tsup` configuration for building the widget.
- Enhanced documentation for the `BridgeWidget`.
- Added various usage examples in the README.
- Updated `pnpm-lock.yaml` with new dependencies and versions.

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

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

<!-- end pr-codex -->

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

- **New Features**
  - Introduced a Bridge Widget with Swap and Buy tabs, theming, branding toggle, currency presets, and callback support.
  - Added an embeddable script API with a global render function to mount the Bridge Widget on any webpage and exposed it in React exports.

- **Style**
  - Connect Wallet embed now uses full-width with max-width constraints for responsive display.

- **Documentation**
  - Added usage guide and Storybook examples for the Bridge Widget script.

- **Build**
  - Added browser-targeted bundle config to produce a self-contained Bridge Widget script.

- **Chores**
  - Updated build scripts and dev dependencies.

- **Tests**
  - Skipped a batch transaction test.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-24 17:30:52 +00:00
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
213 changed files with 8105 additions and 3597 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;
}
+86 -34
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", {
@@ -258,9 +243,51 @@ type TokenSwapParams = {
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 successful token swaps 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?
@@ -272,7 +299,21 @@ export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
/**
* ### Why do we need to report this event?
* - To track number of failed token swaps from the token page
* - 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?
@@ -288,7 +329,7 @@ export function reportTokenSwapFailed(
/**
* ### Why do we need to report this event?
* - To track number of cancelled token swaps from the token page
* - To track number of cancelled token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
@@ -300,7 +341,7 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
/**
* ### Why do we need to report this event?
* - To track number of failed asset purchases from the token page
* - 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?
@@ -308,7 +349,7 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
*/
export function reportAssetBuyFailed(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
error: string;
}) {
@@ -330,7 +371,7 @@ export function reportAssetBuyFailed(properties: {
*/
export function reportAssetBuyCancelled(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy cancelled", {
@@ -545,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
+2 -2
View File
@@ -592,7 +592,7 @@ const cached_getUniversalBridgeUsage = unstable_cache(
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
`Failed to fetch bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
@@ -636,7 +636,7 @@ const cached_getUniversalBridgeWalletUsage = unstable_cache(
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch universal bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
`Failed to fetch bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
@@ -1,34 +1,73 @@
"use client";
import { useTheme } from "next-themes";
import { useState } from "react";
import type { Chain, ThirdwebClient } from "thirdweb";
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: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
pageType: "asset" | "bridge" | "chain";
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">
<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"
@@ -48,36 +87,85 @@ export function BuyAndSwapEmbed(props: {
chain={props.chain}
className="!rounded-2xl !w-full !border-none"
title=""
client={props.client}
client={client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
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,
contractType: "DropERC20",
error: errorMessage,
contractType: undefined,
});
}
}}
onCancel={() => {
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: "DropERC20",
contractType: undefined,
});
}
}}
onSuccess={() => {
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: "DropERC20",
contractType: undefined,
});
}
}}
@@ -89,16 +177,17 @@ export function BuyAndSwapEmbed(props: {
{tab === "swap" && (
<SwapWidget
client={props.client}
client={client}
theme={themeObj}
className="!rounded-2xl !border-none !w-full"
prefill={{
sellToken: {
// buy this token by default
buyToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// only set `buyToken` as "Native token" if `sellToken` is not a "native token" already
buyToken: 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,
}
@@ -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>
@@ -4,7 +4,7 @@ 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-lg overflow-hidden relative">
<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}
@@ -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,18 +1,13 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
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;
}) {
export function BuyFundsSection(props: { chain: ChainMetadata }) {
return (
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
client={props.client}
// eslint-disable-next-line no-restricted-syntax
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
@@ -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;
}
@@ -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[] = [];
@@ -58,6 +58,7 @@ export function NFTGetAllTable({
includeOwners: true,
start: currentPage * pageSize,
tokenByIndex,
useIndexer: false, // TEMPORARY, TODO: Remove when this is fixed in insight
},
);
@@ -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,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,3 +1,4 @@
import { BarChart3Icon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
import { cookies } from "next/headers";
import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
@@ -13,8 +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 { 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,6 +101,44 @@ export async function ERC20PublicPage(props: {
<div className="h-8" />
{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"
}
/>
<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>
)}
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
@@ -118,20 +155,6 @@ export async function ERC20PublicPage(props: {
</div>
)}
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
<div className="container max-w-7xl pb-10">
<DexScreener
chain={
mapChainIdToDexScreenerChainSlug[
props.chainMetadata
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
]
}
contractAddress={props.clientContract.address}
/>
</div>
)}
<div className="container flex max-w-7xl grow flex-col pb-10">
<div className="flex grow flex-col gap-8">
<ContractAnalyticsOverview
@@ -173,7 +196,6 @@ function BuyEmbed(props: {
return (
<BuyAndSwapEmbed
chain={props.clientContract.chain}
client={props.clientContract.client}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
pageType="asset"
@@ -206,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,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>
);
}
@@ -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>
);
@@ -3,7 +3,6 @@
import type { TokenInfo } from "thirdweb/react";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { bridgeAppThirdwebClient } from "../../constants";
export function UniversalBridgeEmbed({
chainId,
@@ -17,9 +16,8 @@ export function UniversalBridgeEmbed({
const chain = useV5DashboardChain(chainId || 1);
return (
<div className="lg:w-[400px]">
<div className="w-full lg:w-[400px]">
<BuyAndSwapEmbed
client={bridgeAppThirdwebClient}
chain={chain}
buyAmount={amount}
tokenAddress={token?.address}
@@ -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-dvh 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 opacity-20"
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-semibold text-base tracking-tight">
Get Started with thirdweb Bridge
</h3>
<p className="text-muted-foreground text-sm">
thirdweb Bridge allows developers to easily bridge and swap
tokens between chains and wallets.
</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/bridge"
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%)]" />
)}
+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,33 +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 type { SupportedFiatCurrency } from "../../../../../../packages/thirdweb/dist/types/pay/convert/type";
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";
@@ -37,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,
@@ -69,7 +54,7 @@ export function LeftSection(props: {
return undefined;
});
const payModeId = useId();
const _payModeId = useId();
const buyTokenAmountId = useId();
const sellerAddressId = useId();
const paymentAmountId = useId();
@@ -120,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 as SupportedFiatCurrency,
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"
@@ -212,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}
@@ -229,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>
@@ -310,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>
@@ -412,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>
@@ -560,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>
);
@@ -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,141 @@
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";
import { arbitrumSepolia } from "thirdweb/chains";
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: arbitrumSepolia, // or any other chain
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);
}
+3 -3
View File
@@ -1,6 +1,6 @@
// export const isProd =
// (process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
// "production";
export const isProd =
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
"production";
function getVercelEnv(): "production" | "preview" | "development" {
const onVercel = process.env.VERCEL || process.env.NEXT_PUBLIC_VERCEL_ENV;
@@ -1,37 +0,0 @@
"use client";
import { useCallback, useState } from "react";
/**
*
* @internal
*/
export function useShowMore<T extends HTMLElement>(
initialItemsToShow: number,
itemsToAdd: number,
) {
// start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more
const [itemsToShow, setItemsToShow] = useState(initialItemsToShow);
const lastItemRef = useCallback(
(node: T) => {
if (!node) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items
}
},
{ threshold: 1 },
);
observer.observe(node);
// when the node is removed from the DOM, observer will be disconnected automatically by the browser
},
[itemsToAdd],
);
return { itemsToShow, lastItemRef };
}
+61
View File
@@ -0,0 +1,61 @@
import { type NextRequest, NextResponse } from "next/server";
import { createThirdwebClient } from "thirdweb";
import { arbitrumSepolia } from "thirdweb/chains";
import { facilitator, settlePayment } from "thirdweb/x402";
const client = createThirdwebClient({
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
});
const chain = arbitrumSepolia;
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
const ENGINE_VAULT_ACCESS_TOKEN = process.env
.ENGINE_VAULT_ACCESS_TOKEN as string;
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
const twFacilitator = facilitator({
baseUrl: `${API_URL}/v1/payments/x402`,
client,
serverWalletAddress: BACKEND_WALLET_ADDRESS,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const method = request.method.toUpperCase();
const resourceUrl = `${request.nextUrl.protocol}//${request.nextUrl.host}${pathname}`;
const paymentData = request.headers.get("X-PAYMENT");
const result = await settlePayment({
resourceUrl,
method,
paymentData,
payTo: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
network: chain,
price: "$0.01",
routeConfig: {
description: "Access to paid content",
},
facilitator: twFacilitator,
});
if (result.status === 200) {
// payment successful, execute the request
const response = NextResponse.next();
for (const [key, value] of Object.entries(result.responseHeaders)) {
response.headers.set(key, value);
}
return response;
}
// 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/paywall"],
};
+46 -52
View File
@@ -213,7 +213,7 @@ export function Header() {
const pathname = usePathname();
return (
<header className="flex w-full flex-col gap-2 border-b bg-background p-4 xl:pb-0 lg:px-8 overflow-hidden">
<header className="flex w-full flex-col gap-2 border-b p-4 xl:pb-0 lg:px-8 lg:pt-5 overflow-hidden bg-card">
{/* Top row */}
<div className="container flex items-center justify-between gap-6">
<div className="flex items-center gap-2">
@@ -230,7 +230,7 @@ export function Header() {
</Link>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4">
<div className="hidden xl:block">
<Link
className="text-foreground"
@@ -269,13 +269,13 @@ export function Header() {
</div>
{/* Bottom row - hidden on mobile */}
<div className="container hidden items-center justify-between gap-6 xl:flex mt-1">
<div className="container hidden items-start justify-between gap-6 xl:flex mt-1">
<nav className="flex grow gap-5">
<ul className="flex flex-row items-center gap-0 mb-1">
<ul className="flex flex-row items-center gap-0 mb-1.5">
{links.map((link) => {
return (
<li
className="flex items-center py-2 relative px-2.5 hover:text-foreground"
className="flex items-center relative"
key={link.href}
onClick={() => {
setShowBurgerMenu(false);
@@ -284,9 +284,13 @@ export function Header() {
setShowBurgerMenu(false);
}}
>
<NavLink href={link.href} name={link.name} />
<NavLink
href={link.href}
name={link.name}
className="py-2.5 px-3 hover:bg-accent rounded-lg hover:text-foreground font-normal"
/>
{pathname?.startsWith(link.href) && (
<div className="bg-violet-700 h-[2px] inset-x-0 rounded-full absolute -bottom-1" />
<div className="bg-foreground h-[2px] inset-x-0 rounded-full absolute -bottom-1.5" />
)}
</li>
);
@@ -294,47 +298,37 @@ export function Header() {
</ul>
</nav>
<div className="flex items-center gap-3">
<div className="px-1">
<DropdownLinks
category="AI"
links={aiLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
</div>
<div className="px-1">
<DropdownLinks
category="SDKs"
links={sdkLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
</div>
<div className="px-1">
<DropdownLinks
category="APIs"
links={apisLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
</div>
<div className="flex items-center">
<DropdownLinks
category="AI"
links={aiLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
<DropdownLinks
category="SDKs"
links={sdkLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
<DropdownLinks
category="APIs"
links={apisLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
<div className="px-1">
<DropdownLinks
category="Tools"
links={toolLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
</div>
<div className="px-1">
<DropdownLinks
category="Support"
links={supportLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
</div>
<DropdownLinks
category="Tools"
links={toolLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
<DropdownLinks
category="Support"
links={supportLinks}
onLinkClick={() => setShowBurgerMenu(false)}
/>
<NavLink
href="/changelog"
className="px-3 py-2.5 hover:bg-accent hover:text-foreground rounded-lg"
name="Changelog"
onClick={() => {
setShowBurgerMenu(false);
@@ -448,31 +442,29 @@ function DropdownLinks(props: {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="inline-flex items-center gap-1 p-0 font-medium text-muted-foreground text-sm hover:bg-transparent hover:text-foreground"
className="inline-flex items-center gap-1.5 font-normal text-muted-foreground text-sm hover:bg-accent hover:text-foreground rounded-lg px-3 py-2.5"
variant="ghost"
>
{props.category}
<ChevronDownIcon className="size-4 text-muted-foreground opacity-70" />
<ChevronDownIcon className="size-3.5 text-muted-foreground opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="flex flex-col gap-1 bg-card p-3"
style={{
minWidth: "150px",
}}
className="flex flex-col gap-1 bg-card p-1 rounded-xl min-w-[200px]"
sideOffset={14}
>
{props.links.map((info) => {
return (
<DropdownMenuItem asChild key={info.name}>
<div
className={clsx(
"relative flex cursor-pointer gap-2 font-medium text-foreground",
"relative flex cursor-pointer gap-3 font-medium text-foreground !rounded-lg px-3 py-2",
"hover:bg-accent",
)}
>
{info.icon && (
<info.icon className="size-5 text-foreground" />
<info.icon className="size-5 text-muted-foreground" />
)}
<Link
className="before:absolute before:inset-0"
@@ -524,6 +516,7 @@ function NavLink(props: {
name: string;
onClick?: () => void;
icon?: React.FC<{ className?: string }>;
className?: string;
}) {
const pathname = usePathname();
return (
@@ -534,6 +527,7 @@ function NavLink(props: {
? "text-foreground"
: "text-muted-foreground",
props.icon ? "flex flex-row gap-3" : "",
props.className,
)}
href={props.href}
onClick={props.onClick}

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