Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
80d5595e7f Version Packages (#8397)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-12 12:51:24 -08:00
Joaquim VergesandGitHub d0bbbfd39d [SDK] Add all connected wallets in onConnect callbacks (#8403) 2025-11-12 12:44:46 -08:00
Joaquim VergesandGitHub e26d81ce1d [SDK] Update useAuthToken to find auth token for any connected wallet (#8402) 2025-11-12 11:06:26 -08:00
MananTank be3316ec1b [PRO-96] Playground: Update product grouping (#8401)
<!--

## 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 restructuring the `payments` and `contracts` sections of the codebase, consolidating them under a new `bridge` section, and updating various links and components to reflect the new structure.

### Detailed summary
- Deleted multiple files related to `payments` and `contracts`.
- Introduced new `bridge` components and pages.
- Updated links in various `.mdx` and `.tsx` files to point to the new `bridge` paths.
- Renamed components such as `BuyPlayground` to `BuyWidget`.
- Updated sidebar links for improved navigation.
- Adjusted feature cards to reflect the new structure and titles.

> ✨ 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**
  * Reorganized navigation menu with new Bridge section consolidating payment widgets
  * Added x402 as prominent top-level navigation item
  * Expanded Account Abstraction section with new submenu items

* **Changes**
  * Updated widget names and playground URLs across documentation and dashboard links
  * Restructured Transactions and Account Abstraction into dedicated submenus under Wallets
  * Renamed and relocated playground widget pages to Bridge section

* **Removals**
  * Removed Contracts section from navigation and playground
  * Removed standalone Payments overview page
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-12 18:13:07 +00:00
FirekeeperandGitHub 872369770f [Dashboard] Add date range filtering to transaction analytics (#8400) 2025-11-12 21:31:10 +07:00
Joaquim VergesandGitHub 3d48826928 [Dashboard] Change default x402 metric from volume to payments (#8399) 2025-11-11 23:26:15 -08:00
Joaquim VergesandGitHub fa640ad64b [Dashboard] Add x402 chain analytics chart and empty state (#8398) 2025-11-11 17:32:03 -08:00
MananTank eb1a9c3c3e Add callout for verifying webhook in dashboard and portal (#8396)
<!--

## 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 `BridgeWebhookModalContent` component by adding a warning alert for payload verification and modifies the documentation for webhook creation and payload parsing to reflect changes in terminology and content structure.

### Detailed summary
- Added `UnderlineLink` and `Alert` components to `webhooks.client.tsx`.
- Introduced a warning alert regarding payload verification in the webhook modal.
- Updated the version selection to only display for specific conditions.
- Changed documentation headings and improved clarity on webhook creation and payload parsing.
- Revised example payloads and verification instructions in the documentation.

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

<!-- end pr-codex -->
2025-11-11 23:20:42 +00:00
MananTank a54c1aba35 SDK: Add verify parameter in Bridge.Webhook.parse function (#8395)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds a `verify` parameter to the `Bridge.Webhook.parse` function in the `thirdweb` library, allowing for validation of webhook payloads against specified criteria such as receiver address, destination token address, chain ID, and minimum destination amount.

### Detailed summary
- Introduced `verify` parameter in `Bridge.Webhook.parse` function.
- Added validation checks for:
  - `receiverAddress`
  - `destinationTokenAddress`
  - `destinationChainId`
  - `minDestinationAmount`
- Enhanced error handling for mismatched values.
- Updated tests to cover verification scenarios for both onchain and onramp transactions.

> ✨ 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**
  * Webhook parsing now supports optional payload verification: enforce receiver address (case-insensitive), destination token, destination chain ID, and minimum destination amount. Verification works for both on-chain and on-ramp payloads and returns descriptive errors on mismatch to help ensure incoming webhooks meet your requirements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-11 23:20:42 +00:00
Joaquim VergesandGitHub bea4f01742 [Dashboard] Add x402 payments section (#8394) 2025-11-11 14:43:04 -08:00
0xFirekeeper e19f7a21ad [Dashboard] New Project > Wallets product grouping and subsections (#8359)
Reorganized wallet-related routes under a new sidebar structure, replacing legacy pages with redirects to new user, server, and sponsored gas wallet sections. Added new layout and overview/configuration pages for server wallets and sponsored gas, updated sidebar navigation to reflect new wallet grouping, and removed legacy Account Abstraction and Vault pages in favor of the new structure.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming "in-app wallets" to "user wallets" throughout the application, along with restructuring related components, routes, and API calls. It also introduces new configurations and adjusts UI elements to reflect these changes.

### Detailed summary
- Renamed `in-app wallets` to `user wallets` in various components and texts.
- Deleted unused files related to `in-app wallets`.
- Updated API error messages to reflect the new terminology.
- Adjusted routing paths for user wallet configurations.
- Updated UI elements and titles to align with the new terminology.
- Refactored sidebar links to include new wallet types.
- Added new components for user wallet management.
- Implemented new pagination and analytics features for user wallets.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/utils.ts`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx`

> ✨ 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**
  * Pages and layouts for Server Wallets, Sponsored Gas, and User Wallets (overview + configuration); key management and analytics pages added.
  * In-app/User wallet settings UI: branding, JWT/custom auth endpoint, image uploads, SMS country selector, and SMS country data/utilities.

* **Navigation Updates**
  * Sidebar reorganized to group wallet features; submenu discovery and mobile behavior improved.

* **Behavioral Changes**
  * Legacy analytics/settings pages replaced with redirects to new wallet routes.

* **UX**
  * Wallet tables: URL-driven pagination, page-size-aware ranges, clearer “Showing X–Y of Z” messaging; wording updated from “In-App” to “User” wallets; announcement banner added.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-10 22:32:28 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c37af4f191 Version Packages (#8389)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-10 14:31:30 -08:00
Joaquim VergesandGitHub f964b8aaff [SDK] Loosen peer dependency requirements for React Native (#8390) 2025-11-10 10:47:57 -08:00
YashandGitHub 424e6b8f9b Stylus modules (#8383) 2025-11-10 10:33:56 -08:00
MananTank 763d3de5a8 [MNY-303] Various UI/UX Improvements in Bridge components (#8388)
<!--

## 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 various UI/UX improvements across multiple components in the `thirdweb` package, particularly enhancing visual consistency and user interaction within the Bridge and Token Selection components.

### Detailed summary
- Adjusted font weights in several components from `600` to `500`.
- Updated `Spacer` component usage for consistent spacing.
- Enhanced `TokenSelection` UI with tabbed navigation for "All Tokens" and "Your Tokens".
- Added fallback images for icons in `TokenAndChain` and `ChainButton`.
- Refactored `CopyIcon` to use a `Button` instead of a `div` for better accessibility.
- Improved loading states and skeletons in `TokenSelectionScreen`.
- Simplified conditional rendering for transaction details in `PaymentReceipt`.

> ✨ 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**
  * Token selection: separate "All Tokens" and "Your Tokens" tabs with per-tab paging and "Load More"
  * Payment receipts: per-transaction explorer links and copy-to-clipboard controls
  * Enhanced success/details views showing expanded per-transaction status entries

* **UI/UX Improvements**
  * Refined typography weights across Bridge screens for clearer hierarchy
  * Adjusted icon sizing and added visual fallbacks for missing chain/token icons
  * Improved spacing, alignment, scrolling and containment in Bridge layouts
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-10 18:11:43 +00:00
Joaquim VergesandGitHub f6f138ccbc [Portal] Move x402 from payments to top-level section (#8386) 2025-11-09 18:09:15 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
f5d5e73e57 Version Packages (#8385)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-09 15:17:10 -08:00
Joaquim VergesandGitHub 673e51b11c [SDK] Replace Celo Alfajores with Celo Sepolia Testnet (#8384) 2025-11-09 14:46:12 -08:00
MananTank 282edf7ccb [PRO-73] Portal: Update hero images (#8381)
<!--

## 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 making stylistic updates to the `Hero` component in the `page.tsx` file, including adjustments to padding and text size in the layout.

### Detailed summary
- Changed padding in the `section` from `py-14` to `pt-14 pb-6`.
- Modified paragraph class from `text-lg` to `text-base` for smaller text size on smaller screens while keeping `lg:text-lg`.

> ✨ 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**
  * Adjusted Hero section vertical spacing for improved layout balance.
  * Enhanced introductory text with responsive font sizing and refined spacing across different screen sizes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-07 20:40:50 +00:00
MananTank a87e91b99c [MNY-305] Dashboard: Checksum addresses in tx page (#8380)
<!--

## 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 address handling in the `GeneericTxDetails` and `TokenInfo` components by introducing the `getAddress` function from `thirdweb`, ensuring that addresses are consistently formatted.

### Detailed summary
- Added `getAddress` import in `page.tsx` and `bridge-status.tsx`.
- Replaced direct usage of `transaction.from` and `transaction.to` with `fromAddress` and `toAddress` in `GeneericTxDetails`.
- Updated `textToCopy` and `textToShow` in `CopyTextButton` components to use `fromAddress` and `toAddress`.
- Updated `TokenInfo` to use `getAddress` for `props.token.address` and `props.walletAddress`.
- Changed `textToCopy` and `textToShow` in `TokenInfo` to use `tokenAddress`.

> ✨ 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 address normalization in transaction and bridge status displays to ensure consistent formatting across the dashboard, preventing potential issues with address validation and comparison.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-07 20:28:47 +00:00
MananTank 0e630c4550 [MNY-304] Move engine tx summary request to client side to fix page crash (#8379)
<!--

## 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 transaction analytics feature by updating data fetching methods, improving UI components, and enhancing error handling for transaction summaries and charts.

### Detailed summary
- Removed outdated files: `utils.ts` and `filter.tsx`.
- Updated import paths for `TransactionSummaryData`.
- Changed UI text in `tx-chart-ui.tsx` for clarity.
- Enhanced error handling in data fetching functions.
- Introduced `getTransactionsChartData` and `getTransactionAnalyticsSummary` for improved data management.
- Refactored `TransactionsAnalyticsPageContent` to streamline data flow and UI rendering.
- Added new components for date and interval selection in transaction charts.

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

<!-- end pr-codex -->

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

* **Refactor**
  * Analytics now load client-side with a spinner and a streamlined layout; analytics sections only show when transactions exist.

* **New Features**
  * Direct date-range and interval controls in the analytics view.
  * Unified analytics summary and charts presented above the transactions table.
  * Chart header updated to "Transactions" with revised description.

* **Chores**
  * Removed URL-synced filter behavior; filters are local.
  * Empty-chart messaging changed to "No transactions found."
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-07 20:13:34 +00:00
Joaquim VergesandGitHub 11c7bf0955 [Playground] Remove hardcoded limit from wrapFetchWithPayment (#8376) 2025-11-07 02:38:54 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c9e430d092 Version Packages (#8375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-07 17:09:03 +07:00
FirekeeperandGitHub 1f440d0c97 Add Epic Games as new auth provider (#8343) 2025-11-07 15:59:08 +07:00
Yash094 3a0aac4393 Change TransactionWidget fiat decimals to two (#8366)
<!--

## [SDK] Fix: Format TransactionWidget fiat amounts to 2 decimals

## Notes for the reviewer

This PR updates the `formatMoney` function to display fiat amounts with a maximum of 2 decimal places instead of 5. This standardizes currency formatting for improved readability within the TransactionWidget.

## How to test

- Verify fiat amounts in the TransactionWidget (e.g., payment screens, token selection) are displayed with a maximum of 2 decimal places.
- Unit tests passed.

-->

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

<a href="https://cursor.com/background-agent?bcId=bc-65f07aee-1cee-4915-9656-8e4d9904ae3c"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-65f07aee-1cee-4915-9656-8e4d9904ae3c"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a>

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the formatting of token balances in the `formatTokenBalance.ts` file to limit the maximum number of fraction digits displayed.

### Detailed summary
- Changed `maximumFractionDigits` from `5` to `2` in the currency formatting options.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Updated currency formatting to display a maximum of 2 decimal places instead of 5, improving readability and consistency of financial amounts throughout the app while maintaining appropriate minimum precision.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-07 07:36:09 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7b13015053 Version Packages (#8374)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-06 16:29:15 -08:00
Joaquim VergesandGitHub 7f081618e1 [SDK] Expose useInvalidateBalances() (#8373) 2025-11-06 16:21:38 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
881c7a96f7 Version Packages (#8372)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-07 06:04:42 +07:00
FirekeeperandGitHub ad68969559 Refactor Solana sign transaction API for flexible inputs (#8371) 2025-11-07 05:56:11 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
915d955da1 Version Packages (#8370)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-06 13:58:49 -08:00
Joaquim VergesandGitHub 7da16cff2f [SDK] Make maxAmount optional in wrapFetchWithPayment (#8369) 2025-11-06 13:54:29 -08:00
Yash094 f0edc0f253 Update vault documentation text and faq (#8367)
```
[Portal] Fix: Update Vault open-source status and add FAQ

## Notes for the reviewer

This PR updates the Vault portal documentation to accurately reflect its open-source status.

*   Removed "open-source" from the main description and metadata on the Vault overview page.
*   Added a new FAQ entry under `/vault/faqs` clarifying that Vault is not yet open source.

## How to test

1.  Navigate to <https://portal.thirdweb.com/vault>
    *   Verify the main description no longer includes "open-source".
    *   (Optional) Inspect page metadata to confirm "open-source" is removed from the description.
2.  Navigate to <https://portal.thirdweb.com/vault/faqs>
    *   Verify the new FAQ "Is Vault open source?" is present with the answer "Vault is not yet open source, but we plan to release it soon."
```

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

<a href="https://cursor.com/background-agent?bcId=bc-ab8c2b24-6a74-4e0c-b302-d84d69ac5953"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-ab8c2b24-6a74-4e0c-b302-d84d69ac5953"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a>

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the description of the `Vault` service to remove references to it being open source and refining the language for clarity.

### Detailed summary
- Added a new `Details` section in `apps/portal/src/app/vault/faqs/page.mdx` about Vault's open-source status.
- Updated the `description` in `apps/portal/src/app/vault/layout.tsx` to remove "open-source."
- Revised the `description` in `apps/portal/src/app/vault/page.mdx` to remove "open-source."
- Modified the main description of `Vault` in `apps/portal/src/app/vault/page.mdx` for 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

## Documentation
* Added new FAQ entry addressing open-source availability with information on future release plans
* Updated Vault service descriptions emphasizing non-custodial key management design and TEE security architecture
* Refined documentation metadata to provide clearer communication of Vault's core features, security model, and service capabilities

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-06 17:58:37 +00:00
Yash094 0a2d269782 Dashboard: Update faucet amount for injective testnet (#8368)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds a new entry for the `Injective testnet` in the `faucet.ts` file, indicating a change in the configuration for test networks.

### Detailed summary
- Added a new entry for `Injective testnet` with a value of `1` in the configuration.
- The existing entry for `Arc testnet` remains unchanged.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

## New Features
* Added support for Injective testnet, enabling faucet claims on this network with customized claim amount configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-06 16:07:33 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3d996d1d23 Version Packages (#8364)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-05 16:06:25 -08:00
Joaquim VergesandGitHub 40781b8170 [SDK] Allow custom chain for SIWE authentication (#8365) 2025-11-05 15:47:54 -08:00
MananTank d907017217 [MNY-295] Fix BuyWidget autoconnect not working when receiverAddress prop is set (#8363)
<!--

## 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 fixing the `BuyWidget` component's autoconnect functionality when the `receiverAddress` prop is set, and it introduces a new story variant to demonstrate this behavior.

### Detailed summary
- Updated `BuyWidget` to conditionally render the `AutoConnect` component based on `connectOptions.autoConnect`.
- Introduced a new story `NoAutoConnect` in `BuyWidget.stories.tsx` to showcase the widget with `autoConnect` set to false.
- Modified `FundWallet.tsx` to ensure proper handling of the `autoConnect` prop.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
  * Fixed auto-connect functionality that was not working correctly when the receiver address property is set on the buy widget.

* **New Features**
  * Added configurable auto-connect option to the buy widget, enabling users to control whether auto-connection is enabled or disabled based on their needs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-05 21:10:50 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
cf53c2d7f1 Version Packages (#8362)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-05 12:10:38 -08:00
Joaquim VergesandGitHub 207cb9dbeb [SDK] Enhance isMobile() detection with additional mobile signals (#8361) 2025-11-05 11:48:57 -08:00
Jonas DanielsandGitHub b4d365bdc0 Move x402 route from /payments/x402 to /x402 (#8360) 2025-11-04 16:30:08 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ea4e2fe376 Version Packages (#8357)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-04 14:20:21 -08:00
Joaquim VergesandGitHub 1d54cff7b4 [SDK] Add support for extra metadata in x402 payment requests (#8358) 2025-11-04 14:13:48 -08:00
YashandGitHub fd04eefea9 Update implementations, fix deployment (#8356) 2025-11-05 00:10:12 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
4cc68310dc Version Packages (#8355)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-04 00:47:18 -08:00
Joaquim VergesandGitHub ccdce496e0 [SDK] Fix hiddenWallets prop not applying to all wallets screen (#8354) 2025-11-04 00:39:11 -08:00
Joaquim VergesandGitHub 2725f337f8 [Dashboard] Fix active users aggregation in project highlights card (#8346) 2025-11-02 18:23:41 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2c6ea3e6dd Version Packages (#8340)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-31 18:48:20 -07:00
Joaquim VergesandGitHub 219fc69d72 [SDK] Update x402 flows to use remote /accepts endpoint (#8345) 2025-10-31 18:40:39 -07:00
Joaquim VergesandGitHub 8d17685683 [SDK] Refactor payment processing to use facilitator.accepts (#8344) 2025-10-31 18:38:51 -07:00
Jonas DanielsandGitHub 8ef418c543 Move success URL handling from PayPage to StyledBuyWidget component (#8342) 2025-10-30 14:35:32 -07:00
jnsdls b88f75b494 Add successUrl support to payment page (#8341)
<!--

## 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 various configuration files, updates the `PayPage` component with URL validation, and enhances the fetch functionality with OAuth support. It also includes the addition of testing HTML and size limits for the package.

### Detailed summary
- Added `wrap` export in `nexus-fetch.ts`.
- Introduced `biome.json`, `.size-limit.json`, `tsconfig.json`, `tsconfig.build.json`, and `knip.json` for configuration.
- Enhanced `PayPage` with `onlyUrl` validation and success URL handling.
- Added testing HTML file for `nexus-fetch`.
- Updated `package.json` with author and dependencies.
- Improved fetch functionality with OAuth and error handling.

> ✨ 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**
  - Payment page now supports redirecting to a provided success URL after payment completes.
  - New nexus-fetch library adds automatic OAuth token management, PKCE-backed sign-in flow, automatic token refresh, and integrated handling of proxy payment flows for web requests.

- **Chores**
  - Added build/configuration and size-limit files for the new nexus-fetch package.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-30 20:40:41 +00:00
MananTank c83f1c6f7a [MNY-298] SDK: Remove Bridge.routes, Update Docs (#8335)
<!--

## 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 removing the `Bridge.routes` functionality and the `useBridgeRoutes` hook from the codebase, along with related references and documentation updates in various files.

### Detailed summary

- Deleted `Routes.ts`, `Routes.test.ts`, and `useBridgeRoutes.ts` files.
- Removed references to `Bridge.routes` in multiple `.mdx` files and sidebar components.
- Updated `index.ts` to stop exporting `routes`.
- Adjusted `useSendTransactionCore` to use `Bridge.tokens` instead of `Bridge.routes`.
- Removed the `PriceComparator` class and associated methods for route comparison.

> ✨ 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

* **Breaking Changes**
  * Removed the Bridge.routes API, which previously supported route discovery with filtering by chains and tokens, pagination, and sorting
  * Removed the useBridgeRoutes React hook

* **Documentation**
  * Removed documentation pages covering bridge route discovery and filtering functionality
  * Removed related sidebar and guide navigation links

* **Tests**
  * Removed test suites for removed APIs

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-30 14:46:34 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
bc0295487b Version Packages (#8339)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 07:07:28 +07:00
FirekeeperandGitHub 90b779107d Add Solana wallet balance endpoint and UI integration (#8338) 2025-10-30 06:56:00 +07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
afe187de95 Version Packages (#8336)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 16:10:10 -07:00
759b93beff [SDK] Fix: move @storybook/react package to be a dev dependency (#8306)
Co-authored-by: Jonas Daniels <[email protected]>
2025-10-29 15:58:10 -07:00
Jonas DanielsandGitHub 8e357b3cb3 setup @thirdweb-dev/nexus package (#8332) 2025-10-29 15:55:12 -07:00
kumaryash90 6c318f83d6 Update implementations (#8334)
<!--

## 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 `MarketplaceV3` contract addresses and the `ZKSYNC_WETH` token addresses in the `implementations.ts` file.

### Detailed summary
- Updated `MarketplaceV3` address for network `232` from `0x9742f5ac11958cFAd151eBF0Fc31302fA409036E` to `0xB7387cF425eC8Da1b04b8E34De86245cb32bCA6D`.
- Updated `MarketplaceV3` address for network `37111` from `0x56Abb6a3f25DCcdaDa106191053b1CC54C196DEE` to `0xF8fd1016420c2B7832714241d36Efe55D41df126`.
- Updated `ZKSYNC_WETH` address for network `232` from `0xE5ecd226b3032910CEaa43ba92EE8232f8237553` to `0x6bDc36E20D267Ff0dd6097799f82e78907105e2F`.
- Updated `ZKSYNC_WETH` address for network `37111` from `0xaA91D645D7a6C1aeaa5988e0547267B77d33fe16` to `0xeee5a340Cdc9c179Db25dea45AcfD5FE8d4d3eB8`.

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

<!-- end pr-codex -->

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

* **Chores**
  * Updated contract addresses for zkSync networks: MarketplaceV3 and WETH addresses replaced for chain 232 and chain 37111.
  * Added a changeset marking a patch release (release metadata).
  * No public API or exported signatures changed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-29 21:14:05 +00:00
292 changed files with 11282 additions and 5556 deletions
+9 -2
View File
@@ -188,9 +188,16 @@ jobs:
- name: Build Packages
run: pnpm build
- name: Report bundle size
- name: Report bundle size (thirdweb)
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
directory: packages/thirdweb
directory: packages/thirdweb
- name: Report bundle size (nexus)
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
directory: packages/nexus
+2 -2
View File
@@ -18,7 +18,7 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
2. Formatting & Linting
- Biome governs formatting and linting; its rules live in biome.json.
- Run pnpm biome check --apply before committing.
- Run `pnpm fix` & `pnpm lint` before committing, make sure there are no linting errors.
- Avoid editor‑specific configs; rely on the shared settings.
- make sure everything builds after each file change by running `pnpm build`
@@ -136,7 +136,7 @@ export function reportContractDeployed(properties: {
}
```
- **Client-side only**: never import `posthog-js` in server components.
- **Client-side only**: never import `posthog-js` in server components.
- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event.
⸻
+1 -1
View File
@@ -10,7 +10,7 @@ module.exports = [
"/wallets",
"/account-abstraction",
"/payments",
"/payments/x402",
"/x402",
"/nexus",
"/auth",
"/in-app-wallets",
+5
View File
@@ -479,6 +479,11 @@ async function redirects() {
destination: "/tokens",
permanent: false,
},
{
source: "/payments/x402",
destination: "/x402",
permanent: false,
},
];
}
+43 -1
View File
@@ -16,6 +16,8 @@ import type {
WalletStats,
WebhookLatencyStats,
WebhookSummaryStats,
X402QueryParams,
X402SettlementStats,
} from "@/types/analytics";
import { getChains } from "./chain";
@@ -184,7 +186,7 @@ const cached_getInAppWalletUsage = unstable_cache(
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch in-app wallet usage, ${res?.status} - ${res.statusText} - ${reason}`,
`Failed to fetch user wallets usage, ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
@@ -906,3 +908,43 @@ export function getInsightUsage(
) {
return cached_getInsightUsage(normalizedParams(params), authToken);
}
const cached_getX402Settlements = unstable_cache(
async (
params: X402QueryParams,
authToken: string,
): Promise<X402SettlementStats[]> => {
const searchParams = buildSearchParams(params);
if (params.groupBy) {
searchParams.append("groupBy", params.groupBy);
}
const res = await fetchAnalytics({
authToken,
url: `v2/x402/settlements?${searchParams.toString()}`,
init: {
method: "GET",
},
});
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch x402 settlements: ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
const json = await res.json();
return json.data as X402SettlementStats[];
},
["getX402Settlements"],
{
revalidate: 60 * 60, // 1 hour
},
);
export function getX402Settlements(params: X402QueryParams, authToken: string) {
return cached_getX402Settlements(normalizedParams(params), authToken);
}
+2 -1
View File
@@ -21,7 +21,8 @@ export type AuthOption =
| "steam"
| "apple"
| "coinbase"
| "line";
| "line"
| "epic";
export type Ecosystem = {
name: string;
@@ -38,11 +38,11 @@ type ShadcnSidebarBaseLink = {
isActive?: (pathname: string) => boolean;
};
type ShadcnSidebarLink =
export type ShadcnSidebarLink =
| ShadcnSidebarBaseLink
| {
group: string;
links: ShadcnSidebarBaseLink[];
links: ShadcnSidebarLink[];
}
| {
separator: true;
@@ -97,10 +97,7 @@ export function FullWidthSidebarLayout(props: {
function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
const activeLink = useActiveShadcnSidebarLink(props.links);
const parentSubNav = props.links.find(
(link) =>
"subMenu" in link && link.links.some((l) => l.href === activeLink?.href),
);
const parentSubNav = findParentSubmenu(props.links, activeLink?.href);
return (
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
@@ -109,7 +106,7 @@ function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
className="h-4 bg-muted-foreground/50"
orientation="vertical"
/>
{parentSubNav && "subMenu" in parentSubNav && (
{parentSubNav && (
<>
<span className="text-sm">{parentSubNav.subMenu.label}</span>
<ChevronRightIcon className="size-4 text-muted-foreground/50 -mx-1.5" />
@@ -131,24 +128,65 @@ function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) {
return pathname?.startsWith(link.href);
}
for (const link of links) {
if ("links" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
function walk(
navLinks: ShadcnSidebarLink[],
): ShadcnSidebarBaseLink | undefined {
for (const link of navLinks) {
if ("subMenu" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
}
}
} else if ("href" in link) {
if (isActive(link)) {
return link;
}
}
} else if ("href" in link) {
if (isActive(link)) {
return link;
if ("links" in link && !("subMenu" in link)) {
const nested = walk(link.links);
if (nested) {
return nested;
}
}
}
return undefined;
}
return walk(links);
}, [links, pathname]);
return activeLink;
}
function findParentSubmenu(
links: ShadcnSidebarLink[],
activeHref: string | undefined,
): Extract<ShadcnSidebarLink, { subMenu: unknown }> | undefined {
if (!activeHref) {
return undefined;
}
for (const link of links) {
if ("subMenu" in link) {
if (link.links.some((subLink) => subLink.href === activeHref)) {
return link;
}
}
if ("links" in link && !("subMenu" in link)) {
const nested = findParentSubmenu(link.links, activeHref);
if (nested) {
return nested;
}
}
}
return undefined;
}
function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) {
const pathname = usePathname();
@@ -12,10 +12,6 @@ import {
type ProjectPageProps = {
header: ProjectPageHeaderProps;
footer?: ProjectPageFooterProps;
/**
* @deprecated only for legacy pages where we still need internal tabs for the moment, currently:
* - /webhooks
*/
tabs?: TabPathLink[];
};
@@ -337,7 +337,7 @@ export function InAppWalletUsersPageContent(
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="in-app wallets"
title="User wallets"
/>
<div className="flex justify-center gap-3 border-t bg-card p-6">
@@ -5,7 +5,6 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useLocalStorage } from "@/hooks/useLocalStorage";
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
function AnnouncementBannerUI(props: {
href: string;
label: string;
@@ -45,12 +44,11 @@ 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 null;
return (
<AnnouncementBannerUI
href="https://blog.thirdweb.com/changelog/project-view-update-wallets/"
label="Project View Update - Wallets"
trackingLabel="project-view-update-wallets"
/>
);
}
@@ -1,5 +1,5 @@
export const siwaExamplePrompts = [
"How do I add in-app wallet with sign in with google to my react app?",
"How do I add user wallet with sign in with google to my react app?",
"How do I send a transaction in Unity?",
"What does this contract revert error mean?",
"I see thirdweb support id in my console log, can you help me?",
+1 -1
View File
@@ -248,7 +248,7 @@ export function teamSubscriptionsStub(
// In-App Wallets
{
amount: usage.inAppWalletAmount?.amount || 0,
description: `${usage.inAppWalletAmount?.quantity || 0} x In-App Wallets (Tier 1 at $0.00 / month)`,
description: `${usage.inAppWalletAmount?.quantity || 0} x User Wallets (Tier 1 at $0.00 / month)`,
thirdwebSku: "usage:in_app_wallet",
},
// AA Sponsorship
+59
View File
@@ -94,3 +94,62 @@ export interface AnalyticsQueryParams {
period?: "day" | "week" | "month" | "year" | "all";
limit?: number;
}
export interface X402SettlementsOverall {
date: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByChainId {
date: string;
chainId: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByPayer {
date: string;
payer: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
interface X402SettlementsByReceiver {
date: string;
receiver: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByResource {
date: string;
resource: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
interface X402SettlementsByAsset {
date: string;
asset: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export type X402SettlementStats =
| X402SettlementsOverall
| X402SettlementsByChainId
| X402SettlementsByPayer
| X402SettlementsByReceiver
| X402SettlementsByResource
| X402SettlementsByAsset;
export interface X402QueryParams extends AnalyticsQueryParams {
groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset" | "receiver";
}
+2
View File
@@ -7,6 +7,8 @@ const customClaimAmounts: Record<number, number> = {
531050104: 5,
// Arc testnet
5042002: 1,
// Injective testnet
1439: 1,
};
const defaultClaimAmount = 0.01;
+1 -1
View File
@@ -1,6 +1,6 @@
const usdCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 2, // prefix with $
maximumFractionDigits: 6, // prefix with $
minimumFractionDigits: 0, // don't show decimal places if value is a whole number
notation: "compact", // at max 2 decimal places
roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations
+1 -1
View File
@@ -23,7 +23,7 @@ export const TEAM_PLANS: Record<
features: [
"Email Support",
"48hr Guaranteed Response",
"Custom In-App Wallet Auth",
"Custom User Wallet Auth",
],
price: 99,
subTitle: "Everything in Starter, plus:",
@@ -225,6 +225,10 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
moduleInfo: {
bytecodeUri: selectedModule.metadata.bytecodeUri,
},
isStylus:
(selectedModule.metadata.compilers?.stylus &&
selectedModule.metadata.compilers?.stylus?.length > 0) ||
false,
});
},
queryKey: [
@@ -432,6 +436,7 @@ async function isModuleCompatible(options: {
moduleInfo: {
bytecodeUri: string;
};
isStylus: boolean;
client: ThirdwebClient;
}) {
// 1. get module's bytecode
@@ -444,15 +449,17 @@ async function isModuleCompatible(options: {
// 2. check compatibility with core and installed modules
try {
const isCompatible = await checkModulesCompatibility({
chain: options.contractInfo.chain,
client: options.client,
coreBytecode: options.contractInfo.bytecode,
moduleBytecodes: [
moduleBytecode,
...options.contractInfo.installedModuleBytecodes,
],
});
const isCompatible =
options.isStylus ||
(await checkModulesCompatibility({
chain: options.contractInfo.chain,
client: options.client,
coreBytecode: options.contractInfo.bytecode,
moduleBytecodes: [
moduleBytecode,
...options.contractInfo.installedModuleBytecodes,
],
}));
return isCompatible;
} catch (e) {
@@ -10,7 +10,11 @@ import {
CircleXIcon,
} from "lucide-react";
import Link from "next/link";
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import type { Status, Token } from "thirdweb/bridge";
import { status } from "thirdweb/bridge";
import { toTokens } from "thirdweb/utils";
@@ -123,6 +127,8 @@ function TokenInfo(props: {
const isNativeToken =
props.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
const tokenAddress = getAddress(props.token.address);
return (
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
<div className="flex justify-between items-center">
@@ -206,7 +212,7 @@ function TokenInfo(props: {
<div className="space-y-1">
<p className="text-sm text-foreground ">{props.addressLabel}</p>
<WalletAddress
address={props.walletAddress}
address={getAddress(props.walletAddress)}
client={props.client}
className="py-0.5 h-auto text-sm [&>*]:!font-sans tabular-nums [&>*]:font-normal text-muted-foreground hover:text-foreground"
iconClassName="size-3"
@@ -222,8 +228,8 @@ function TokenInfo(props: {
? `/${chainQuery.data?.slug || props.token.chainId}`
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
}
textToShow={props.token.address}
textToCopy={props.token.address}
textToShow={tokenAddress}
textToCopy={tokenAddress}
copyTooltip="Copy Token Address"
className="-translate-x-1"
/>
@@ -8,7 +8,7 @@ import {
Clock4Icon,
InfoIcon,
} from "lucide-react";
import { toTokens } from "thirdweb";
import { getAddress, toTokens } from "thirdweb";
import { status } from "thirdweb/bridge";
import type { ChainMetadata } from "thirdweb/chains";
import {
@@ -153,6 +153,9 @@ function GeneericTxDetails(props: {
const timestamp = getDatefromTimestamp(block.timestamp);
const fromAddress = getAddress(transaction.from);
const toAddress = transaction.to ? getAddress(transaction.to) : undefined;
return (
<div className="border rounded-xl p-4 lg:p-6 bg-card">
{/* section 1 */}
@@ -216,8 +219,8 @@ function GeneericTxDetails(props: {
<section className="border-b py-6 space-y-5 lg:space-y-3">
<GridItem label="From" tooltip="The sending party of the transaction.">
<CopyTextButton
textToCopy={transaction.from}
textToShow={transaction.from}
textToCopy={fromAddress}
textToShow={fromAddress}
tooltip="Copy from address"
variant="ghost"
copyIconPosition="right"
@@ -225,14 +228,14 @@ function GeneericTxDetails(props: {
/>
</GridItem>
{transaction.to && (
{toAddress && (
<GridItem
label="Interacted with (To)"
tooltip="The receiving party of the transaction (could be a contract address)."
>
<CopyTextButton
textToCopy={transaction.to}
textToShow={transaction.to}
textToCopy={toAddress}
textToShow={toAddress}
tooltip="Copy to address"
variant="ghost"
copyIconPosition="right"
@@ -183,13 +183,64 @@ const STYLUS = {
],
description:
"Airdrop your NFTs or tokens to a large number of recipients. Built with Arbitrum Stylus.",
displayName: "Arbitrum Stylus Contracts",
displayName: "Airdrop - Arbitrum Stylus",
id: "stylus",
name: "Stylus",
showInExplore: true,
isBeta: true,
} satisfies ExploreCategory;
const MODULAR_CONTRACTS_STYLUS = {
contracts: [
// erc721 token
[
"thirdweb.eth/ERC721CoreInitializable",
[
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC721",
"deployer.thirdweb.eth/BatchMetadataERC721",
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC721",
],
{
description: "ERC721 NFTs that only owners can mint.",
title: "Modular NFT Collection",
},
],
// erc1155 token
[
"thirdweb.eth/ERC1155CoreInitializable",
[
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC1155",
"deployer.thirdweb.eth/BatchMetadataERC1155",
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC1155",
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
],
{
description: "ERC1155 NFTs that only owners can mint.",
title: "Modular Edition",
},
],
// erc20 token
[
"thirdweb.eth/ERC20CoreInitializable",
[
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC20",
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC20",
],
{
description: "ERC20 Tokens that only owners can mint.",
title: "Modular Token",
},
],
],
description:
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
displayName: "Modular Contracts - Arbitrum Stylus",
id: "modular-contracts",
name: "modular",
showInExplore: true,
isBeta: true,
} satisfies ExploreCategory;
const CATEGORIES: Record<string, ExploreCategory> = {
[POPULAR.id]: POPULAR,
[NFTS.id]: NFTS,
@@ -203,6 +254,7 @@ const CATEGORIES: Record<string, ExploreCategory> = {
[COMMERCE.id]: COMMERCE,
[STAKING.id]: STAKING,
[GOVERNANCE.id]: GOVERNANCE,
[MODULAR_CONTRACTS_STYLUS.id]: MODULAR_CONTRACTS_STYLUS,
};
export function getCategory(id: string) {
@@ -92,7 +92,7 @@ export function TeamSidebarLayout(props: {
label: "Documentation",
},
{
href: "https://playground.thirdweb.com/connect/sign-in/button",
href: "https://playground.thirdweb.com/wallets/sign-in/button",
icon: BoxIcon,
label: "Playground",
},
@@ -58,6 +58,7 @@ const authOptions = [
"apple",
"coinbase",
"line",
"epic",
] as const satisfies AuthOption[];
type AuthOptionsFormData = {
@@ -6,9 +6,9 @@ export const SmartWalletsBillingAlert = (props: { teamSlug: string }) => {
return (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
<AlertTitle>Gas Sponsorship on Mainnet</AlertTitle>
<AlertDescription>
To enable AA on mainnet chains,{" "}
To enable Gas Sponsorship on mainnet chains,{" "}
<UnderlineLink href={`/team/${props.teamSlug}/~/billing`}>
subscribe to a billing plan.
</UnderlineLink>
@@ -14,7 +14,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
export function DefaultFactoriesSection() {
const data = [
@@ -38,13 +37,6 @@ export function DefaultFactoriesSection() {
</h3>
<p className="text-muted-foreground text-sm">
Ready to use account factories that are pre-deployed on each chain.{" "}
<UnderlineLink
href="https://playground.thirdweb.com/connect/account-abstraction/connect"
rel="noopener noreferrer"
target="_blank"
>
Learn how to use these in your apps
</UnderlineLink>
</p>
</div>
@@ -1,163 +1,11 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getUserOpUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { SmartAccountIcon } from "@/icons/SmartAccountIcon";
import { getAbsoluteUrl } from "@/utils/vercel";
import { AccountAbstractionSummary } from "./AccountAbstractionAnalytics/AccountAbstractionSummary";
import { SmartWalletsBillingAlert } from "./Alerts";
import { AccountAbstractionAnalytics } from "./aa-analytics";
import { searchParamLoader } from "./search-params";
interface PageParams {
team_slug: string;
project_slug: string;
}
import { redirect } from "next/navigation";
// Redirect old Account Abstraction page to new Sponsored Gas Overview
export default async function Page(props: {
params: Promise<PageParams>;
searchParams: Promise<SearchParams>;
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const interval = searchParams.interval ?? "week";
const rangeType = searchParams.range || "last-120";
const range: Range = {
from:
rangeType === "custom"
? searchParams.from
: getLastNDaysRange(rangeType).from,
to:
rangeType === "custom"
? searchParams.to
: getLastNDaysRange(rangeType).to,
type: rangeType,
};
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
const isBundlerServiceEnabled = !!project.services.find(
(s) => s.name === "bundler",
);
const hasSmartWalletsWithoutBilling =
isBundlerServiceEnabled &&
team.billingStatus !== "validPayment" &&
team.billingStatus !== "pastDue";
return (
<ProjectPage
header={{
icon: SmartAccountIcon,
client,
title: "Account Abstraction",
description:
"Integrate EIP-7702 and EIP-4337 compliant smart accounts for gasless sponsorships and more.",
actions: null,
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/account-abstraction`,
},
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions/sponsor",
},
{
type: "playground",
href: "https://playground.thirdweb.com/account-abstraction/eip-7702",
},
],
}}
>
{hasSmartWalletsWithoutBilling && (
<>
<SmartWalletsBillingAlert teamSlug={params.team_slug} />
<div className="h-10" />
</>
)}
<div className="flex grow flex-col gap-10">
<AccountAbstractionSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
<AccountAbstractionAnalytics
client={client}
projectId={project.id}
teamId={project.teamId}
teamSlug={params.team_slug}
userOpStats={userOpStats}
/>
</div>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`,
);
}
const seo = {
desc: "Add account abstraction to your web3 app & unlock powerful features for seamless onboarding, customizable transactions, & maximum security. Get started.",
title: "The Complete Account Abstraction Toolkit | thirdweb",
};
export const metadata: Metadata = {
description: seo.desc,
openGraph: {
description: seo.desc,
images: [
{
alt: seo.title,
height: 630,
url: `${getAbsoluteUrl()}/assets/og-image/dashboard-wallets-smart-wallet.png`,
width: 1200,
},
],
title: seo.title,
},
title: seo.title,
};
@@ -371,7 +371,7 @@ export function AccountAbstractionSettingsPage(
of the{" "}
<UnderlineLink
className="text-primary-500"
href="https://portal.thirdweb.com/wallets/smart-wallet/infrastructure#supported-chains"
href="https://thirdweb.com/chainlist?service=account-abstraction"
rel="noopener noreferrer"
target="_blank"
>
@@ -69,6 +69,9 @@ export default async function Page(props: {
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/payments`,
},
links: [
{
type: "docs",
@@ -1,23 +1,23 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import {
ArrowLeftRightIcon,
BookTextIcon,
BoxIcon,
DatabaseIcon,
HomeIcon,
LockIcon,
RssIcon,
Settings2Icon,
WebhookIcon,
} from "lucide-react";
import { FullWidthSidebarLayout } from "@/components/blocks/full-width-sidebar-layout";
import { Badge } from "@/components/ui/badge";
import {
FullWidthSidebarLayout,
type ShadcnSidebarLink,
} from "@/components/blocks/full-width-sidebar-layout";
import { BridgeIcon } from "@/icons/BridgeIcon";
import { ContractIcon } from "@/icons/ContractIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { PayIcon } from "@/icons/PayIcon";
import { SmartAccountIcon } from "@/icons/SmartAccountIcon";
import { TokenIcon } from "@/icons/TokenIcon";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
@@ -25,136 +25,140 @@ export function ProjectSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
}) {
const contentSidebarLinks = [
{
exactMatch: true,
href: props.layoutPath,
icon: HomeIcon,
label: "Overview",
},
{
separator: true,
},
{
group: "Build",
links: [
{
subMenu: {
icon: WalletProductIcon,
label: "Wallets",
},
links: [
{
href: `${props.layoutPath}/wallets/user-wallets`,
label: "User Wallets",
},
{
href: `${props.layoutPath}/wallets/server-wallets`,
label: "Server Wallets",
},
{
href: `${props.layoutPath}/wallets/sponsored-gas`,
label: "Gas Sponsorship",
},
],
},
{
href: `${props.layoutPath}/contracts`,
icon: ContractIcon,
label: "Contracts",
},
{
href: `${props.layoutPath}/ai`,
icon: NebulaIcon,
label: "AI",
},
],
},
{
separator: true,
},
{
group: "Monetize",
links: [
{
href: `${props.layoutPath}/x402`,
icon: PayIcon,
label: (
<span className="flex items-center gap-2">
x402 <Badge>New</Badge>
</span>
),
},
{
href: `${props.layoutPath}/bridge`,
icon: BridgeIcon,
label: "Bridge",
},
{
href: `${props.layoutPath}/tokens`,
icon: TokenIcon,
label: "Tokens",
},
],
},
{
separator: true,
},
{
group: "Scale",
links: [
{
href: `${props.layoutPath}/insight`,
icon: InsightIcon,
label: "Insight",
},
{
href: `${props.layoutPath}/rpc`,
icon: RssIcon,
label: "RPC",
},
// linkely want to move this to `team` level eventually
{
href: `${props.layoutPath}/engine`,
icon: DatabaseIcon,
label: "Engine",
},
],
},
] satisfies ShadcnSidebarLink[];
const footerSidebarLinks = [
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
label: "Project Settings",
},
{
separator: true,
},
{
href: "https://portal.thirdweb.com",
icon: BookTextIcon,
label: "Documentation",
},
{
href: "https://playground.thirdweb.com/wallets/sign-in/button",
icon: BoxIcon,
label: "Playground",
},
] satisfies ShadcnSidebarLink[];
return (
<FullWidthSidebarLayout
contentSidebarLinks={[
{
exactMatch: true,
href: props.layoutPath,
icon: HomeIcon,
label: "Overview",
},
{
separator: true,
},
{
group: "Build",
links: [
{
href: `${props.layoutPath}/wallets`,
icon: WalletProductIcon,
label: "Wallets",
},
{
href: `${props.layoutPath}/transactions`,
icon: ArrowLeftRightIcon,
label: (
<span className="flex items-center gap-2">
Transactions <Badge>New</Badge>
</span>
),
},
{
href: `${props.layoutPath}/contracts`,
icon: ContractIcon,
label: "Contracts",
},
{
href: `${props.layoutPath}/ai`,
icon: NebulaIcon,
label: "AI",
},
],
},
{
separator: true,
},
{
group: "Monetize",
links: [
{
href: `${props.layoutPath}/payments`,
icon: PayIcon,
label: "Payments",
},
{
href: `${props.layoutPath}/bridge`,
icon: BridgeIcon,
label: "Bridge",
},
{
href: `${props.layoutPath}/tokens`,
icon: TokenIcon,
label: "Tokens",
},
],
},
{
separator: true,
},
{
group: "Scale",
links: [
{
href: `${props.layoutPath}/insight`,
icon: InsightIcon,
label: "Insight",
},
{
href: `${props.layoutPath}/account-abstraction`,
icon: SmartAccountIcon,
label: "Account Abstraction",
},
{
href: `${props.layoutPath}/rpc`,
icon: RssIcon,
label: "RPC",
},
{
href: `${props.layoutPath}/vault`,
icon: LockIcon,
label: "Vault",
},
// linkely want to move this to `team` level eventually
{
href: `${props.layoutPath}/engine`,
icon: DatabaseIcon,
label: "Engine",
},
],
},
]}
footerSidebarLinks={[
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
label: "Project Settings",
},
{
separator: true,
},
{
href: "https://portal.thirdweb.com",
icon: BookTextIcon,
label: "Documentation",
},
{
href: "https://playground.thirdweb.com/connect/sign-in/button",
icon: BoxIcon,
label: "Playground",
},
]}
contentSidebarLinks={contentSidebarLinks}
footerSidebarLinks={footerSidebarLinks}
>
{props.children}
</FullWidthSidebarLayout>
@@ -14,6 +14,7 @@ type AggregatedMetrics = {
export function ProjectHighlightsCard(props: {
selectedChart: string | undefined;
aggregatedUserStats: InAppWalletStats[];
userStats: InAppWalletStats[];
volumeStats: UniversalBridgeStats[];
teamSlug: string;
@@ -22,6 +23,7 @@ export function ProjectHighlightsCard(props: {
}) {
const {
selectedChart,
aggregatedUserStats,
userStats,
volumeStats,
teamSlug,
@@ -70,6 +72,12 @@ export function ProjectHighlightsCard(props: {
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return aggregatedUserStats.reduce(
(acc, curr) => acc + curr.uniqueWalletsConnected,
0,
);
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
@@ -378,8 +378,18 @@ async function AsyncAppHighlightsCard(props: {
params: PageParams;
authToken: string;
}) {
const [walletUserStatsTimeSeries, universalBridgeUsage] =
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getInAppWalletUsage(
{
from: props.range.from,
@@ -408,6 +418,11 @@ async function AsyncAppHighlightsCard(props: {
) {
return (
<ProjectHighlightsCard
aggregatedUserStats={
aggregatedUserStats.status === "fulfilled"
? aggregatedUserStats.value
: []
}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
teamSlug={props.params.team_slug}
@@ -81,7 +81,7 @@ export default async function Page(props: {
},
{
type: "playground",
href: "https://playground.thirdweb.com/payments/ui-components",
href: "https://playground.thirdweb.com/bridge",
},
{
type: "api",
@@ -93,20 +93,20 @@ export default async function Page(props: {
center: {
links: [
{
href: "https://playground.thirdweb.com/payments/ui-components",
label: "UI Component",
href: "https://playground.thirdweb.com/bridge/buy-widget",
label: "Buy Widget",
},
{
href: "https://playground.thirdweb.com/connect/payments/fund-wallet",
label: "Buy Crypto",
href: "https://playground.thirdweb.com/bridge/checkout-widget",
label: "Checkout Widget",
},
{
href: "https://playground.thirdweb.com/connect/payments/commerce",
label: "Checkout",
href: "https://playground.thirdweb.com/bridge/transaction-widget",
label: "Transaction Widget",
},
{
href: "https://playground.thirdweb.com/connect/payments/transactions",
label: "Transactions",
href: "https://playground.thirdweb.com/bridge/swap-widget",
label: "Swap Widget",
},
],
title: "Demos",
@@ -2,9 +2,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
import { formatDistanceToNow } from "date-fns";
import {
DotIcon,
InfoIcon,
MoreVerticalIcon,
PencilIcon,
PlusIcon,
@@ -25,6 +27,7 @@ import {
updateWebhook,
} from "@/api/universal-bridge/developer";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
@@ -435,28 +438,30 @@ function BridgeWebhookModalContent(
)}
/>
<FormField
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="v2" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">v2</SelectItem>
<SelectItem value="1">v1</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the data format of the webhook payload (v2 recommended,
v1 for legacy users).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{props.type === "edit" && props.webhook.version === 1 && (
<FormField
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="v2" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">v2</SelectItem>
<SelectItem value="1">v1</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the data format of the webhook payload (v2 recommended,
v1 for legacy users).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{props.type === "create" && (
<section>
@@ -486,6 +491,22 @@ function BridgeWebhookModalContent(
</section>
)}
<Alert variant="warning">
<InfoIcon className="size-4" />
<AlertTitle> Verify payload before processing </AlertTitle>
<AlertDescription>
Make sure to verify receiver, destination chain, token address and
amount to ensure that it represents the expected state of the
transaction before processing it in your backend.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/bridge/webhooks#verify-payload"
target="_blank"
>
Learn more
</UnderlineLink>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
className="gap-2"
@@ -220,9 +220,9 @@ export function ProjectGeneralSettingsPageUI(props: {
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
const paths = {
aaConfig: `${projectLayout}/settings/account-abstraction`,
aaConfig: `${projectLayout}/wallets/sponsored-gas/configuration`,
afterDeleteRedirectTo: `/team/${props.teamSlug}`,
inAppConfig: `${projectLayout}/settings/wallets`,
inAppConfig: `${projectLayout}/wallets/user-wallets/configuration`,
payConfig: `${projectLayout}/settings/payments`,
};
@@ -1,95 +0,0 @@
import { CircleAlertIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { loginRedirect } from "@/utils/redirects";
import { DefaultFactoriesSection } from "../../account-abstraction/factories/AccountFactories";
import { YourFactoriesSection } from "../../account-abstraction/factories/AccountFactories/your-factories";
import { AccountAbstractionSettingsPage } from "../../account-abstraction/settings/SponsorshipPolicies";
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [team, project, authToken] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/team/${team_slug}/settings/account-abstraction`);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const bundlerService = project.services.find((s) => s.name === "bundler");
return (
<div>
<ProjectSettingsBreadcrumb
teamSlug={team_slug}
projectSlug={project_slug}
page="Account Abstraction"
/>
<div className="space-y-8 mt-6">
{!bundlerService ? (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
<AlertDescription>
Enable Account Abstraction service in{" "}
<UnderlineLink
href={`/team/${team_slug}/${project_slug}/settings`}
>
project settings
</UnderlineLink>{" "}
to configure the sponsorship rules
</AlertDescription>
</Alert>
) : (
<>
<AccountAbstractionSettingsPage
bundlerService={bundlerService}
client={client}
project={project}
teamId={team.id}
teamSlug={team.slug}
validTeamPlan={getValidTeamPlan(team)}
/>
<DefaultFactoriesSection />
<YourFactoriesSection
authToken={authToken}
clientThirdwebClient={client}
projectId={project.id}
projectSlug={project.slug}
teamId={team.id}
teamSlug={team.slug}
/>
</>
)}
</div>
</div>
);
}
@@ -1,6 +1,4 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
export type SMSCountryTiers = {
tier1: string[];
@@ -10,52 +8,3 @@ export type SMSCountryTiers = {
tier5: string[];
tier6: string[];
};
export async function getSMSCountryTiers() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/sms/list-country-tiers`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
}
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
@@ -13,7 +13,6 @@ import type { Project } from "@/api/project/projects";
import type { Team } from "@/api/team/get-team";
import { FileInput } from "@/components/blocks/FileInput";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
@@ -31,7 +30,6 @@ import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { planToTierRecordForGating } from "@/constants/planToTierRecord";
import { updateProjectClient } from "@/hooks/useApi";
import { cn } from "@/lib/utils";
import {
type ApiKeyEmbeddedWalletsValidationSchema,
@@ -57,82 +55,6 @@ type UpdateAPIKeyTrackingData = {
hasCustomAuthEndpoint: boolean;
};
export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) {
const updateProject = useMutation({
mutationFn: async (projectValues: Partial<Project>) => {
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.teamId,
},
projectValues,
);
},
});
function handleUpdateProject(projectValues: Partial<Project>) {
updateProject.mutate(projectValues, {
onError: (err) => {
toast.error("Failed to update an API Key");
console.error(err);
},
onSuccess: () => {
toast.success("In-App Wallet API Key configuration updated");
},
});
}
return (
<InAppWalletSettingsPageUI
{...props}
isUpdating={updateProject.isPending}
smsCountryTiers={props.smsCountryTiers}
updateApiKey={handleUpdateProject}
/>
);
}
const InAppWalletSettingsPageUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
projectValues: Partial<Project>,
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
smsCountryTiers: SMSCountryTiers;
}
> = (props) => {
const embeddedWalletService = props.project.services.find(
(service) => service.name === "embeddedWallets",
);
if (!embeddedWalletService) {
return (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>In-App wallets service is disabled</AlertTitle>
<AlertDescription>
Enable In-App wallets service in the{" "}
<UnderlineLink
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
project settings
</UnderlineLink>{" "}
to configure settings
</AlertDescription>
</Alert>
);
}
return (
<InAppWalletSettingsUI
{...props}
client={props.client}
embeddedWalletService={embeddedWalletService}
/>
);
};
export const InAppWalletSettingsUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
@@ -191,7 +113,7 @@ export const InAppWalletSettingsUI: React.FC<
) {
return toast.error("Custom JSON Web Token configuration is invalid", {
description:
"To use In-App Wallets with Custom JSON Web Token, provide JWKS URI and AUD.",
"To use User Wallets with Custom JSON Web Token, provide JWKS URI and AUD.",
dismissible: true,
duration: 9000,
});
@@ -202,7 +124,7 @@ export const InAppWalletSettingsUI: React.FC<
"Custom Authentication Endpoint configuration is invalid",
{
description:
"To use In-App Wallets with Custom Authentication Endpoint, provide a valid URL.",
"To use User Wallets with Custom Authentication Endpoint, provide a valid URL.",
dismissible: true,
duration: 9000,
},
@@ -1,45 +1,102 @@
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
"use client";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@workspace/ui/components/spinner";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { getLastNDaysRange } from "@/components/analytics/date-range-selector";
import { normalizeTimeISOString } from "@/lib/time";
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
import { getTransactionAnalyticsSummary } from "../lib/analytics-summary.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { TransactionAnalyticsFilter } from "./filter";
import { TransactionsChartCard } from "./tx-chart/tx-chart";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
import { EngineChecklist } from "./ftux.client";
import { TransactionsAnalytics } from "./tx-chart/tx-chart";
export function TransactionsAnalyticsPageContent(props: {
searchParams: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
project: Project;
showAnalytics: boolean;
wallets?: Wallet[];
wallets: Wallet[];
teamSlug: string;
client: ThirdwebClient;
authToken: string;
teamId: string;
isManagedVault: boolean;
testTxWithWallet: string | undefined;
testSolanaTxWithWallet: string | undefined;
solanaWallets: SolanaWallet[];
}) {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<div className="flex grow flex-col gap-6">
{props.showAnalytics && (
<>
<div className="flex justify-end">
<TransactionAnalyticsFilter />
</div>
<TransactionsChartCard
project={props.project}
searchParams={props.searchParams}
teamSlug={props.teamSlug}
wallets={props.wallets ?? []}
/>
</>
)}
<UnifiedTransactionsTable
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
/>
const defaultRange = useMemo(() => {
const range = getLastNDaysRange("last-30");
return {
from: normalizeTimeISOString(range.from),
to: normalizeTimeISOString(range.to),
};
}, []);
const engineTxSummaryQuery = useQuery({
queryKey: [
"engine-tx-analytics-summary",
props.teamId,
props.project.publishableKey,
props.authToken,
defaultRange.from,
defaultRange.to,
],
queryFn: async () => {
const data = await getTransactionAnalyticsSummary({
clientId: props.project.publishableKey,
teamId: props.teamId,
authToken: props.authToken,
startDate: defaultRange.from,
endDate: defaultRange.to,
});
return data;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
if (engineTxSummaryQuery.isPending) {
return (
<div className="flex h-[642px] grow items-center justify-center bg-card rounded-xl border">
<Spinner className="size-10" />
</div>
</ResponsiveSearchParamsProvider>
);
}
const hasTransactions = engineTxSummaryQuery.data
? engineTxSummaryQuery.data.totalCount > 0
: false;
return (
<div className="flex grow flex-col gap-10">
<EngineChecklist
isManagedVault={props.isManagedVault}
client={props.client}
hasTransactions={hasTransactions}
project={props.project}
teamSlug={props.teamSlug}
testTxWithWallet={props.testTxWithWallet}
testSolanaTxWithWallet={props.testSolanaTxWithWallet}
wallets={props.wallets}
solanaWallets={props.solanaWallets}
/>
{props.showAnalytics && hasTransactions && (
<TransactionsAnalytics
project={props.project}
authToken={props.authToken}
teamSlug={props.teamSlug}
wallets={props.wallets ?? []}
/>
)}
<UnifiedTransactionsTable
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
/>
</div>
);
}
@@ -1,51 +0,0 @@
"use client";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import { DateRangeSelector } from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { normalizeTimeISOString } from "@/lib/time";
import { getTxAnalyticsFiltersFromSearchParams } from "../lib/utils";
export function TransactionAnalyticsFilter() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getTxAnalyticsFiltersFromSearchParams({
from: responsiveSearchParams.from,
interval: responsiveSearchParams.interval,
to: responsiveSearchParams.to,
});
return (
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
popoverAlign="end"
range={range}
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
return {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
});
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
return {
...v,
interval: newInterval,
};
});
}}
/>
</div>
);
}
@@ -1,7 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ActivityIcon, CoinsIcon } from "lucide-react";
import { toEther } from "thirdweb/utils";
import { StatCard } from "@/components/analytics/stat"; // Assuming correct path
import type { TransactionSummaryData } from "../lib/analytics";
import {
getTransactionAnalyticsSummary,
type TransactionSummaryData,
} from "../lib/analytics-summary.client";
// Renders the UI based on fetched data or pending state
function TransactionAnalyticsSummaryUI(props: {
@@ -88,9 +93,37 @@ function TransactionAnalyticsSummaryUI(props: {
export function TransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
initialData: TransactionSummaryData | undefined;
authToken: string;
startDate?: string;
endDate?: string;
}) {
const engineTxSummaryQuery = useQuery({
queryKey: [
"engine-tx-analytics-summary",
props.teamId,
props.clientId,
props.authToken,
props.startDate,
props.endDate,
],
queryFn: async () => {
const data = await getTransactionAnalyticsSummary({
clientId: props.clientId,
teamId: props.teamId,
authToken: props.authToken,
startDate: props.startDate,
endDate: props.endDate,
});
return data;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return (
<TransactionAnalyticsSummaryUI data={props.initialData} isPending={false} />
<TransactionAnalyticsSummaryUI
data={engineTxSummaryQuery.data}
isPending={engineTxSummaryQuery.isPending}
/>
);
}
@@ -0,0 +1,75 @@
"use client";
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
import type { TransactionStats } from "@/types/analytics";
export async function getTransactionsChartData({
teamId,
clientId,
from,
to,
interval,
authToken,
}: {
teamId: string;
clientId: string;
from: string;
to: string;
interval: "day" | "week";
authToken: string;
}): Promise<TransactionStats[]> {
const filters = {
endDate: to,
resolution: interval,
startDate: from,
};
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
{
body: JSON.stringify(filters),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-team-id": teamId,
},
method: "POST",
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 400) {
return [];
}
// TODO - need to handle this error state, like we do with the connect charts
throw new Error(
`Error fetching transactions chart data: ${response.status} ${
response.statusText
} - ${await response.text().catch(() => "Unknown error")}`,
);
}
type TransactionsChartResponse = {
result: {
analytics: Array<{
timeBucket: string;
chainId: string;
count: number;
}>;
metadata: {
resolution: string;
startDate: string;
endDate: string;
};
};
};
const data = (await response.json()) as TransactionsChartResponse;
return data.result.analytics.map((stat) => ({
chainId: Number(stat.chainId),
count: stat.count,
date: stat.timeBucket,
}));
}
@@ -108,10 +108,10 @@ export function TransactionsChartCardUI(props: {
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Daily Transactions
Transactions
</h3>
<p className="text-muted-foreground text-sm">
Amount of daily transactions by chain.
transactions broken down by chain
</p>
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
@@ -184,7 +184,7 @@ function EmptyChartContent(props: {
<Button
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/vault`,
`/team/${props.teamSlug}/${props.project.slug}/wallets/server-wallets/configuration`,
);
}}
variant="primary"
@@ -194,13 +194,7 @@ function EmptyChartContent(props: {
</div>
</>
) : (
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
<span className="!pointer-events-auto relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
Waiting for transactions...
</p>
<p className="text-sm text-muted-foreground">No transactions found</p>
)}
</div>
);
@@ -1,73 +1,79 @@
import { ResponsiveSuspense } from "responsive-rsc";
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { Project } from "@/api/project/projects";
import { getTransactionsChart } from "../../lib/analytics";
import { getTxAnalyticsFiltersFromSearchParams } from "../../lib/utils";
import {
DateRangeSelector,
getLastNDaysRange,
} from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { normalizeTimeISOString } from "@/lib/time";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionAnalyticsSummary } from "../summary";
import { getTransactionsChartData } from "./data";
import { TransactionsChartCardUI } from "./tx-chart-ui";
async function AsyncTransactionsChartCard(props: {
from: string;
to: string;
interval: "day" | "week";
export function TransactionsAnalytics(props: {
project: Project;
wallets: Wallet[];
authToken: string;
teamSlug: string;
}) {
const data = await getTransactionsChart({
const [range, setRange] = useState(() => getLastNDaysRange("last-30"));
const [interval, setInterval] = useState<"day" | "week">("day");
const normalizedFrom = normalizeTimeISOString(range.from);
const normalizedTo = normalizeTimeISOString(range.to);
const params = {
clientId: props.project.publishableKey,
from: props.from,
interval: props.interval,
from: normalizedFrom,
interval: interval,
teamId: props.project.teamId,
to: props.to,
to: normalizedTo,
authToken: props.authToken,
};
const engineTxAnalytics = useQuery({
queryKey: ["engine-tx-analytics", params],
queryFn: async () => {
const data = await getTransactionsChartData(params);
return data;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return (
<TransactionsChartCardUI
isPending={false}
project={props.project}
teamSlug={props.teamSlug}
userOpStats={data}
wallets={props.wallets}
/>
);
}
<div className="flex flex-col gap-6">
<div className="flex justify-start">
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
popoverAlign="end"
range={range}
setRange={setRange}
/>
export function TransactionsChartCard(props: {
searchParams: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
project: Project;
wallets: Wallet[];
teamSlug: string;
}) {
const { range, interval } = getTxAnalyticsFiltersFromSearchParams(
props.searchParams,
);
return (
<ResponsiveSuspense
// TODO - change this if this component does not end up using these params
fallback={
<TransactionsChartCardUI
isPending={true}
project={props.project}
teamSlug={props.teamSlug}
userOpStats={[]}
wallets={[]}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncTransactionsChartCard
from={range.from.toISOString()}
interval={interval}
<IntervalSelector
intervalType={interval}
setIntervalType={setInterval}
/>
</div>
</div>
<TransactionAnalyticsSummary
authToken={props.authToken}
clientId={props.project.publishableKey}
teamId={props.project.teamId}
startDate={normalizedFrom}
endDate={normalizedTo}
/>
<TransactionsChartCardUI
isPending={engineTxAnalytics.isPending}
project={props.project}
teamSlug={props.teamSlug}
to={range.to.toISOString()}
userOpStats={engineTxAnalytics.data ?? []}
wallets={props.wallets}
/>
</ResponsiveSuspense>
</div>
);
}
@@ -10,6 +10,7 @@ import {
XIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
@@ -21,6 +22,7 @@ import {
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
@@ -33,13 +35,12 @@ import {
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
@@ -57,6 +58,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { fetchSolanaBalance } from "../lib/getSolanaBalance";
import { updateDefaultProjectWallet } from "../lib/vault.client";
import { CreateServerWallet } from "../server-wallets/components/create-server-wallet.client";
import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types";
@@ -79,6 +81,8 @@ interface ServerWalletsTableProps {
teamSlug: string;
client: ThirdwebClient;
solanaPermissionError?: boolean;
authToken: string;
pageSize: number;
}
export function ServerWalletsTable(props: ServerWalletsTableProps) {
@@ -95,10 +99,15 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
solanaTotalPages,
client,
solanaPermissionError,
authToken,
pageSize,
} = props;
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [selectedSolanaChain, setSelectedSolanaChain] = useState<
"solana:mainnet" | "solana:devnet"
>("solana:mainnet");
const [showSmartAccount, setShowSmartAccount] = useState(false);
const queryClient = useQueryClient();
@@ -145,11 +154,33 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
</>
)}
{activeChain === "solana" && (
<CreateSolanaWallet
project={project}
teamSlug={teamSlug}
disabled={solanaPermissionError}
/>
<>
<CreateSolanaWallet
project={project}
teamSlug={teamSlug}
disabled={solanaPermissionError}
/>
<Select
value={selectedSolanaChain}
onValueChange={(value) =>
setSelectedSolanaChain(
value as "solana:mainnet" | "solana:devnet",
)
}
>
<SelectTrigger className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50">
<SelectValue placeholder="Select network" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="solana:mainnet" className="rounded-lg">
Solana Mainnet
</SelectItem>
<SelectItem value="solana:devnet" className="rounded-lg">
Solana Devnet
</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
@@ -278,6 +309,8 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
project={project}
teamSlug={teamSlug}
client={client}
authToken={authToken}
chainId={selectedSolanaChain}
/>
))}
</TableBody>
@@ -296,13 +329,13 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
</TableContainer>
{totalPages > 1 && (
<WalletsPagination
<ServerWalletsPagination
activeChain={activeChain}
currentPage={currentPage}
currentCount={wallets.length}
pageSize={pageSize}
totalPages={totalPages}
totalRecords={totalRecords}
teamSlug={teamSlug}
projectSlug={project.slug}
/>
)}
</>
@@ -312,78 +345,69 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
);
}
// Wallets Pagination Component
function WalletsPagination({
function ServerWalletsPagination({
activeChain,
currentPage,
currentCount,
pageSize,
totalPages,
totalRecords,
teamSlug,
projectSlug,
}: {
activeChain: WalletChain;
currentPage: number;
currentCount: number;
pageSize: number;
totalPages: number;
totalRecords: number;
teamSlug: string;
projectSlug: string;
}) {
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pageParam = activeChain === "evm" ? "page" : "solana_page";
const effectivePageSize = pageSize > 0 ? pageSize : currentCount;
const rangeStart =
totalRecords && effectivePageSize
? Math.min((currentPage - 1) * effectivePageSize + 1, totalRecords)
: 0;
const computedCount = currentCount
? currentCount
: totalRecords && effectivePageSize
? Math.min(effectivePageSize, totalRecords - rangeStart + 1)
: 0;
const rangeEnd = totalRecords
? Math.min(rangeStart + Math.max(computedCount - 1, 0), totalRecords)
: 0;
const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (page <= 1) {
params.delete(pageParam);
} else {
params.set(pageParam, page.toString());
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
return (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
<div className="border-t px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<span className="text-sm text-muted-foreground">
{totalRecords
? `Showing ${rangeStart.toLocaleString()}-${rangeEnd.toLocaleString()} of ${totalRecords.toLocaleString()} ` +
(activeChain === "evm" ? "EVM" : "Solana") +
" wallets"
: `No ${activeChain === "evm" ? "EVM" : "Solana"} wallets found`}
</span>
<PaginationButtons
activePage={currentPage}
className="justify-start lg:justify-end"
onPageClick={handlePageChange}
totalPages={totalPages}
/>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
@@ -507,11 +531,15 @@ function SolanaWalletRow({
project,
teamSlug,
client,
authToken,
chainId,
}: {
wallet: SolanaWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
authToken: string;
chainId: "solana:mainnet" | "solana:devnet";
}) {
const engineService = project.services.find(
(s) => s.name === "engineCloud",
@@ -547,7 +575,12 @@ function SolanaWalletRow({
</TableCell>
<TableCell>
<SolanaWalletBalance publicKey={wallet.publicKey} />
<SolanaWalletBalance
publicKey={wallet.publicKey}
authToken={authToken}
clientId={project.publishableKey}
chainId={chainId}
/>
</TableCell>
<TableCell>
@@ -739,16 +772,27 @@ function WalletBalance({
);
}
function SolanaWalletBalance({ publicKey }: { publicKey: string }) {
function SolanaWalletBalance({
publicKey,
authToken,
clientId,
chainId,
}: {
publicKey: string;
authToken: string;
clientId: string;
chainId: "solana:mainnet" | "solana:devnet";
}) {
const balance = useQuery({
queryFn: async () => {
// TODO: Implement actual Solana balance fetching
return {
displayValue: "0",
symbol: "SOL",
};
return await fetchSolanaBalance({
publicKey,
authToken,
clientId,
chainId,
});
},
queryKey: ["solanaWalletBalance", publicKey],
queryKey: ["solanaWalletBalance", publicKey, chainId],
});
if (balance.isFetching) {
@@ -0,0 +1,88 @@
"use client";
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
export type TransactionSummaryData = {
totalCount: number;
totalGasCostWei: string;
totalGasUnitsUsed: string; // Keep fetched data structure
};
type AnalyticsSummaryApiResponse = {
result: {
summary: {
totalCount: number;
totalGasCostWei: string;
totalGasUnitsUsed: string;
};
metadata: {
startDate?: string;
endDate?: string;
};
};
};
// Fetches data from the /analytics-summary endpoint
export async function getTransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
authToken: string;
startDate?: string;
endDate?: string;
}): Promise<TransactionSummaryData> {
const body: {
startDate?: string;
endDate?: string;
} = {};
if (props.startDate) {
body.startDate = props.startDate;
}
if (props.endDate) {
body.endDate = props.endDate;
}
const defaultData: TransactionSummaryData = {
totalCount: 0,
totalGasCostWei: "0",
totalGasUnitsUsed: "0",
};
try {
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
{
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${props.authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "POST",
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 400) {
console.error("Unauthorized fetching transaction summary");
return defaultData;
}
const errorText = await response.text();
throw new Error(
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
);
}
const data = (await response.json()) as AnalyticsSummaryApiResponse;
return {
totalCount: data.result.summary.totalCount ?? 0,
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
};
} catch (error) {
console.error("Failed to fetch transaction summary:", error);
return defaultData;
}
}
@@ -1,157 +1,10 @@
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
import type { TransactionStats } from "@/types/analytics";
import type {
Transaction,
TransactionsResponse,
} from "../analytics/tx-table/types";
// Define the structure of the data we expect back from our fetch function
export type TransactionSummaryData = {
totalCount: number;
totalGasCostWei: string;
totalGasUnitsUsed: string; // Keep fetched data structure
};
// Define the structure of the API response
type AnalyticsSummaryApiResponse = {
result: {
summary: {
totalCount: number;
totalGasCostWei: string;
totalGasUnitsUsed: string;
};
metadata: {
startDate?: string;
endDate?: string;
};
};
};
// Fetches data from the /analytics-summary endpoint
export async function getTransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
}): Promise<TransactionSummaryData> {
const authToken = await getAuthToken();
const body = {};
const defaultData: TransactionSummaryData = {
totalCount: 0,
totalGasCostWei: "0",
totalGasUnitsUsed: "0",
};
try {
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
{
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "POST",
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 400) {
console.error("Unauthorized fetching transaction summary");
return defaultData;
}
const errorText = await response.text();
throw new Error(
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
);
}
const data = (await response.json()) as AnalyticsSummaryApiResponse;
return {
totalCount: data.result.summary.totalCount ?? 0,
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
};
} catch (error) {
console.error("Failed to fetch transaction summary:", error);
return defaultData;
}
}
export async function getTransactionsChart({
teamId,
clientId,
from,
to,
interval,
}: {
teamId: string;
clientId: string;
from: string;
to: string;
interval: "day" | "week";
}): Promise<TransactionStats[]> {
const authToken = await getAuthToken();
const filters = {
endDate: to,
resolution: interval,
startDate: from,
};
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
{
body: JSON.stringify(filters),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-team-id": teamId,
},
method: "POST",
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 400) {
return [];
}
// TODO - need to handle this error state, like we do with the connect charts
throw new Error(
`Error fetching transactions chart data: ${response.status} ${
response.statusText
} - ${await response.text().catch(() => "Unknown error")}`,
);
}
type TransactionsChartResponse = {
result: {
analytics: Array<{
timeBucket: string;
chainId: string;
count: number;
}>;
metadata: {
resolution: string;
startDate: string;
endDate: string;
};
};
};
const data = (await response.json()) as TransactionsChartResponse;
return data.result.analytics.map((stat) => ({
chainId: Number(stat.chainId),
count: stat.count,
date: stat.timeBucket,
}));
}
export async function getSingleTransaction({
teamId,
clientId,
@@ -0,0 +1,60 @@
import { configure, getSolanaWalletBalance } from "@thirdweb-dev/api";
import { THIRDWEB_API_HOST } from "@/constants/urls";
// Configure the API client to use the correct base URL
configure({
override: {
baseUrl: THIRDWEB_API_HOST,
},
});
export async function fetchSolanaBalance({
publicKey,
authToken,
clientId,
chainId = "solana:mainnet",
}: {
publicKey: string;
authToken: string;
clientId: string;
chainId?: "solana:mainnet" | "solana:devnet";
}): Promise<{
displayValue: string;
symbol: string;
value: string;
decimals: number;
} | null> {
try {
const response = await getSolanaWalletBalance({
path: {
address: publicKey,
},
query: {
chainId,
},
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
},
});
if (response.error || !response.data) {
console.error(
"Error fetching Solana balance:",
response.error || "No data returned",
);
return null;
}
return {
displayValue: response.data.result.displayValue,
symbol: "SOL",
value: response.data.result.value,
decimals: response.data.result.decimals,
};
} catch (error) {
console.error("Error fetching Solana balance:", error);
return null;
}
}
@@ -1,14 +0,0 @@
import { getFiltersFromSearchParams } from "@/lib/time";
export function getTxAnalyticsFiltersFromSearchParams(params: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
}) {
return getFiltersFromSearchParams({
defaultRange: "last-30",
from: params.from,
interval: params.interval,
to: params.to,
});
}
@@ -1,223 +1,11 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { ArrowLeftRightIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
import { EngineChecklist } from "./analytics/ftux.client";
import { TransactionAnalyticsSummary } from "./analytics/summary";
import { ServerWalletsTable } from "./components/server-wallets-table.client";
import { getTransactionAnalyticsSummary } from "./lib/analytics";
import type { Wallet } from "./server-wallets/wallet-table/types";
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function TransactionsAnalyticsPage(props: {
// Redirect old Transactions page to new Server Wallets Overview
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | string[] | undefined;
to?: string | string[] | undefined;
interval?: string | string[] | undefined;
testTxWithWallet?: string | string[] | undefined;
testSolanaTxWithWallet?: string | string[] | undefined;
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
props.searchParams,
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!vaultClient) {
return <div>Error: Failed to connect to Vault</div>;
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
const wallets = eoas.data?.items as Wallet[] | undefined;
// Fetch Solana accounts - gracefully handle permission errors
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check if error is a permission error
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const initialData = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
}).catch(() => undefined);
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
icon: ArrowLeftRightIcon,
title: "Transactions",
description: (
<>
Send, monitor, and manage transactions.{" "}
<br className="max-sm:hidden" /> Send transactions from user or
server wallets, sponsor gas, monitor transaction status, and more
</>
),
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions",
},
{
type: "playground",
href: "https://playground.thirdweb.com/transactions/airdrop-tokens",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/transactions",
},
],
}}
>
<div className="flex flex-col gap-10">
<EngineChecklist
isManagedVault={isManagedVault}
client={client}
hasTransactions={hasTransactions}
project={project}
teamSlug={params.team_slug}
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
testSolanaTxWithWallet={
searchParams.testSolanaTxWithWallet as string | undefined
}
wallets={wallets ?? []}
solanaWallets={solanaAccounts.data.items}
/>
{hasTransactions &&
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet && (
<TransactionAnalyticsSummary
clientId={project.publishableKey}
initialData={initialData}
teamId={project.teamId}
/>
)}
{/* transactions */}
<TransactionsAnalyticsPageContent
client={client}
project={project}
searchParams={searchParams}
showAnalytics={
hasTransactions &&
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet
}
teamSlug={params.team_slug}
wallets={wallets}
/>
{/* Server Wallets (EVM + Solana) */}
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError}
/>
)}
</div>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
@@ -70,8 +70,8 @@ export function CreateSolanaWallet(props: {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="default"
className="gap-2 rounded-full bg-foreground hover:bg-foreground/90"
variant="outline"
className="gap-1.5 rounded-full bg-background text-foreground"
disabled={props.disabled}
>
<PlusIcon className="size-4" />
@@ -1,3 +1,4 @@
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
import type { Project } from "@/api/project/projects";
@@ -53,15 +54,20 @@ export function KeyManagement({
{isManagedVault && (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
<h2 className="font-semibold text-xl tracking-tight">
<h2 className="font-semibold text-xl tracking-tight mb-1">
Managed Vault
</h2>
<p className="text-muted-foreground text-sm">
Your vault is currently managed by Thirdweb so you can access it
via you project secret key. You can eject and manage your own
vault keys at any time. Doing so means you'll need to pass your
own vault access token to the transactions API additionally to
your project secret key.
<p className="text-muted-foreground text-sm max-w-3xl">
If you choose to eject and manage your own vault keys, you'll need
to provide your vault access token alongside your project secret
key when using the Transactions API.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/vault"
target="_blank"
rel="noopener noreferrer"
>
Learn more about Vault
</UnderlineLink>
</p>
<div className="h-4" />
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
@@ -1,58 +1,11 @@
import { LockIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { KeyManagement } from "./components/key-management";
import { redirect } from "next/navigation";
export default async function VaultPage(props: {
// Redirect old Vault page to new Server Wallets Configuration
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
]);
if (!project || !authToken) {
notFound();
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
title: "Vault",
icon: LockIcon,
description:
"Secure, non-custodial key management system for your server wallets.",
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/vault",
},
],
}}
>
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
isManagedVault={isManagedVault}
project={project}
/>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/configuration`,
);
}
@@ -19,7 +19,7 @@ export const Variants: Story = {
function Component() {
const title = "This is Title";
const description =
"This is an example of a description about in-app wallet usage chart";
"This is an example of a description about user wallet usage chart";
return (
<div className="container flex max-w-6xl flex-col gap-10 py-10">
<BadgeContainer label="30 days">
@@ -87,6 +87,7 @@ const authMethodsToPickFrom: InAppWalletAuth[] = [
"line",
"x",
"tiktok",
"epic",
"coinbase",
"farcaster",
"telegram",
@@ -21,7 +21,7 @@ function InAppWalletAnalyticsUI({
return (
<InAppWalletUsersChartCardUI
title="Unique Users"
description="The total number of active in-app wallet users on your project."
description="The total number of active user wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
/>
@@ -1,225 +0,0 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client";
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types";
import { InAppWalletAnalytics } from "./analytics/chart";
import { InAppWalletsSummary } from "./analytics/chart/Summary";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
page?: string;
solana_page?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/wallets`);
}
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
// Fetch server wallets with pagination (5 per page)
const pageSize = 5;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
// Fetch EVM wallets
const eoas =
vaultClient && managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
// Fetch Solana wallets
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check for Solana permission errors
const isSolanaPermissionError = solanaAccounts.error?.message?.includes(
"AUTH_INSUFFICIENT_SCOPE",
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
header={{
icon: WalletProductIcon,
client,
title: "Wallets",
description: (
<>
Create wallets for your users with flexible authentication
options.
<br className="max-sm:hidden" /> Choose from email/phone
verification, OAuth, passkeys, or external wallet connections
</>
),
actions: null,
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/wallets`,
},
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets",
},
{
type: "playground",
href: "https://playground.thirdweb.com/wallets/in-app-wallet",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/wallets",
},
],
}}
>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
<InAppWalletsSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
range={range}
/>
<InAppWalletAnalytics
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
{/* Server Wallets Section (EVM + Solana) */}
<div className="flex flex-col gap-4">
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message || "Failed to load EVM wallets"}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError || false}
/>
)}
</div>
{/* User Wallets Section */}
<div className="flex flex-col gap-4">
<h2 className="font-semibold text-2xl tracking-tight">
User wallets
</h2>
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</div>
</ProjectPage>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { loginRedirect } from "@/utils/redirects";
import { KeyManagement } from "../../../vault/components/key-management";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
]);
if (!authToken) {
loginRedirect(
`/team/${team_slug}/${project_slug}/wallets/server-wallets/configuration`,
);
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
return (
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
isManagedVault={isManagedVault}
project={project}
/>
);
}
@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "Server Wallets",
description:
"Wallet used to relay transactions from your backend at scale",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets/server",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,172 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { TransactionsAnalyticsPageContent } from "../../transactions/analytics/analytics-page";
import { ServerWalletsTable } from "../../transactions/components/server-wallets-table.client";
import type { Wallet } from "../../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../../transactions/solana-wallets/wallet-table/types";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | string[];
to?: string | string[];
interval?: string | string[];
testTxWithWallet?: string | string[];
testSolanaTxWithWallet?: string | string[];
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
props.searchParams,
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!vaultClient) {
return <div>Error: Failed to connect to Vault</div>;
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
const wallets = (eoas.data?.items as Wallet[] | undefined) ?? [];
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanaCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const testTxWithWallet =
typeof searchParams.testTxWithWallet === "string"
? searchParams.testTxWithWallet
: undefined;
const testSolanaTxWithWallet =
typeof searchParams.testSolanaTxWithWallet === "string"
? searchParams.testSolanaTxWithWallet
: undefined;
const hasTransactions = wallets.length > 0;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<div className="flex flex-col gap-10">
<TransactionsAnalyticsPageContent
authToken={authToken}
client={client}
isManagedVault={isManagedVault}
project={project}
showAnalytics={
hasTransactions && !testTxWithWallet && !testSolanaTxWithWallet
}
solanaWallets={solanaAccounts.data.items}
teamId={project.teamId}
teamSlug={params.team_slug}
testSolanaTxWithWallet={testSolanaTxWithWallet}
testTxWithWallet={testTxWithWallet}
wallets={wallets}
/>
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={wallets}
pageSize={pageSize}
project={project}
solanaCurrentPage={solanaCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError}
authToken={authToken}
/>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { CircleAlertIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { loginRedirect } from "@/utils/redirects";
import { DefaultFactoriesSection } from "../../../account-abstraction/factories/AccountFactories";
import { YourFactoriesSection } from "../../../account-abstraction/factories/AccountFactories/your-factories";
import { AccountAbstractionSettingsPage } from "../../../account-abstraction/settings/SponsorshipPolicies";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [team, project, authToken] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${team_slug}/${project_slug}/wallets/sponsored-gas/configuration`,
);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const bundlerService = project.services.find((s) => s.name === "bundler");
return (
<div className="space-y-8">
{!bundlerService ? (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
<AlertDescription>
Enable Account Abstraction service in{" "}
<UnderlineLink href={`/team/${team_slug}/${project_slug}/settings`}>
project settings
</UnderlineLink>{" "}
to configure the sponsorship rules
</AlertDescription>
</Alert>
) : (
<>
<AccountAbstractionSettingsPage
bundlerService={bundlerService}
client={client}
project={project}
teamId={team.id}
teamSlug={team.slug}
validTeamPlan={getValidTeamPlan(team)}
/>
<DefaultFactoriesSection />
<YourFactoriesSection
authToken={authToken}
clientThirdwebClient={client}
projectId={project.id}
projectSlug={project.slug}
teamId={team.id}
teamSlug={team.slug}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "Gas Sponsorship",
description:
"Cover gas fees on behalf of your users for a seamless onboarding experience",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions/sponsor",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,114 @@
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getUserOpUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { AccountAbstractionSummary } from "../../account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { SmartWalletsBillingAlert } from "../../account-abstraction/Alerts";
import { AccountAbstractionAnalytics } from "../../account-abstraction/aa-analytics";
import { searchParamLoader } from "../../account-abstraction/search-params";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<SearchParams>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`,
);
}
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const interval = searchParams.interval ?? "week";
const rangeType = searchParams.range || "last-120";
const range: Range = {
from:
rangeType === "custom"
? searchParams.from
: getLastNDaysRange(rangeType).from,
to:
rangeType === "custom"
? searchParams.to
: getLastNDaysRange(rangeType).to,
type: rangeType,
};
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
const isBundlerServiceEnabled = !!project.services.find(
(s) => s.name === "bundler",
);
const hasSmartWalletsWithoutBilling =
isBundlerServiceEnabled &&
team.billingStatus !== "validPayment" &&
team.billingStatus !== "pastDue";
return (
<>
{hasSmartWalletsWithoutBilling && (
<>
<SmartWalletsBillingAlert teamSlug={params.team_slug} />
<div className="h-10" />
</>
)}
<div className="flex grow flex-col gap-10">
<AccountAbstractionSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
<AccountAbstractionAnalytics
client={client}
projectId={project.id}
teamId={project.teamId}
teamSlug={params.team_slug}
userOpStats={userOpStats}
/>
</div>
</>
);
}
@@ -0,0 +1,61 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
export type SMSCountryTiers = {
tier1: string[];
tier2: string[];
tier3: string[];
tier4: string[];
tier5: string[];
tier6: string[];
};
export async function getSMSCountryTiers() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/sms/list-country-tiers`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
}
@@ -0,0 +1,891 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import type { Project } from "@/api/project/projects";
import type { Team } from "@/api/team/get-team";
import { FileInput } from "@/components/blocks/FileInput";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
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 { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { planToTierRecordForGating } from "@/constants/planToTierRecord";
import { updateProjectClient } from "@/hooks/useApi";
import { cn } from "@/lib/utils";
import {
type ApiKeyEmbeddedWalletsValidationSchema,
apiKeyEmbeddedWalletsValidationSchema,
} from "@/schema/validations";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { toArrFromList } from "@/utils/string";
import type { SMSCountryTiers } from "../api/sms";
import { CountrySelector } from "./sms-country-select";
type InAppWalletSettingsPageProps = {
project: Project;
teamId: string;
teamSlug: string;
teamPlan: Team["billingPlan"];
smsCountryTiers: SMSCountryTiers;
client: ThirdwebClient;
};
type UpdateAPIKeyTrackingData = {
hasCustomBranding: boolean;
hasCustomJwt: boolean;
hasCustomAuthEndpoint: boolean;
};
export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) {
const updateProject = useMutation({
mutationFn: async (projectValues: Partial<Project>) => {
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.teamId,
},
projectValues,
);
},
});
function handleUpdateProject(projectValues: Partial<Project>) {
updateProject.mutate(projectValues, {
onError: (err) => {
toast.error("Failed to update an API Key");
console.error(err);
},
onSuccess: () => {
toast.success("Configuration updated successfully");
},
});
}
return (
<InAppWalletSettingsPageUI
{...props}
isUpdating={updateProject.isPending}
smsCountryTiers={props.smsCountryTiers}
updateApiKey={handleUpdateProject}
/>
);
}
const InAppWalletSettingsPageUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
projectValues: Partial<Project>,
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
smsCountryTiers: SMSCountryTiers;
}
> = (props) => {
const embeddedWalletService = props.project.services.find(
(service) => service.name === "embeddedWallets",
);
if (!embeddedWalletService) {
return (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Wallets service is disabled</AlertTitle>
<AlertDescription>
Enable Wallets service in the{" "}
<UnderlineLink
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
project settings
</UnderlineLink>{" "}
to configure settings
</AlertDescription>
</Alert>
);
}
return (
<InAppWalletSettingsUI
{...props}
client={props.client}
embeddedWalletService={embeddedWalletService}
/>
);
};
const InAppWalletSettingsUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
projectValues: Partial<Project>,
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
embeddedWalletService: ProjectEmbeddedWalletsService;
client: ThirdwebClient;
}
> = (props) => {
const services = props.project.services;
const config = props.embeddedWalletService;
const hasCustomBranding =
!!config.applicationImageUrl?.length || !!config.applicationName?.length;
const authRequiredPlan = "growth";
const brandingRequiredPlan = "starter";
// growth or higher plan required
const canEditSmsCountries =
planToTierRecordForGating[props.teamPlan] >=
planToTierRecordForGating[authRequiredPlan];
const form = useForm<ApiKeyEmbeddedWalletsValidationSchema>({
resolver: zodResolver(apiKeyEmbeddedWalletsValidationSchema),
values: {
customAuthEndpoint: config.customAuthEndpoint || undefined,
customAuthentication: config.customAuthentication || undefined,
...(hasCustomBranding
? {
branding: {
applicationImageUrl: config.applicationImageUrl || undefined,
applicationName: config.applicationName || undefined,
},
}
: undefined),
redirectUrls: (config.redirectUrls || []).join("\n"),
smsEnabledCountryISOs: config.smsEnabledCountryISOs
? config.smsEnabledCountryISOs
: canEditSmsCountries
? ["US", "CA"]
: [],
},
});
const handleSubmit = form.handleSubmit((values) => {
const { customAuthentication, customAuthEndpoint, branding, redirectUrls } =
values;
if (
customAuthentication &&
(!customAuthentication.aud.length || !customAuthentication.jwksUri.length)
) {
return toast.error("Custom JSON Web Token configuration is invalid", {
description:
"To use user wallets with Custom JSON Web Token, provide JWKS URI and AUD.",
dismissible: true,
duration: 9000,
});
}
if (customAuthEndpoint && !customAuthEndpoint.authEndpoint.length) {
return toast.error(
"Custom Authentication Endpoint configuration is invalid",
{
description:
"To use user wallets with Custom Authentication Endpoint, provide a valid URL.",
dismissible: true,
duration: 9000,
},
);
}
const newServices = services.map((service) => {
if (service.name !== "embeddedWallets") {
return service;
}
return {
...service,
applicationImageUrl: branding?.applicationImageUrl,
applicationName: branding?.applicationName || props.project.name,
customAuthEndpoint,
customAuthentication,
redirectUrls: toArrFromList(redirectUrls || "", true),
smsEnabledCountryISOs: values.smsEnabledCountryISOs,
};
});
props.updateApiKey(
{
services: newServices,
},
{
hasCustomAuthEndpoint: !!customAuthEndpoint,
hasCustomBranding: !!branding,
hasCustomJwt: !!customAuthentication,
},
);
});
return (
<Form {...form}>
<form autoComplete="off" className="space-y-6" onSubmit={handleSubmit}>
{/* Branding */}
<BrandingFieldset
client={props.client}
form={form}
requiredPlan={brandingRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
/>
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
{/* Authentication */}
<Fieldset
legend="Authentication"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button
className="gap-2"
type="submit"
size="sm"
variant="outline"
>
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<JSONWebTokenFields
form={form}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
<div className="my-5 border-t border-dashed" />
<AuthEndpointFields
form={form}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
<div className="my-5 border-t border-dashed" />
<SMSCountryFields
form={form}
requiredPlan={authRequiredPlan}
smsCountryTiers={props.smsCountryTiers}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
</Fieldset>
</form>
</Form>
);
};
function BrandingFieldset(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
client: ThirdwebClient;
isUpdating: boolean;
}) {
return (
<FieldsetWithDescription
legend="Branding"
description="Set an app name and logo to be used in the emails sent to users"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button className="gap-2" type="submit" size="sm" variant="outline">
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<div className="absolute top-8 right-6">
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("branding"),
id: "branding-switch",
onCheckedChange: (checked) =>
props.form.setValue(
"branding",
checked
? {
applicationImageUrl: "",
applicationName: "",
}
: undefined,
),
}}
teamSlug={props.teamSlug}
trackingLabel="customEmailLogoAndName"
/>
</div>
<GatedCollapsibleContainer
className="border-t border-dashed pt-5"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("branding")}
requiredPlan={props.requiredPlan}
>
<div className="flex flex-col gap-6 lg:flex-row">
<FormField
control={props.form.control}
name="branding.applicationImageUrl"
render={() => (
<FormItem className="lg:border-r lg:border-dashed lg:pr-6">
<div className="space-y-1">
<FormLabel>Application Image URL</FormLabel>
<FormDescription className="!mb-4">
Logo that will display in the emails sent to users.{" "}
<br className="max-sm:hidden" /> The image must be squared
with recommended size of 72x72 px.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<AppImageFormControl
client={props.client}
setUri={(uri) => {
props.form.setValue("branding.applicationImageUrl", uri, {
shouldDirty: true,
shouldTouch: true,
});
}}
uri={props.form.watch("branding.applicationImageUrl")}
/>
</FormControl>
</FormItem>
)}
/>
{/* Application Name */}
<FormField
control={props.form.control}
name="branding.applicationName"
render={({ field }) => (
<FormItem>
<FormLabel>Application Name</FormLabel>
<FormDescription>
Name that will be displayed in the emails sent to users.{" "}
<br className="max-sm:hidden" /> Defaults to your API Key's
name.
</FormDescription>
<FormControl>
<Input {...field} className="max-w-md" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</GatedCollapsibleContainer>
</FieldsetWithDescription>
);
}
function AppImageFormControl(props: {
uri: string | undefined;
setUri: (uri: string) => void;
client: ThirdwebClient;
}) {
const [image, setImage] = useState<File | undefined>();
const resolveUrl = resolveSchemeWithErrorHandler({
client: props.client,
uri: props.uri || undefined,
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
const uri = await upload({
client: props.client,
files: [file],
});
return uri;
},
});
return (
<div className="flex">
<div className="relative">
<FileInput
accept={{ "image/*": [] }}
className="w-24 rounded-full bg-background"
client={props.client}
disableHelperText
setValue={async (v) => {
try {
setImage(v);
const uri = await uploadImage.mutateAsync(v);
props.setUri(uri);
} catch (error) {
setImage(undefined);
toast.error("Failed to upload image", {
description: error instanceof Error ? error.message : undefined,
});
}
}}
value={image || resolveUrl}
/>
{uploadImage.isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-full border bg-background/50">
<Spinner className="size-7" />
</div>
)}
</div>
</div>
);
}
function SMSCountryFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
smsCountryTiers: SMSCountryTiers;
teamPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
teamSlug: string;
}) {
return (
<div>
<SwitchContainer
description="Optionally allow users in selected countries to login via SMS OTP."
switchId="sms-switch"
title="SMS"
>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("smsEnabledCountryISOs").length,
id: "sms-switch",
onCheckedChange: (checked) =>
props.form.setValue(
"smsEnabledCountryISOs",
checked ? ["US", "CA"] : [],
),
}}
teamSlug={props.teamSlug}
trackingLabel="sms"
/>
</SwitchContainer>
<GatedCollapsibleContainer
className="grid grid-cols-1"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("smsEnabledCountryISOs").length}
requiredPlan={props.requiredPlan}
>
<FormField
control={props.form.control}
name="smsEnabledCountryISOs"
render={({ field }) => (
<CountrySelector
countryTiers={props.smsCountryTiers}
onChange={field.onChange}
selected={field.value}
/>
)}
/>
</GatedCollapsibleContainer>
</div>
);
}
function JSONWebTokenFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
}) {
return (
<div>
<SwitchContainer
description={
<>
Optionally allow users to authenticate with a custom JWT.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/wallets/custom-auth"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</>
}
switchId="authentication-switch"
title="Custom JSON Web Token"
>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("customAuthentication"),
id: "authentication-switch",
onCheckedChange: (checked) => {
props.form.setValue(
"customAuthentication",
checked ? { aud: "", jwksUri: "" } : undefined,
);
},
}}
teamSlug={props.teamSlug}
trackingLabel="customAuthJWT"
/>
</SwitchContainer>
<GatedCollapsibleContainer
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("customAuthentication")}
requiredPlan={props.requiredPlan}
>
<FormField
control={props.form.control}
name="customAuthentication.jwksUri"
render={({ field }) => (
<FormItem>
<FormLabel>JWKS URI</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/.well-known/jwks.json"
/>
</FormControl>
<FormDescription>Enter the URI of the JWKS</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.form.control}
name="customAuthentication.aud"
render={({ field }) => (
<FormItem>
<FormLabel>AUD Value</FormLabel>
<FormControl>
<Input {...field} placeholder="AUD" />
</FormControl>
<FormDescription>
Enter the audience claim for the JWT
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</GatedCollapsibleContainer>
</div>
);
}
function AuthEndpointFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
}) {
const expandCustomAuthEndpointField =
props.form.watch("customAuthEndpoint") !== undefined;
return (
<div>
<SwitchContainer
description={
<>
Optionally allow users to authenticate with any arbitrary payload
that you provide.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/wallets/custom-auth"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</>
}
switchId="auth-endpoint-switch"
title="Custom Authentication Endpoint"
>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: expandCustomAuthEndpointField,
id: "auth-endpoint-switch",
onCheckedChange: (checked) => {
props.form.setValue(
"customAuthEndpoint",
checked
? {
authEndpoint: "",
customHeaders: [],
}
: undefined,
);
},
}}
teamSlug={props.teamSlug}
trackingLabel="customAuthEndpoint"
/>
</SwitchContainer>
{/* useFieldArray used on this component - it creates empty customAuthEndpoint.customHeaders array on mount */}
{/* So only mount if expandCustomAuthEndpointField is true */}
{expandCustomAuthEndpointField && (
<AuthEndpointFieldsContent form={props.form} />
)}
</div>
);
}
function AuthEndpointFieldsContent(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
}) {
const customHeaderFields = useFieldArray({
control: props.form.control,
name: "customAuthEndpoint.customHeaders",
});
return (
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<FormField
control={props.form.control}
name="customAuthEndpoint.authEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Endpoint</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-auth-verifier"
/>
</FormControl>
<FormDescription>
Enter the URL of your server where we will send the user payload
for verification
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label className="mb-3 inline-block">Custom Headers</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, customHeaderIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...props.form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...props.form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.value`,
)}
/>
<Button
aria-label="Remove header"
className="!w-auto px-3"
onClick={() => {
customHeaderFields.remove(customHeaderIdx);
}}
variant="outline"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
<Button
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
variant="outline"
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
<p className="mt-3 text-muted-foreground text-sm">
Set custom headers to be sent along the request with the payload to
the authentication endpoint above. This can be used to verify the
incoming requests
</p>
</div>
</div>
);
}
function NativeAppsFieldset(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
isUpdating: boolean;
}) {
const { form } = props;
return (
<Fieldset
legend="Native Apps"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button className="gap-2" type="submit" size="sm" variant="outline">
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<FormField
control={form.control}
name="redirectUrls"
render={({ field }) => (
<FormItem>
<FormLabel>Allowed redirect URIs</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="appName://, localhost:3000, https://example.com"
/>
</FormControl>
<FormDescription>
Enter redirect URIs separated by commas or new lines. This is
often your application's deep link.
<br className="max-sm:hidden" />
Currently only used in Unity, Unreal Engine and React Native
platform when users authenticate through social logins.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Fieldset>
);
}
function GatedCollapsibleContainer(props: {
children: React.ReactNode;
isExpanded: boolean;
className?: string;
requiredPlan: Team["billingPlan"];
currentPlan: Team["billingPlan"];
}) {
const upgradeRequired =
planToTierRecordForGating[props.currentPlan] <
planToTierRecordForGating[props.requiredPlan];
if (!props.isExpanded || upgradeRequired) {
return null;
}
return (
<div
className={cn("mt-5 fade-in-0 duration-300 animate-in", props.className)}
>
{props.children}
</div>
);
}
function Fieldset(props: {
legend: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="rounded-lg border bg-card relative">
<DynamicHeight>
<fieldset className="p-4 md:px-6 md:py-5">
{/* put inside div to remove default styles on legend */}
<div className="border-b pb-4 mb-5 border-dashed font-semibold text-xl tracking-tight">
<legend> {props.legend}</legend>
</div>
{props.children}
</fieldset>
</DynamicHeight>
{props.footer}
</div>
);
}
function FieldsetWithDescription(props: {
legend: string;
children: React.ReactNode;
footer?: React.ReactNode;
description: React.ReactNode;
}) {
return (
<div className="rounded-lg border bg-card relative">
<DynamicHeight>
<fieldset className="p-4 md:p-6">
{/* put inside div to remove default styles on legend */}
<div className="pr-20">
<legend className="font-semibold text-xl tracking-tight">
{props.legend}
</legend>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
{props.children}
</fieldset>
</DynamicHeight>
{props.footer}
</div>
);
}
function SwitchContainer(props: {
switchId: string;
title: string;
description: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6">
<div>
<Label className="text-base" htmlFor={props.switchId}>
{props.title}
</Label>
<p className="mt-0.5 text-muted-foreground text-sm ">
{props.description}
</p>
</div>
{props.children}
</div>
);
}
@@ -0,0 +1,210 @@
/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { SMSCountryTiers } from "../../api/sms";
import { countryNames, countryPrefixes, getCountryFlag, tierPricing } from ".";
interface CountrySelectorProps {
countryTiers: SMSCountryTiers;
selected: string[];
onChange: (selectedCountries: string[]) => void;
}
export default function CountrySelector({
countryTiers,
selected,
onChange,
}: CountrySelectorProps) {
// Helper function to check if a country is selected
const isCountrySelected = (country: string) => selected.includes(country);
// Check if all countries in a tier are selected
const isTierSelected = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
return tierCountries.every((country) => isCountrySelected(country));
};
// Toggle a tier selection
const toggleTier = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
let newSelected: string[];
if (isTierSelected(tier)) {
// Deselect all countries in this tier
newSelected = selected.filter(
(country) => !tierCountries.includes(country),
);
} else {
// Select all countries in this tier
const currentSelected = new Set(selected);
for (const country of tierCountries) {
currentSelected.add(country);
}
newSelected = Array.from(currentSelected);
}
// Call onChange with the updated selection
onChange(newSelected);
};
// Toggle a single country selection
const toggleCountry = (country: string) => {
let newSelected: string[];
if (isCountrySelected(country)) {
// Remove country from selection
newSelected = selected.filter((c) => c !== country);
} else {
// Add country to selection
newSelected = [...selected, country];
}
// Call onChange with the updated selection
onChange(newSelected);
};
return (
<div className="space-y-5">
{Object.entries(countryTiers).map(([tier, tierCountries], index) => {
const selectedTierCountries = tierCountries.filter((country) =>
isCountrySelected(country),
);
return (
<TierCard
key={tier}
tierIndex={index}
tier={tier}
onToggleTier={() => toggleTier(tier)}
tierCountries={tierCountries}
selectedTierCountries={selectedTierCountries}
onToggleCountry={toggleCountry}
/>
);
})}
</div>
);
}
function TierCard(props: {
tier: string;
tierIndex: number;
onToggleTier: () => void;
tierCountries: string[];
selectedTierCountries: string[];
onToggleCountry: (country: string) => void;
}) {
const {
tier,
tierIndex,
onToggleTier,
tierCountries: countries,
selectedTierCountries: selectedCountries,
onToggleCountry,
} = props;
const [isExpanded, setIsExpanded] = useState(true);
const isPartiallySelected =
selectedCountries.length > 0 && selectedCountries.length < countries.length;
const isTierFullySelected = selectedCountries.length === countries.length;
return (
<div className="overflow-hidden rounded-xl border bg-background">
{/* header */}
<div className="flex items-center px-4 py-3 gap-3 justify-between">
{/* left */}
<div className="flex items-center gap-2">
<Checkbox
checked={
isPartiallySelected ? "indeterminate" : isTierFullySelected
}
id={`tier-${tier}`}
onCheckedChange={onToggleTier}
/>
<label
className="flex-1 cursor-pointer font-medium text-sm"
htmlFor={`tier-${tier}`}
>
Tier {tierIndex + 1}
</label>
{isPartiallySelected && (
<span className="text-muted-foreground text-sm">
({selectedCountries.length}/{countries.length})
</span>
)}
</div>
{/* right */}
<div className="flex items-center gap-3">
<span
className={cn(
"text-sm",
tier === "tier1"
? "text-green-600 dark:text-green-500"
: "text-muted-foreground",
)}
>
{tierPricing[tier as keyof typeof tierPricing]}
</span>
<Button
variant="ghost"
className="p-0 size-7"
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isExpanded && "rotate-180",
)}
/>
</Button>
</div>
</div>
{/* body */}
{isExpanded && (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 translate-y-[1px] translate-x-[1px] -ml-[1px] border-t",
)}
>
{countries.map((country) => {
const isSelected = selectedCountries.includes(country);
return (
<Button
variant="outline"
className={cn(
"justify-between gap-3 text-start h-auto rounded-none border-l-0 border-t-0 border-border py-2.5 bg-background",
)}
key={country}
onClick={() => onToggleCountry(country)}
title={countryNames[country] || country}
>
<div className="flex items-center gap-3">
<div className="text-3xl">{getCountryFlag(country)}</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">
{countryNames[country] || country}
</div>
<div className="text-muted-foreground text-xs tabular-nums">
{countryPrefixes[country]
? `${countryPrefixes[country]}`
: ""}
</div>
</div>
</div>
{isSelected && <CheckIcon className="size-5 text-foreground" />}
</Button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,7 @@
export { default as CountrySelector } from "./country-selector";
export {
countryNames,
countryPrefixes,
getCountryFlag,
tierPricing,
} from "./utils";
@@ -0,0 +1,482 @@
// Pricing for each tier
export const tierPricing = {
tier1: "Included",
tier2: "$0.05 per SMS",
tier3: "$0.10 per SMS",
tier4: "$0.20 per SMS",
tier5: "$0.40 per SMS",
tier6: "$1.00 per SMS",
} as const;
// Country names mapped to ISO codes
export const countryNames: Record<string, string> = {
// Tier 4
AD: "Andorra",
AE: "United Arab Emirates",
// Tier 5
AF: "Afghanistan",
AG: "Antigua and Barbuda",
AI: "Anguilla",
AL: "Albania",
AM: "Armenia",
AO: "Angola",
// Tier 6
RU: "Russia/Kazakhstan",
PG: "Papua New Guinea",
UZ: "Uzbekistan",
TZ: "Tanzania",
KM: "Comoros",
BT: "Bhutan",
// Tier 3
AR: "Argentina",
AS: "American Samoa",
AT: "Austria",
AU: "Australia",
AW: "Aruba",
AZ: "Azerbaijan",
BA: "Bosnia and Herzegovina",
BB: "Barbados",
BD: "Bangladesh",
BE: "Belgium",
BF: "Burkina Faso",
BG: "Bulgaria",
// Tier 2
BH: "Bahrain",
BI: "Burundi",
BJ: "Benin",
BM: "Bermuda",
BN: "Brunei",
BO: "Bolivia",
BR: "Brazil",
BS: "Bahamas",
BW: "Botswana",
BY: "Belarus",
BZ: "Belize",
// Tier 1
CA: "Canada",
CD: "DR Congo",
CG: "Congo",
CH: "Switzerland",
CI: "Ivory Coast",
CK: "Cook Islands",
CL: "Chile",
CM: "Cameroon",
CN: "China",
CO: "Colombia",
CR: "Costa Rica",
CU: "Cuba",
CV: "Cape Verde",
CY: "Cyprus",
CZ: "Czech Republic",
DE: "Germany",
DJ: "Djibouti",
DK: "Denmark",
DM: "Dominica",
DO: "Dominican Republic",
DZ: "Algeria",
EC: "Ecuador",
EE: "Estonia",
EG: "Egypt",
ER: "Eritrea",
ES: "Spain",
ET: "Ethiopia",
FI: "Finland",
FJ: "Fiji",
FK: "Falkland Islands",
FM: "Micronesia",
FO: "Faroe Islands",
FR: "France",
GA: "Gabon",
GB: "United Kingdom",
GD: "Grenada",
GE: "Georgia",
GF: "French Guiana",
GH: "Ghana",
GI: "Gibraltar",
GL: "Greenland",
GM: "Gambia",
GN: "Guinea",
GP: "Guadeloupe",
GQ: "Equatorial Guinea",
GR: "Greece",
GT: "Guatemala",
GU: "Guam",
GW: "Guinea-Bissau",
GY: "Guyana",
HK: "Hong Kong",
HN: "Honduras",
HR: "Croatia",
HT: "Haiti",
HU: "Hungary",
ID: "Indonesia",
IE: "Ireland",
IL: "Israel",
IN: "India",
IQ: "Iraq",
IS: "Iceland",
IT: "Italy",
JM: "Jamaica",
JO: "Jordan",
JP: "Japan",
KE: "Kenya",
KG: "Kyrgyzstan",
KH: "Cambodia",
KI: "Kiribati",
KN: "Saint Kitts and Nevis",
KR: "South Korea",
KW: "Kuwait",
KY: "Cayman Islands",
KZ: "Kazakhstan",
LA: "Laos PDR",
LB: "Lebanon",
LC: "Saint Lucia",
LI: "Liechtenstein",
LK: "Sri Lanka",
LR: "Liberia",
LS: "Lesotho",
LT: "Lithuania",
LU: "Luxembourg",
LV: "Latvia",
LY: "Libya",
MA: "Morocco",
MC: "Monaco",
MD: "Moldova",
ME: "Montenegro",
MG: "Madagascar",
MH: "Marshall Islands",
MK: "North Macedonia",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
MO: "Macao",
MQ: "Martinique",
MR: "Mauritania",
MS: "Montserrat",
MT: "Malta",
MU: "Mauritius",
MV: "Maldives",
MW: "Malawi",
MX: "Mexico",
MY: "Malaysia",
MZ: "Mozambique",
NA: "Namibia",
NC: "New Caledonia",
NE: "Niger",
NF: "Norfolk Island",
NG: "Nigeria",
NI: "Nicaragua",
NL: "Netherlands",
NO: "Norway",
NP: "Nepal",
NU: "Niue",
NZ: "New Zealand",
OM: "Oman",
PA: "Panama",
PE: "Peru",
PF: "French Polynesia",
PH: "Philippines",
PK: "Pakistan",
PL: "Poland",
PM: "Saint Pierre and Miquelon",
PR: "Puerto Rico",
PS: "Palestinian Territory",
PT: "Portugal",
PW: "Palau",
PY: "Paraguay",
QA: "Qatar",
RE: "Réunion",
RO: "Romania",
RS: "Serbia",
RW: "Rwanda",
SA: "Saudi Arabia",
SB: "Solomon Islands",
SC: "Seychelles",
SD: "Sudan",
SE: "Sweden",
SG: "Singapore",
SI: "Slovenia",
SK: "Slovakia",
SL: "Sierra Leone",
SM: "San Marino",
SN: "Senegal",
SO: "Somalia",
SR: "Suriname",
SS: "South Sudan",
ST: "Sao Tome and Principe",
SV: "El Salvador",
SY: "Syria",
SZ: "Eswatini",
TC: "Turks and Caicos Islands",
TD: "Chad",
TG: "Togo",
TH: "Thailand",
TL: "East Timor",
TM: "Turkmenistan",
TN: "Tunisia",
TO: "Tonga",
TR: "Turkey",
TT: "Trinidad and Tobago",
TV: "Tuvalu",
TW: "Taiwan",
UA: "Ukraine",
UG: "Uganda",
US: "United States",
UY: "Uruguay",
VC: "Saint Vincent and the Grenadines",
VE: "Venezuela",
VG: "Virgin Islands, British",
VI: "Virgin Islands, U.S.",
VN: "Vietnam",
VU: "Vanuatu",
WF: "Wallis and Futuna",
WS: "Samoa",
YE: "Yemen",
ZA: "South Africa",
ZM: "Zambia",
ZW: "Zimbabwe",
} as const;
export const countryPrefixes: Record<string, string> = {
// Tier 4
AD: "+376",
AE: "+971",
// Tier 5
AF: "+93",
AG: "+1",
AI: "+1",
AL: "+355",
AM: "+374",
AO: "+244",
// Tier 6
RU: "+7",
PG: "+675",
UZ: "+998",
TZ: "+255",
KM: "+269",
BT: "+975",
// Tier 3
AR: "+54",
AS: "+1",
AT: "+43",
AU: "+61",
AW: "+297",
AZ: "+994",
BA: "+387",
BB: "+1",
BD: "+880",
BE: "+32",
BF: "+226",
BG: "+359",
// Tier 2
BH: "+973",
BI: "+257",
BJ: "+229",
BM: "+1",
BN: "+673",
BO: "+591",
BR: "+55",
BS: "+1",
BW: "+267",
BY: "+375",
BZ: "+501",
// Tier 1
CA: "+1",
CD: "+243",
CG: "+242",
CH: "+41",
CI: "+225",
CK: "+682",
CL: "+56",
CM: "+237",
CN: "+86",
CO: "+57",
CR: "+506",
CU: "+53",
CV: "+238",
CY: "+357",
CZ: "+420",
DE: "+49",
DJ: "+253",
DK: "+45",
DM: "+1",
DO: "+1",
DZ: "+213",
EC: "+593",
EE: "+372",
EG: "+20",
ER: "+291",
ES: "+34",
ET: "+251",
FI: "+358",
FJ: "+679",
FK: "+500",
FM: "+691",
FO: "+298",
FR: "+33",
GA: "+241",
GB: "+44",
GD: "+1",
GE: "+995",
GF: "+594",
GH: "+233",
GI: "+350",
GL: "+299",
GM: "+220",
GN: "+224",
GP: "+590",
GQ: "+240",
GR: "+30",
GT: "+502",
GU: "+1",
GW: "+245",
GY: "+592",
HK: "+852",
HN: "+504",
HR: "+385",
HT: "+509",
HU: "+36",
ID: "+62",
IE: "+353",
IL: "+972",
IN: "+91",
IQ: "+964",
IS: "+354",
IT: "+39",
JM: "+1",
JO: "+962",
JP: "+81",
KE: "+254",
KG: "+996",
KH: "+855",
KI: "+686",
KN: "+1",
KR: "+82",
KW: "+965",
KY: "+1",
KZ: "+7",
LA: "+856",
LB: "+961",
LC: "+1",
LI: "+423",
LK: "+94",
LR: "+231",
LS: "+266",
LT: "+370",
LU: "+352",
LV: "+371",
LY: "+218",
MA: "+212",
MC: "+377",
MD: "+373",
ME: "+382",
MG: "+261",
MH: "+692",
MK: "+389",
ML: "+223",
MM: "+95",
MN: "+976",
MO: "+853",
MQ: "+596",
MR: "+222",
MS: "+1",
MT: "+356",
MU: "+230",
MV: "+960",
MW: "+265",
MX: "+52",
MY: "+60",
MZ: "+258",
NA: "+264",
NC: "+687",
NE: "+227",
NF: "+672",
NG: "+234",
NI: "+505",
NL: "+31",
NO: "+47",
NP: "+977",
NU: "+683",
NZ: "+64",
OM: "+968",
PA: "+507",
PE: "+51",
PF: "+689",
PH: "+63",
PK: "+92",
PL: "+48",
PM: "+508",
PR: "+1",
PS: "+970",
PT: "+351",
PW: "+680",
PY: "+595",
QA: "+974",
RE: "+262",
RO: "+40",
RS: "+381",
RW: "+250",
SA: "+966",
SB: "+677",
SC: "+248",
SD: "+249",
SE: "+46",
SG: "+65",
SI: "+386",
SK: "+421",
SL: "+232",
SM: "+378",
SN: "+221",
SO: "+252",
SR: "+597",
SS: "+211",
ST: "+239",
SV: "+503",
SY: "+963",
SZ: "+268",
TC: "+1",
TD: "+235",
TG: "+228",
TH: "+66",
TL: "+670",
TM: "+993",
TN: "+216",
TO: "+676",
TR: "+90",
TT: "+1",
TV: "+688",
TW: "+886",
UA: "+380",
UG: "+256",
US: "+1",
UY: "+598",
VC: "+1",
VE: "+58",
VG: "+1",
VI: "+1",
VN: "+84",
VU: "+678",
WF: "+681",
WS: "+685",
YE: "+967",
ZA: "+27",
ZM: "+260",
ZW: "+263",
} as const;
// Helper function to convert country code to flag emoji
export const getCountryFlag = (countryCode: string) => {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
@@ -5,7 +5,6 @@ import { getTeamBySlug } from "@/api/team/get-team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { loginRedirect } from "@/utils/redirects";
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
import { getSMSCountryTiers } from "./api/sms";
import { InAppWalletSettingsPage } from "./components";
@@ -22,7 +21,9 @@ export default async function Page(props: {
]);
if (!authToken) {
loginRedirect(`/team/${team_slug}/settings/wallets`);
loginRedirect(
`/team/${team_slug}/${project_slug}/wallets/user-wallets/configuration`,
);
}
if (!team) {
@@ -40,12 +41,6 @@ export default async function Page(props: {
return (
<div className="flex flex-col gap-6">
<ProjectSettingsBreadcrumb
teamSlug={team_slug}
projectSlug={project_slug}
page="Wallets"
/>
<InAppWalletSettingsPage
client={client}
project={project}
@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "User Wallets",
description:
"Secure, auto-generated wallets created for authenticated users using email, socials, or passkeys",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets/users",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,81 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { InAppWalletAnalytics } from "../analytics/chart";
import { InAppWalletsSummary } from "../analytics/chart/Summary";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets`,
);
}
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
<InAppWalletsSummary
authToken={authToken}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<InAppWalletAnalytics
authToken={authToken}
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,69 @@
"use client";
import { BotIcon, ServerIcon, WalletIcon } from "lucide-react";
import { FeatureCard } from "../payments/components/FeatureCard.client";
export function QuickStartSection() {
return (
<section>
<div className="mb-4">
<h2 className="font-semibold text-xl tracking-tight">Quick Start</h2>
<p className="text-muted-foreground text-sm">
Choose how to integrate x402 payments into your project.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard
title="Payment gate your API"
description="Make your endpoints payable with a single line of code"
icon={ServerIcon}
id="x402_server"
setupTime={2}
features={[
"Supports 170+ chains",
"Supports 6.7k+ tokens",
"Dynamic pricing logic",
]}
link={{
href: "https://portal.thirdweb.com/x402/server",
label: "Get Started",
}}
/>
<FeatureCard
title="Let your users pay for x402 resources"
description="Handle x402 payments from any user wallet in your apps"
icon={WalletIcon}
id="x402_client"
setupTime={2}
features={[
"Works with any wallet",
"No gas required",
"One line of code",
]}
link={{
href: "https://portal.thirdweb.com/x402/client",
label: "Get Started",
}}
/>
<FeatureCard
title="Equip your agents with x402 tools"
description="Give your AI agents a wallet and the ability to pay for any x402 resource"
icon={BotIcon}
id="x402_agents"
setupTime={2}
features={[
"Remote MCP server",
"Low level APIs",
"Works with any AI framework",
]}
link={{
href: "https://portal.thirdweb.com/x402/agents",
label: "Get Started",
}}
/>
</div>
</section>
);
}
@@ -0,0 +1,30 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Metric } from "./MetricSwitcher";
import { MetricSwitcher } from "./MetricSwitcher";
export function ChartMetricSwitcher() {
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const metric = (searchParams.get("metric") as Metric) || "payments";
const handleMetricChange = useCallback(
(newMetric: Metric) => {
const params = new URLSearchParams(searchParams.toString());
params.set("metric", newMetric);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
return (
<div className="flex justify-end">
<MetricSwitcher value={metric} onChange={handleMetricChange} />
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type Metric = "payments" | "volume";
export function MetricSwitcher(props: {
value: Metric;
onChange: (value: Metric) => void;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm font-medium">Show:</span>
<Select value={props.value} onValueChange={props.onChange}>
<SelectTrigger className="w-[180px] rounded-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="payments">Payments</SelectItem>
<SelectItem value="volume">Volume (USD)</SelectItem>
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,133 @@
import { CreditCardIcon, DollarSignIcon, UsersIcon } from "lucide-react";
import { Suspense } from "react";
import { getX402Settlements } from "@/api/analytics";
import type { Range } from "@/components/analytics/date-range-selector";
import { StatCard } from "@/components/analytics/stat";
import type {
X402SettlementsByPayer,
X402SettlementsOverall,
} from "@/types/analytics";
function X402SummaryInner(props: {
totalPayments: number | undefined;
totalBuyers: number | undefined;
totalVolume: number | undefined;
isPending: boolean;
}) {
const formatUSD = (value: number) => {
return `$${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
})}`;
};
return (
<div className="grid grid-cols-3 gap-4">
<StatCard
icon={CreditCardIcon}
isPending={props.isPending}
label="Total Payments"
value={props.totalPayments || 0}
/>
<StatCard
icon={UsersIcon}
isPending={props.isPending}
label="Total Buyers"
value={props.totalBuyers || 0}
/>
<StatCard
formatter={formatUSD}
icon={DollarSignIcon}
isPending={props.isPending}
label="Total Volume"
value={props.totalVolume || 0}
/>
</div>
);
}
async function AsyncX402Summary(props: {
teamId: string;
projectId: string;
authToken: string;
range: Range;
}) {
const { teamId, projectId, authToken, range } = props;
const [overallStats, payerStats] = await Promise.all([
getX402Settlements(
{
from: range.from,
period: "all",
projectId,
teamId,
to: range.to,
groupBy: "overall",
},
authToken,
).catch(() => []),
getX402Settlements(
{
from: range.from,
period: "all",
projectId,
teamId,
to: range.to,
groupBy: "payer",
},
authToken,
).catch(() => []),
]);
const totalPayments = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalRequests,
0,
);
const totalVolume = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalValueUSD,
0,
);
// Count unique payers
const uniquePayers = new Set(
(payerStats as X402SettlementsByPayer[]).map((stat) => stat.payer),
);
const totalBuyers = uniquePayers.size;
return (
<X402SummaryInner
totalBuyers={totalBuyers}
totalPayments={totalPayments}
totalVolume={totalVolume}
isPending={false}
/>
);
}
export function X402Summary(props: {
teamId: string;
projectId: string;
authToken: string;
range: Range;
}) {
return (
<Suspense
fallback={
<X402SummaryInner
totalBuyers={undefined}
totalPayments={undefined}
totalVolume={undefined}
isPending={true}
/>
}
>
<AsyncX402Summary
authToken={props.authToken}
projectId={props.projectId}
range={props.range}
teamId={props.teamId}
/>
</Suspense>
);
}
@@ -0,0 +1,160 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { useAllChainsData } from "@/hooks/chains/allChains";
import type { X402SettlementsByChainId } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByChainChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByChainId[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxChainsToDisplay = 10;
const isVolumeMetric = metric === "volume";
const chainsStore = useAllChainsData();
const { data, chainsToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const chainToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, chainId, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
// Convert chainId to chain name
const chain = chainsStore.idToChain.get(Number(chainId));
const chainName = chain?.name || chainId.toString();
dateRecord[chainName] = (dateRecord[chainName] || 0) + value;
chainToCountMap.set(
chainName,
(chainToCountMap.get(chainName) || 0) + value,
);
}
// Sort chains by count (highest count first) - remove the ones with 0 count
const sortedChainsByCount = Array.from(chainToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const chainsToDisplayArray = sortedChainsByCount
.slice(0, maxChainsToDisplay)
.map(([chain]) => chain);
const chainsToDisplaySet = new Set(chainsToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the chain that is not in chainsToDisplay with "Other"
// Add total key that is the sum of all chains
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !chainsToDisplaySet.has(key),
);
for (const chain of keysToMove) {
dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[chain] || 0);
delete dateRecord[chain];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < chainsToDisplayArray.length; i++) {
const chain = chainsToDisplayArray[i];
if (chain) {
chartConfig[chain] = {
label: chain,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" chains
if (sortedChainsByCount.length > maxChainsToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
chainsToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
chainsToDisplay: chainsToDisplayArray,
};
}, [rawData, isVolumeMetric, chainsStore]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Chain" : "Payments by Chain";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -0,0 +1,158 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import type { X402SettlementsByPayer } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByPayerChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByPayer[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxPayersToDisplay = 10;
const isVolumeMetric = metric === "volume";
const { data, payersToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const payerToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, payer, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
// Truncate payer address for display
const displayPayer =
payer.length > 10 ? `${payer.slice(0, 6)}...${payer.slice(-4)}` : payer;
dateRecord[displayPayer] = (dateRecord[displayPayer] || 0) + value;
payerToCountMap.set(
displayPayer,
(payerToCountMap.get(displayPayer) || 0) + value,
);
}
// Sort payers by count (highest count first) - remove the ones with 0 count
const sortedPayersByCount = Array.from(payerToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const payersToDisplayArray = sortedPayersByCount
.slice(0, maxPayersToDisplay)
.map(([payer]) => payer);
const payersToDisplay = new Set(payersToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the payer that is not in payersToDisplay with "Other"
// Add total key that is the sum of all payers
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !payersToDisplay.has(key),
);
for (const payer of keysToMove) {
dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[payer] || 0);
delete dateRecord[payer];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < payersToDisplayArray.length; i++) {
const payer = payersToDisplayArray[i];
if (payer) {
chartConfig[payer] = {
label: payer,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" payers
if (sortedPayersByCount.length > maxPayersToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
payersToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
payersToDisplay: payersToDisplayArray,
};
}, [rawData, isVolumeMetric]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Buyer" : "Payments by Buyer";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -0,0 +1,155 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import type { X402SettlementsByResource } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByResourceChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByResource[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxResourcesToDisplay = 10;
const isVolumeMetric = metric === "volume";
const { data, resourcesToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const resourceToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, resource, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
dateRecord[resource] = (dateRecord[resource] || 0) + value;
resourceToCountMap.set(
resource,
(resourceToCountMap.get(resource) || 0) + value,
);
}
// Sort resources by count (highest count first) - remove the ones with 0 count
const sortedResourcesByCount = Array.from(resourceToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const resourcesToDisplayArray = sortedResourcesByCount
.slice(0, maxResourcesToDisplay)
.map(([resource]) => resource);
const resourcesToDisplay = new Set(resourcesToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the resource that is not in resourcesToDisplay with "Other"
// Add total key that is the sum of all resources
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !resourcesToDisplay.has(key),
);
for (const resource of keysToMove) {
dateRecord.Other =
(dateRecord.Other || 0) + (dateRecord[resource] || 0);
delete dateRecord[resource];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < resourcesToDisplayArray.length; i++) {
const resource = resourcesToDisplayArray[i];
if (resource) {
chartConfig[resource] = {
label: resource,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" resources
if (sortedResourcesByCount.length > maxResourcesToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
resourcesToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
resourcesToDisplay: resourcesToDisplayArray,
};
}, [rawData, isVolumeMetric]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Resource" : "Payments by Resource";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -0,0 +1,249 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import type {
X402SettlementsByChainId,
X402SettlementsByPayer,
X402SettlementsByResource,
} from "@/types/analytics";
import { X402SettlementsByChainChartCard } from "./X402SettlementsByChainChartCard";
import { X402SettlementsByPayerChartCard } from "./X402SettlementsByPayerChartCard";
import { X402SettlementsByResourceChartCard } from "./X402SettlementsByResourceChartCard";
// Payments by Resource Chart
type X402SettlementsByResourceChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByResource[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByResourceChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByResourceChartProps) {
return (
<X402SettlementsByResourceChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByResourceChartProps = Omit<
X402SettlementsByResourceChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByResourceChart(
props: AsyncX402SettlementsByResourceChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "resource",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByResourceChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByResource[]}
/>
);
}
export function X402SettlementsByResourceChart(
props: AsyncX402SettlementsByResourceChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByResourceChartUI
{...props}
isPending={true}
stats={[]}
/>
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByResourceChart {...props} />
</ResponsiveSuspense>
);
}
// Payments by Payer Chart
type X402SettlementsByPayerChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByPayer[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByPayerChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByPayerChartProps) {
return (
<X402SettlementsByPayerChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByPayerChartProps = Omit<
X402SettlementsByPayerChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByPayerChart(
props: AsyncX402SettlementsByPayerChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "payer",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByPayerChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByPayer[]}
/>
);
}
export function X402SettlementsByPayerChart(
props: AsyncX402SettlementsByPayerChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByPayerChartUI {...props} isPending={true} stats={[]} />
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByPayerChart {...props} />
</ResponsiveSuspense>
);
}
// Payments by Chain Chart
type X402SettlementsByChainChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByChainId[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByChainChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByChainChartProps) {
return (
<X402SettlementsByChainChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByChainChartProps = Omit<
X402SettlementsByChainChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByChainChart(
props: AsyncX402SettlementsByChainChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "chainId",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByChainChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByChainId[]}
/>
);
}
export function X402SettlementsByChainChart(
props: AsyncX402SettlementsByChainChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByChainChartUI {...props} isPending={true} stats={[]} />
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByChainChart {...props} />
</ResponsiveSuspense>
);
}
@@ -0,0 +1,63 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function X402EmptyState(props: { walletAddress?: string }) {
return (
<WaitingForIntegrationCard
codeTabs={[
{
code: (
<CodeServer
className="bg-background"
code={jsCode(props.walletAddress)}
lang="ts"
/>
),
label: "JavaScript",
},
]}
ctas={[
{
href: "https://portal.thirdweb.com/x402",
label: "View Docs",
},
]}
title="Start Monetizing your API"
/>
);
}
const jsCode = (walletAddress?: string) => `\
import { createThirdwebClient } from "thirdweb";
import { facilitator, settlePayment } from "thirdweb/x402";
import { arbitrumSepolia } from "thirdweb/chains";
const client = createThirdwebClient({ secretKey: "your-secret-key" });
const thirdwebX402Facilitator = facilitator({
client,
serverWalletAddress: "${walletAddress || "0xYourWalletAddress"}",
});
export async function GET(request: Request) {
// process the payment
const result = await settlePayment({
resourceUrl: "https://api.example.com/premium-content",
method: "GET",
paymentData: request.headers.get("x-payment"),
network: arbitrumSepolia,
price: "$0.01",
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// Payment successful, continue to app logic
return Response.json({ data: "premium content" });
} else {
return Response.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
}
`;
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { loginRedirect } from "@/utils/redirects";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/x402/configuration`,
);
}
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border bg-card p-8 text-center">
<h2 className="text-2xl font-semibold">Coming Soon</h2>
<p className="mt-2 text-muted-foreground">
x402 payments configuration will be available soon.
</p>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { PayIcon } from "@/icons/PayIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/x402`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: PayIcon,
title: "x402 Payments",
description: "Instant payments for your APIs, apps and agents",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/x402",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,127 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import type { X402SettlementsOverall } from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import {
X402SettlementsByChainChart,
X402SettlementsByPayerChart,
X402SettlementsByResourceChart,
} from "./analytics";
import { ChartMetricSwitcher } from "./analytics/ChartsSection";
import { X402Summary } from "./analytics/Summary";
import { X402EmptyState } from "./components/X402EmptyState";
import { QuickStartSection } from "./QuickstartSection.client";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
metric?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/x402`);
}
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
// Check if there are any payments to determine if we should show empty state
const overallStats = await getX402Settlements(
{
from: range.from,
period: "all",
projectId: project.id,
teamId: project.teamId,
to: range.to,
groupBy: "overall",
},
authToken,
).catch(() => []);
const totalPayments = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalRequests,
0,
);
// Get project wallet for prefilling the code snippet
const projectWallet = await getProjectWallet(project);
const metric = (searchParams.metric as "payments" | "volume") || "payments";
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
{totalPayments === 0 ? (
<X402EmptyState walletAddress={projectWallet?.address} />
) : (
<>
<X402Summary
authToken={authToken}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<ChartMetricSwitcher />
<X402SettlementsByResourceChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByChainChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByPayerChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
</>
)}
<div className="pt-4">
<QuickStartSection />
</div>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -9,7 +9,12 @@ import { payLandingWallets } from "./wallets";
const client = getClientThirdwebClient();
export function StyledBuyWidget(
props: Omit<BuyWidgetProps, "client" | "theme">,
props: Omit<
BuyWidgetProps,
"client" | "theme" | "onSuccess" | "onError" | "onCancel"
> & {
successUrl?: string;
},
) {
const { theme } = useTheme();
@@ -22,6 +27,22 @@ export function StyledBuyWidget(
connectOptions={{
wallets: payLandingWallets,
}}
onSuccess={() => {
if (props.successUrl) {
try {
const url = new URL(props.successUrl);
url.searchParams.set("success", "true");
window.location.href = url.toString();
} catch (error) {
// Log URL construction error for debugging
console.error(
"Failed to construct redirect URL:",
props.successUrl,
error,
);
}
}
}}
/>
);
}
+30 -4
View File
@@ -21,19 +21,44 @@ type SearchParams = {
[key: string]: string | string[] | undefined;
};
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
const onlyNumber = (v: string) =>
Number.isNaN(Number(v)) ? undefined : Number(v);
/**
* Validates and normalizes a URL string.
* Only allows http: and https: protocols with a valid hostname.
* @returns normalized URL string on success, undefined on failure
*/
const onlyUrl = (v: string): string | undefined => {
try {
const url = new URL(v);
// Only allow http or https protocols
if (url.protocol !== "http:" && url.protocol !== "https:") {
return undefined;
}
// Ensure hostname is non-empty
if (!url.hostname) {
return undefined;
}
// Return normalized URL
return url.toString();
} catch {
// Invalid URL format
return undefined;
}
};
export default async function PayPage(props: {
searchParams: Promise<SearchParams>;
}) {
const searchParams = await props.searchParams;
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
const onlyNumber = (v: string) =>
Number.isNaN(Number(v)) ? undefined : Number(v);
const receiver = parse(searchParams.receiver, onlyAddress);
const token = parse(searchParams.token, onlyAddress);
const chain = parse(searchParams.chain, onlyNumber);
const amount = parse(searchParams.amount, onlyNumber);
const successUrl = parse(searchParams.successUrl, onlyUrl);
return (
<ThemeProvider
@@ -51,6 +76,7 @@ export default async function PayPage(props: {
tokenAddress={token}
receiverAddress={receiver}
amount={amount ? amount.toString() : undefined}
successUrl={successUrl}
/>
</div>
</div>
+28 -3
View File
@@ -59,12 +59,12 @@ const nextConfig = {
return [
{
source: "/connect/pay",
destination: "/payments/ui-components",
destination: "/bridge",
permanent: false,
},
{
source: "/connect/pay/:path*",
destination: "/payments/:path*",
destination: "/bridge",
permanent: false,
},
{
@@ -159,7 +159,32 @@ const nextConfig = {
},
{
source: "/payments/ui-components",
destination: "/payments",
destination: "/bridge",
permanent: false,
},
{
source: "/payments",
destination: "/bridge",
permanent: false,
},
{
source: "/payments/x402",
destination: "/x402",
permanent: false,
},
{
source: "/payments/fund-wallet",
destination: "/bridge/buy-widget",
permanent: false,
},
{
source: "/payments/commerce",
destination: "/bridge/checkout-widget",
permanent: false,
},
{
source: "/payments/transactions",
destination: "/bridge/transaction-widget",
permanent: false,
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"dependencies": {
"@abstract-foundation/agw-react": "^1.6.4",
"@abstract-foundation/agw-react": "^1.10.0",
"@ai-sdk/react": "^2.0.25",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-avatar": "^1.1.10",
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { createThirdwebClient, defineChain } from "thirdweb";
import { toUnits } from "thirdweb/utils";
import { facilitator, settlePayment } from "thirdweb/x402";
import { token } from "../../payments/x402/components/constants";
import { token } from "../../x402/components/constants";
// Allow streaming responses up to 5 minutes
export const maxDuration = 300;
@@ -4,7 +4,7 @@ import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { BuyPlayground } from "./BuyPlayground";
const title = "Buy Crypto Component";
const title = "Buy Widget";
const description =
"Embeddable component for users to purchase any cryptocurrency for top-ups and more with fiat or crypto-to-crypto swaps";
const ogDescription =
@@ -4,7 +4,7 @@ import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { CheckoutPlayground } from "./CheckoutPlayground";
const title = "Checkout Component";
const title = "Checkout Widget";
const description =
"Enable purchase of any service or goods with fiat or cryptocurrency and setup notifications on every sale to ship goods, activate services, and more";
const ogDescription =
@@ -15,8 +15,8 @@ import {
import { Button } from "../../../components/ui/button";
import { THIRDWEB_CLIENT } from "../../../lib/client";
import { cn } from "../../../lib/utils";
import { CodeGen } from "../components/CodeGen";
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
import { CodeGen } from "./CodeGen";
import type { BridgeComponentsPlaygroundOptions } from "./types";
const nftContract = getContract({
address: "0xf0d0CBf84005Dd4eC81364D1f5D7d896Bd53D1B8",
@@ -0,0 +1,14 @@
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { PayIcon } from "@/icons/PayIcon";
import { bridgeFeatureCards } from "../data/pages-metadata";
export default function Page() {
return (
<OverviewPage
title="Bridge"
description="thirdweb Bridge lets developers swap and transfer any token across any chain instantly"
featureCards={bridgeFeatureCards}
icon={PayIcon}
/>
);
}
@@ -4,7 +4,7 @@ import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { SwapWidgetPlayground } from "./components/swap-widget-playground";
const title = "Swap Widget Component";
const title = "Swap Widget";
const description =
"Embeddable component for users to swap any cryptocurrency with cross-chain support";
const ogDescription =
@@ -4,16 +4,13 @@ import { CodeExample } from "@/components/code/code-example";
import { PayTransactionButtonPreview } from "@/components/pay/transaction-button";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { TransactionPlayground } from "./TransactionPlayground";
const title = "Onchain Transaction Components";
const title = "Transaction Button";
const description =
"Enable seamless onchain transactions for any contract with fiat or crypto with amounts calculated and automatic execution after funds are confirmed.";
const ogDescription =
"Power onchain transactions with fiat or crypto payments. Automatically calculate costs and run the transaction post onramp or token swap.";
"Transaction Button component allows users to perform onchain transaction and prompts the user to fund the wallet if required from fiat or swap";
export const metadata = createMetadata({
description: ogDescription,
description: description,
title,
image: {
icon: "payments",
@@ -28,27 +25,16 @@ export default function Page() {
icon={ArrowLeftRightIcon}
containerClassName="space-y-12"
description={description}
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionButton"
title={title}
>
<div>
<h2 className="text-xl font-semibold tracking-tight mb-1">
Transaction Widget
</h2>
<p className="text-base text-muted-foreground mb-6">
Render a prebuilt UI for performing transactions using any token or
fiat. <br /> It handles the complete payment flow, supporting both
crypto and fiat payments across 50+ chains.
</p>
<TransactionPlayground />
</div>
<NoFundsPopup />
<Example />
</PageLayout>
</ThirdwebProvider>
);
}
function NoFundsPopup() {
function Example() {
return (
<CodeExample
code={`
@@ -111,15 +97,6 @@ return (
</div>
);
};`}
header={{
description: (
<>
Any transaction with value will automatically trigger onramp to fund
the wallet if needed before executing the transaction.
</>
),
title: "Transaction Button",
}}
lang="tsx"
preview={<PayTransactionButtonPreview />}
/>
@@ -0,0 +1,36 @@
import { ArrowLeftRightIcon } from "lucide-react";
import { PageLayout } from "@/components/blocks/APIHeader";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { TransactionPlayground } from "./TransactionPlayground";
const title = "Transaction Widget";
const description =
"Enable seamless onchain transactions for any contract with fiat or crypto with amounts calculated and automatic execution after funds are confirmed.";
const ogDescription =
"Power onchain transactions with fiat or crypto payments. Automatically calculate costs and run the transaction post onramp or token swap.";
export const metadata = createMetadata({
description: ogDescription,
title,
image: {
icon: "payments",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={ArrowLeftRightIcon}
containerClassName="space-y-12"
description={description}
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
title={title}
>
<TransactionPlayground />
</PageLayout>
</ThirdwebProvider>
);
}
@@ -1,71 +0,0 @@
import { RssIcon } from "lucide-react";
import { WatchEventPreview } from "@/components/blockchain-api/watch-event-preview";
import { CodeExample } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
const title = "Listen Contract Events";
const description =
"Subscribe to any contract event with auto-polling hooks and type-safe event handlers. Supports all common standards out of the box";
const ogDescription =
"Listen to blockchain contract events using auto-polling hooks and type-safe functions. Supports ERC20, ERC721, and other common Web3 standards with minimal setup.";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "contract",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={RssIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
>
<WatchEvent />
</PageLayout>
</ThirdwebProvider>
);
}
function WatchEvent() {
return (
<CodeExample
code={`import { useContractEvents } from "thirdweb/react";
import { getContract } from "thirdweb";
import { base } from "thirdweb/chains";
import { transferEvent } from "thirdweb/extensions/erc20";
const usdcContractOnBase = getContract({
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
chain: base,
client,
});
function App() {
// Listen to USDC transfers on Base
const contractEvents = useContractEvents({
contract: usdcContractOnBase,
events: [transferEvent()],
blockRange: 100,
});
(contractEvents.data || []).forEach((item) => {
const { from, to, value } = item.args;
console.log("{from}...{value} USDC...{to}");
});
}
`}
lang="tsx"
preview={<WatchEventPreview />}
/>
);
}
@@ -1,117 +0,0 @@
import { BlocksIcon } from "lucide-react";
import { ReadContractExtensionPreview } from "@/components/blockchain-api/read-contract-extension";
import { WriteContractExtensionPreview } from "@/components/blockchain-api/write-contract-extension";
import { CodeExample } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
const title = "Pre-Built Extensions";
const description =
"High-level ready and write functions with built-in pre/post-processing for common standards.";
const ogDescription =
"Simplify smart contract reads and writes with prebuilt extensions. Handle approvals, formatting, and standards with built-in pre/post-processing.";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "contract",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={BlocksIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
>
<div className="flex flex-col gap-14">
<ReadContractExtension />
<WriteContractExtension />
</div>
</PageLayout>
</ThirdwebProvider>
);
}
function ReadContractExtension() {
return (
<CodeExample
code={`import { getContract } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import { MediaRenderer, useReadContract } from "thirdweb/react";
const nftContract = getContract({
address: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d",
chain: ethereum,
client: THIRDWEB_CLIENT,
});
function App() {
const { data } = useReadContract(getNFT, {
contract: nftContract,
tokenId: 458n,
});
return (
<MediaRenderer
client={THIRDWEB_CLIENT}
src={data?.metadata.image}
/>
);
}
`}
header={{
description:
"Extensions let you do more with less code. High level functions with simple API that do pre and post processing for all common standards.",
title: "Prebuilt read extensions",
}}
lang="tsx"
preview={<ReadContractExtensionPreview />}
/>
);
}
function WriteContractExtension() {
return (
<CodeExample
code={`import { getContract } from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { claimTo } from "thirdweb/extensions/erc20";
const tw_coin = getContract({
address: "0xACf072b740a23D48ECd302C9052fbeb3813b60a6",
chain: sepolia,
client: THIRDWEB_CLIENT,
});
function App() {
return <TransactionButton
transaction={() =>
claimTo({
contract: twCoinContract,
to: account.address,
quantity: "10",
})
}
>
Claim
</TransactionButton>
}
`}
header={{
description:
"Extensions let you do more with less code. High level functions with simple API that do pre and post processing for all common standards.",
title: "Prebuilt write extensions",
}}
lang="tsx"
preview={<WriteContractExtensionPreview />}
/>
);
}

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