Compare commits

..
Author SHA1 Message Date
github-actions[bot]andJonas Daniels ef577028fd Version Packages 2025-11-14 08:28:40 -08:00
MananTank 304700f318 Improve Mobile detection in WC connection (#8415)
<!--

## 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 `isMobile` function to include viewport detection for mobile devices and refines the QR overlay creation logic for desktop environments by ensuring existing overlays are cleaned up before creating new ones.

### Detailed summary
- Added viewport detection in `isMobile` function to return `true` if `window.innerWidth` is less than 640.
- Refactored QR overlay creation in `create-wallet.ts` to ensure existing overlays are destroyed before creating a new one.

> ✨ 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 mobile device detection based on viewport size.
  * Enhanced QR code overlay display with better error handling and resource cleanup.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-14 16:10:19 +00:00
kumaryash90 6cd681c9ce Fix resolve implementation (#8413)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the `resolveImplementation` function in the `thirdweb` package by enhancing the implementation resolution process for proxy contracts.

### Detailed summary
- Replaced the existing implementation resolution logic with a more robust approach using `Promise.all` to fetch implementations from both storage and contract calls.
- Introduced a loop to handle multiple implementations, ensuring that valid addresses are checked before proceeding.
- Updated the return logic to ensure correct addresses and bytecode are returned based on the resolved implementation.

> ✨ 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 the reliability of contract implementation resolution to handle edge cases more robustly when detecting implementations across proxy contracts and various contract architecture types. Processing is now more efficient through concurrent implementation detection and optimized fallback handling, ensuring more consistent and faster results in complex contract scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-14 15:29:13 +00:00
FirekeeperandGitHub 144bb33ffd [Dashboard] Move Contracts back to top level sidebar (#8412) 2025-11-14 01:47:06 +07:00
jnsdls 01116bb650 consolidate gateway navbar (#8411)
# [Dashboard] Feature: Reorganize Gateway section in project sidebar

## Notes for the reviewer

This PR reorganizes the project sidebar by:
- Creating a new "Gateway" submenu that contains both RPC and Indexer (formerly Insight)
- Moving the Insight page to Gateway/Indexer
- Simplifying the Indexer FTUX component
- Updating all related file paths and imports

## How to test

Navigate to the project sidebar and verify:
- The Gateway submenu appears with RPC and Indexer options
- The former Insight page now appears under Gateway/Indexer
- All functionality continues to work as expected

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

* **New Features**
  * Added Indexer page and onboarding card; introduced Gateway submenu grouping RPC and Indexer.
  * Introduced a Wallets submenu consolidating wallet-related links; added a Bridge link and highlighted a monetization item with a "New" badge.
  * Sidebar now shows an Engine link when dedicated engines exist.

* **Refactor**
  * Removed standalone Insight product/page and its onboarding card.
  * Simplified engine onboarding and empty-state flow.

* **Chores**
  * Added redirects routing old insight/rpc paths to gateway equivalents.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on restructuring the `dashboard` application by reorganizing components and updating routes. It removes unused files, modifies imports, and adjusts the sidebar layout to improve navigation and functionality.

### Detailed summary
- Deleted several unused components related to `insight`.
- Moved `RpcAnalytics` and `MethodsTable` to a new path under `gateway`.
- Updated `loginRedirect` paths in several components.
- Added a new `InsightFTUX` component.
- Adjusted sidebar links and layout to reflect new component structure.
- Removed references to `InsightIcon` and related logic in various files.

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

<!-- end pr-codex -->
2025-11-13 13:00:24 +00:00
joaquim-verges f07b4b56c5 [SDK] Rename otherWallet to allConnectedWallets in autoConnect callback (#8408)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming the `otherWallet` parameter to `allConnectedWallets` in the `onConnect` callback to improve clarity and consistency in the code.

### Detailed summary
- Updated the `onConnect` callback type in `packages/thirdweb/src/wallets/connection/types.ts` to use `OnConnectCallback`.
- Renamed `otherWallet` to `allConnectedWallets` in the `onConnect` function signature.

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

<!-- end pr-codex -->
2025-11-12 22:46:33 +00:00
joaquim-verges 9442dc7729 [SDK] Rename otherWallets to allConnectedWallets in onConnect callback (#8407)
<!--

## 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 `onConnect` callback in the `AutoConnect` component to improve clarity in variable naming, changing `otherWallets` to `allConnectedWallets`.

### Detailed summary
- Changed the parameter name in `onConnect` from `otherWallets` to `allConnectedWallets`.
- Updated the console log statement to reflect the new parameter name, changing it from "other wallets that were also connected" to "all connected wallets".

> ✨ 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**
  * Updated parameter naming in the AutoConnect callback configuration to align with updated documentation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-12 22:46:32 +00:00
MananTank 6c4e684ee6 [PRO-90] Dashboard: Project > Bridge page changes (#8406)
<!--

## 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 payments and bridge components in the dashboard application. It updates routes, modifies component structures, and enhances the user interface for better functionality and clarity.

### Detailed summary
- Deleted `view-tx-status.tsx` and `project-settings-breadcrumb.tsx`.
- Updated payment-related paths to bridge configuration.
- Refactored `FeatureCard` and `QuickstartSection` components.
- Changed `TableRow` to `PurchaseTableRow` in `PaymentsTableRow`.
- Modified layout and routing in `bridge/layout.tsx` and `bridge/page.tsx`.
- Enhanced UI components for better responsiveness and clarity.

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

<!-- end pr-codex -->

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

* **New Features**
  * Added a Bridge configuration page with Overview and Configuration tabs.
  * Transaction hashes now show as copyable links in the payments table.

* **Improvements**
  * Updated Quickstart cards and labels to streamline onboarding and emphasize bridge setup.
  * Navigation paths updated to the Bridge configuration flow.
  * Added a styling hook for link icons and refined payments table layout and semantics.

* **Removals**
  * Removed a legacy breadcrumb component and an old transaction status widget.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-12 22:35:49 +00:00
nischitpra 68978711f8 default sort desc for tokens (#8405)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces a new sorting option for retrieving tokens, allowing results to be ordered in descending order based on their balance.

### Detailed summary
- Added a new property `sort_order` with the value `"desc"` to the object passed to `getV1Tokens`.

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

* **Improvements**
  * Token balances are now displayed in consistent descending order.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-12 21:53:55 +00:00
MananTank eca4ce2b82 Dashboard: Update announcement banner (#8404)
<!--

## 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 `label` prop of the `AnnouncementBannerUI` component to provide more detailed information about recent changes related to Wallets in the Project Dashboard.

### Detailed summary
- Updated the `label` prop of the `AnnouncementBannerUI` component from:
  - "Project View Update - Wallets"
  - To: "Transactions, Vault, and Account Abstraction are now part of Wallets in Project Dashboard. View changes"

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * Updated dashboard announcement banner with information about recent Wallets feature changes, including details on Transactions, Vault, and Account Abstraction integration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-12 20:59:56 +00:00
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
270 changed files with 7415 additions and 3645 deletions
+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",
+15
View File
@@ -115,6 +115,16 @@ const projectPageRedirects = [
permanent: false,
source: `${projectRoute}/connect/analytics`,
},
{
destination: `${projectRoute}/gateway/indexer/:path*`,
permanent: false,
source: `${projectRoute}/insight/:path*`,
},
{
destination: `${projectRoute}/gateway/rpc/:path*`,
permanent: false,
source: `${projectRoute}/rpc/:path*`,
},
];
const teamPageRedirects = [
@@ -479,6 +489,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="Transactions, Vault, and Account Abstraction are now part of Wallets in Project Dashboard. View changes"
trackingLabel="project-view-update-wallets"
/>
);
}
@@ -12,6 +12,7 @@ export function LinkWithCopyButton(props: {
textToCopy: string;
copyTooltip: string;
className?: string;
linkIconClassName?: string;
}) {
const [isCopied, setIsCopied] = useState(false);
const Icon = isCopied ? CheckIcon : CopyIcon;
@@ -37,8 +38,15 @@ export function LinkWithCopyButton(props: {
target="_blank"
className="text-sm text-muted-foreground hover:underline flex items-center gap-1 tabular-nums flex-1 truncate hover:text-foreground group"
>
<span className="max-w-full truncate">{props.textToShow}</span>
<ArrowUpRightIcon className="size-3.5 opacity-70 shrink-0 group-hover:opacity-100" />
<span className="max-w-full truncate tabular-nums">
{props.textToShow}
</span>
<ArrowUpRightIcon
className={cn(
"size-3.5 opacity-70 shrink-0 group-hover:opacity-100",
props.linkIconClassName,
)}
/>
</Link>
</div>
);
@@ -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"
@@ -1,7 +1,6 @@
import { ConnectSDKIcon } from "@/icons/ConnectSDKIcon";
import { ContractIcon } from "@/icons/ContractIcon";
import { EngineIcon } from "@/icons/EngineIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { PayIcon } from "@/icons/PayIcon";
import { RPCIcon } from "@/icons/RPCIcon";
@@ -51,13 +50,6 @@ export const products = [
link: "https://portal.thirdweb.com/payments",
name: "Payments",
},
{
description: "Query and analyze blockchain data",
icon: InsightIcon,
id: "insight",
link: "https://thirdweb.com/insight",
name: "Insight",
},
{
description: "The most powerful AI for interacting with the blockchain",
icon: NebulaIcon,
@@ -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"
>
@@ -1,10 +1,6 @@
"use client";
import {
ArrowRightLeftIcon,
BadgeDollarSignIcon,
WebhookIcon,
} from "lucide-react";
import { CodeIcon, WebhookIcon } from "lucide-react";
import { FeatureCard } from "../payments/components/FeatureCard.client";
export function QuickStartSection(props: {
@@ -24,32 +20,36 @@ export function QuickStartSection(props: {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard
title="Cross-chain Swap Tokens"
icon={ArrowRightLeftIcon}
setupTime={5}
title="Buy Widget Component"
icon={CodeIcon}
id="swap_tokens"
features={["Swap any token", "Cross-chain swap"]}
description="Swap tokens cross-chain with dedicated swapping endpoints."
features={[
"React component",
"Cross-chain token swaps across 85+ blockchains",
"Fiat onramp support to buy tokens with credit/debit cards",
"Customizable UI",
]}
description={undefined}
link={{
href: `https://portal.thirdweb.com/bridge/swap`,
label: "Setup Swap",
label: "Get Started",
}}
/>
<FeatureCard
title="Earn Fees"
description="Setup fees to earn any time a user swaps or bridges funds."
icon={BadgeDollarSignIcon}
title="Bridge Widget Script"
description={undefined}
icon={CodeIcon}
id="fees"
setupTime={1}
features={[
"Fees on every purchase",
"Custom percentage",
"Directly to your wallet",
"Integrate with a script tag",
"Cross-chain token swaps across 85+ blockchains",
"Fiat onramp support to buy tokens with credit/debit cards",
"Customizable UI",
]}
link={{
href: `/team/${props.teamSlug}/${props.projectSlug}/settings/payments`,
label: "Configure Fees",
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
label: "Get Started",
}}
/>
@@ -57,7 +57,6 @@ export function QuickStartSection(props: {
title="Webhooks"
description="Create Webhooks to get notified on each purchase or transaction."
icon={WebhookIcon}
setupTime={5}
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
@@ -3,9 +3,10 @@ import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { getFees } from "@/api/universal-bridge/developer";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { loginRedirect } from "@/utils/redirects";
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
import { RouteDiscovery } from "../RouteDiscovery";
import { PayConfig } from "./PayConfig";
export default async function Page(props: {
@@ -21,7 +22,7 @@ export default async function Page(props: {
]);
if (!authToken) {
loginRedirect(`/team/${team_slug}/${project_slug}/settings/payments`);
loginRedirect(`/team/${team_slug}/${project_slug}/bridge/configuration`);
}
if (!team) {
@@ -56,14 +57,13 @@ export default async function Page(props: {
};
}
return (
<div className="flex flex-col gap-5">
<ProjectSettingsBreadcrumb
teamSlug={team_slug}
projectSlug={project_slug}
page="Payments"
/>
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<div className="flex flex-col gap-6">
<PayConfig
fees={fees}
project={project}
@@ -72,6 +72,8 @@ export default async function Page(props: {
teamSlug={team_slug}
authToken={authToken}
/>
<RouteDiscovery client={client} project={project} />
</div>
);
}
@@ -0,0 +1,83 @@
import { WebhookIcon } from "lucide-react";
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 { BridgeIcon } from "@/icons/BridgeIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
children: React.ReactNode;
}) {
const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
const project = await getProject(params.team_slug, params.project_slug);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/bridge`);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
title: "Bridge",
icon: BridgeIcon,
description: (
<>
Bridge lets developers swap and transfer any token across any chain
instantly
</>
),
actions: {
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/bridge",
},
{
type: "playground",
href: "https://playground.thirdweb.com/bridge/swap-widget",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/bridge",
},
],
}}
tabs={[
{
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/bridge`,
exactMatch: true,
},
{
name: "Configuration",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -1,17 +1,12 @@
import { WebhookIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
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 { BridgeIcon } from "@/icons/BridgeIcon";
import { loginRedirect } from "@/utils/redirects";
import { PayAnalytics } from "../payments/components/PayAnalytics";
import { getUniversalBridgeFiltersFromSearchParams } from "../payments/components/time";
import { QuickStartSection } from "./QuickstartSection.client";
import { RouteDiscovery } from "./RouteDiscovery";
import { ViewTxStatus } from "./view-tx-status";
export default async function Page(props: {
params: Promise<{
@@ -51,66 +46,27 @@ export default async function Page(props: {
});
return (
<ProjectPage
header={{
client,
title: "Bridge",
icon: BridgeIcon,
description: (
<>
Bridge lets developers swap and transfer any token across any chain
instantly
</>
),
actions: {
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/bridge",
},
{
type: "playground",
href: "https://playground.thirdweb.com/bridge/swap-widget",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/bridge",
},
],
}}
>
<div className="flex flex-col gap-6">
<ResponsiveSearchParamsProvider value={searchParams}>
<PayAnalytics
client={client}
interval={interval}
projectClientId={project.publishableKey}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSearchParamsProvider>
<div className="flex flex-col gap-6">
<ResponsiveSearchParamsProvider value={searchParams}>
<PayAnalytics
client={client}
interval={interval}
projectClientId={project.publishableKey}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSearchParamsProvider>
<RouteDiscovery client={client} project={project} />
<ViewTxStatus client={client} />
<div className="pt-4">
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
<div className="pt-4">
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</ProjectPage>
</div>
);
}
@@ -1,72 +0,0 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function ViewTxStatus(props: { client: ThirdwebClient }) {
const [txHash, setTxHash] = useState("");
const [chainId, setChainId] = useState<number>(1);
const isValidTxHash = txHash.length === 66 && txHash.startsWith("0x");
const isEnabled = !!txHash && !!chainId && isValidTxHash;
return (
<div className="bg-card rounded-xl border">
<div className="p-4 lg:p-6 mb-3">
<h3 className="font-semibold text-xl tracking-tight mb-1">
View transaction status
</h3>
<p className="text-sm text-muted-foreground mb-4">
Select chain and enter the transaction hash to view the transaction
status.
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
<div className="space-y-2">
<Label>Chain</Label>
<SingleNetworkSelector
chainId={chainId}
onChange={setChainId}
client={props.client}
disableChainId
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="view-tx-hash">Transaction Hash</Label>
<Input
id="view-tx-hash"
value={txHash}
onChange={(e) => setTxHash(e.target.value)}
placeholder="0x.."
/>
{txHash && !isValidTxHash && (
<p className="text-sm text-destructive-text">
Invalid transaction hash
</p>
)}
</div>
</div>
</div>
<div className="px-4 lg:px-6 py-4 border-t border-dashed justify-end items-center flex">
<Button
asChild
disabled={!isEnabled}
size="sm"
variant={isEnabled ? "default" : "outline"}
className="gap-1.5 rounded-full"
>
<Link href={`/${chainId.toString()}/tx/${txHash}`} target="_blank">
View Transaction Status <ArrowUpRightIcon className="size-4" />
</Link>
</Button>
</div>
</div>
);
}
@@ -13,7 +13,6 @@ import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
import { ContractIcon } from "@/icons/ContractIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { PayIcon } from "@/icons/PayIcon";
import { ClientIDSection } from "./ClientIDSection";
import { SecretKeySection } from "./SecretKeySection";
@@ -111,13 +110,6 @@ function ProductsSection(props: { teamSlug: string; projectSlug: string }) {
icon: ContractIcon,
title: "Contracts",
},
{
description:
"Add indexing capabilities to retrieve real-time onchain data",
href: `/team/${props.teamSlug}/${props.projectSlug}/insight`,
icon: InsightIcon,
title: "Insight",
},
{
description:
"Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing",
@@ -1,160 +1,153 @@
"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";
export function ProjectSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
hasEngines: boolean;
}) {
const contentSidebarLinks = [
{
exactMatch: true,
href: props.layoutPath,
icon: HomeIcon,
label: "Overview",
},
{
separator: true,
},
{
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}/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",
},
{
href: `${props.layoutPath}/ai`,
icon: NebulaIcon,
label: "AI",
},
{
subMenu: {
icon: RssIcon,
label: "Gateway",
},
links: [
{
href: `${props.layoutPath}/gateway/rpc`,
label: "RPC",
},
{
href: `${props.layoutPath}/gateway/indexer`,
label: "Indexer",
},
],
},
// only show engine link if there the user already has an engine instance
...(props.hasEngines
? [
{
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>
@@ -6,15 +6,10 @@ import {
ArrowRightIcon,
CheckIcon,
CircleAlertIcon,
DatabaseIcon,
InfinityIcon,
InfoIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
ShieldCheckIcon,
Trash2Icon,
WalletIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -22,7 +17,6 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { Team } from "@/api/team/get-team";
import { CheckoutButton } from "@/components/billing/billing";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -31,20 +25,10 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
@@ -72,7 +56,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import {
type DeleteCloudHostedEngineParams,
deleteCloudHostedEngine,
@@ -84,7 +67,6 @@ import {
} from "@/hooks/useEngine";
import { EngineIcon } from "@/icons/EngineIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
type DeletedCloudHostedEngine = (
params: DeleteCloudHostedEngineParams,
@@ -126,96 +108,6 @@ export function EngineInstancesTable(props: {
);
}
export function DedicatedEngineSubscriptionButton(props: { team: Team }) {
const [open, setOpen] = useState(false);
const isMobile = useIsMobile();
const trigger = (
<Button className="gap-2 rounded-full" size="sm">
<PlusIcon className="size-3.5" />
Deploy Dedicated Engine
</Button>
);
const title = (
<div className="flex flex-row items-center gap-2">
<div className="grid size-8 place-items-center rounded-full bg-warning-text">
<DatabaseIcon className="size-4 text-white" />
</div>
<span className="font-semibold text-lg">Dedicated Engine</span>
</div>
);
const content = (
<div className="flex flex-col items-start justify-center gap-4 px-4 md:px-0">
<ul className="flex flex-col gap-1 self-start text-muted-foreground text-sm">
<li className="flex items-center gap-2">
<ShieldCheckIcon className="size-4 text-foreground" />
Isolated environment
</li>
<li className="flex items-center gap-2">
<WalletIcon className="size-4 text-foreground" />
EOA or Smart Wallets
</li>
<li className="flex items-center gap-2">
<InfinityIcon className="size-4 text-foreground" />
No transaction usage limits or charges
</li>
</ul>
</div>
);
if (isMobile) {
return (
<Drawer onOpenChange={setOpen} open={open}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>
Instantly deploy a dedicated engine instance for your team.
</DrawerDescription>
</DrawerHeader>
{content}
<DrawerFooter>
<CheckoutButton
billingStatus={props.team.billingStatus}
sku="product:engine_standard"
teamSlug={props.team.slug}
>
Deploy Now · $299 / month
</CheckoutButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
Instantly deploy a dedicated engine instance for your team.
</DialogDescription>
</DialogHeader>
{content}
<DialogFooter>
<CheckoutButton
billingStatus={props.team.billingStatus}
sku="product:engine_standard"
teamSlug={props.team.slug}
>
Deploy Now · $299 / month
</CheckoutButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function EngineInstancesTableUI(props: {
instances: EngineInstance[];
engineLinkPrefix: string;
@@ -896,10 +788,6 @@ function DeleteEngineSubscriptionModalContent(props: {
}
function EmptyEngineState(props: { team: Team; projectSlug: string }) {
const [selectedTab, setSelectedTab] = useState<
"self-hosted" | "cloud-hosted"
>("cloud-hosted");
return (
<div className="flex flex-col items-center justify-center rounded-lg border bg-card px-4 py-12 lg:px-6">
<div className="mb-4 rounded-full border bg-card p-2">
@@ -908,75 +796,34 @@ function EmptyEngineState(props: { team: Team; projectSlug: string }) {
<h2 className="mb-1 font-semibold text-xl">No Engine instances found</h2>
<p className="mb-6 max-w-lg text-center text-muted-foreground text-sm">
Get started with one of the options below to add your first engine
instance.
Import an Engine instance running on your own infrastructure.
</p>
<div className="w-full max-w-md">
<div className="mx-auto max-w-sm">
<div className="grid grid-cols-2 gap-1 rounded-lg border p-1">
<Button
className={cn(selectedTab === "cloud-hosted" && "bg-accent")}
onClick={() => setSelectedTab("cloud-hosted")}
size="sm"
variant="ghost"
>
Managed
</Button>
<div className="h-5" />
<div className="flex flex-col text-center">
<h3 className="mb-0.5 font-semibold text-base">Self-Hosted Engine</h3>
<p className="text-muted-foreground text-sm">
Add engine instance running on your own infrastructure.
</p>
<div className="h-4" />
<div className="mt-auto">
<Button
className={cn(selectedTab === "self-hosted" && "bg-accent")}
onClick={() => setSelectedTab("self-hosted")}
asChild
className="w-full gap-2"
size="sm"
variant="ghost"
variant="default"
>
Self-hosted
<Link
href={`/team/${props.team.slug}/${props.projectSlug}/engine`}
>
Import self-hosted Engine
<ArrowRightIcon size={16} />
</Link>
</Button>
</div>
</div>
<div className="h-5" />
{selectedTab === "self-hosted" && (
<div className="flex flex-col text-center">
<h3 className="mb-0.5 font-semibold text-base">
Self-Hosted Engine
</h3>
<p className="text-muted-foreground text-sm">
Add engine instance running on your own infrastructure.
</p>
<div className="h-4" />
<div className="mt-auto">
<Button
asChild
className="w-full gap-2"
size="sm"
variant="default"
>
<Link
href={`/team/${props.team.slug}/${props.projectSlug}/engine`}
>
Import self-hosted Engine
<ArrowRightIcon size={16} />
</Link>
</Button>
</div>
</div>
)}
{selectedTab === "cloud-hosted" && (
<div className="flex flex-col text-center">
<h3 className="mb-0.5 font-semibold text-base">Managed Engine</h3>
<p className="text-muted-foreground text-sm">
Deploy a managed engine instance to your team. <br /> We recommend
using Engine Cloud in most cases.
</p>
<div className="h-4" />
<div className="mt-auto">
<DedicatedEngineSubscriptionButton team={props.team} />
</div>
</div>
)}
</div>
<div className="mt-4 text-center text-muted-foreground text-sm">
@@ -7,7 +7,6 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { getEngineInstances } from "../_utils/getEngineInstances";
import { ImportEngineButton } from "./import/import-engine-dialog";
import { DedicatedEngineSubscriptionButton } from "./overview/engine-instances-table";
import { EngineInstancesList } from "./overview/engine-list";
export default async function Page(props: {
@@ -61,9 +60,6 @@ export default async function Page(props: {
title: "Engine",
description: "Manage your deployed Engine instances.",
actions: {
primary: {
component: <DedicatedEngineSubscriptionButton team={team} />,
},
secondary: {
component: (
<ImportEngineButton
@@ -13,7 +13,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CardHeading } from "../../payments/components/common";
import { CardHeading } from "../../../payments/components/common";
export function TopInsightChainsTable(props: {
data: InsightChainStats[];
@@ -13,7 +13,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CardHeading } from "../../payments/components/common";
import { CardHeading } from "../../../payments/components/common";
export function TopInsightEndpointsTable(props: {
data: InsightEndpointStats[];
@@ -0,0 +1,30 @@
export function InsightFTUX(_props: { clientId: string }) {
return (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">Indexer</h2>
</div>
<div className="px-4 py-6 lg:p-6">
<p className="text-muted-foreground text-sm">
Indexed data is automatically used when using the thirdweb SDKs and
API where appropriate.
</p>
<p className="text-muted-foreground text-sm">
Once you have integrated thirdweb into your application you will start
seeing usage patterns here.
</p>
</div>
<div className="flex flex-col gap-3 border-t p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
<div className="flex gap-3"></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 integration
</p>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
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 { InsightIcon } from "@/icons/InsightIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { InsightAnalytics } from "./components/InsightAnalytics";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
}>;
}) {
const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
const project = await getProject(params.team_slug, params.project_slug);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/gateway/indexer`,
);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const searchParams = await props.searchParams;
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
icon: InsightIcon,
title: "Indexer",
description:
"Indexed data for EVM chains, available automatically via the thirdweb SDKs and API.",
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/contracts/events",
},
{
type: "api",
href: "https://portal.thirdweb.com/reference",
},
],
}}
>
<ResponsiveSearchParamsProvider value={searchParams}>
<InsightAnalytics
client={client}
interval={interval}
projectClientId={project.publishableKey}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSearchParamsProvider>
</ProjectPage>
);
}
@@ -15,7 +15,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CardHeading } from "../../payments/components/common";
import { CardHeading } from "../../../payments/components/common";
export function TopRPCMethodsTable(props: {
data: RpcMethodStats[];
@@ -1,7 +1,7 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { isProd } from "@/constants/env-utils";
import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection";
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
import { ClientIDSection } from "../../../components/ProjectFTUX/ClientIDSection";
import { WaitingForIntegrationCard } from "../../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function RpcFTUX(props: { clientId: string }) {
return (
@@ -25,7 +25,9 @@ export default async function Page(props: {
const project = await getProject(params.team_slug, params.project_slug);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/rpc`);
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/gateway/rpc`,
);
}
if (!project) {
@@ -1,74 +0,0 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { isProd } from "@/constants/env-utils";
import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection";
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function InsightFTUX(props: { clientId: string }) {
return (
<WaitingForIntegrationCard
codeTabs={[
{
code: (
<CodeServer
className="bg-background"
code={jsCode(props.clientId)}
lang="ts"
/>
),
label: "JavaScript",
},
{
code: (
<CodeServer
className="bg-background"
code={pythonCode(props.clientId)}
lang="python"
/>
),
label: "Python",
},
{
code: (
<CodeServer
className="bg-background"
code={curlCode(props.clientId)}
lang="bash"
/>
),
label: "Curl",
},
]}
ctas={[
{
href: "https://portal.thirdweb.com/insight",
label: "View Docs",
},
]}
title="Integrate Insight"
>
<ClientIDSection clientId={props.clientId} />
<div className="h-4" />
</WaitingForIntegrationCard>
);
}
const twDomain = isProd ? "thirdweb" : "thirdweb-dev";
const jsCode = (clientId: string) => `\
// Example: Get latest 10 transactions on Ethereum
const res = await fetch("https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}");
const data = await res.json();
`;
const curlCode = (clientId: string) => `\
# Example: Get latest 10 transactions on Ethereum
curl -X GET "https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}"
`;
const pythonCode = (clientId: string) => `\
# Example: Get latest 10 transactions on Ethereum
import requests
response = requests.get("https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}")
data = response.json()
`;
@@ -1,113 +0,0 @@
import { ArrowUpRightIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
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 { InsightIcon } from "@/icons/InsightIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { InsightAnalytics } from "./components/InsightAnalytics";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
}>;
}) {
const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
const project = await getProject(params.team_slug, params.project_slug);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/insight`);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const searchParams = await props.searchParams;
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
icon: InsightIcon,
title: "Insight",
description:
"APIs to retrieve blockchain data from any EVM chain, enrich it with metadata, and transform it using custom logic.",
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/insight",
},
{
type: "api",
href: "https://insight-api.thirdweb.com/reference",
},
{
type: "webhooks",
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
],
}}
>
<ResponsiveSearchParamsProvider value={searchParams}>
<div>
<InsightAnalytics
client={client}
interval={interval}
projectClientId={project.publishableKey}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
<div className="h-10" />
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with Insight
</h3>
<p className="text-muted-foreground text-sm">
A cross-chain API for historic blockchain data.
</p>
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/insight"
rel="noopener noreferrer"
target="_blank"
>
Learn More
<ArrowUpRightIcon className="size-4" />
</a>
</div>
</div>
</div>
</ResponsiveSearchParamsProvider>
</ProjectPage>
);
}
@@ -12,6 +12,7 @@ import { TeamHeaderLoggedIn } from "../../../components/TeamHeader/team-header-l
import { StaffModeNotice } from "../../(team)/_components/StaffModeNotice";
import { ProjectSidebarLayout } from "./components/ProjectSidebarLayout";
import { SaveLastUsedProject } from "./components/SaveLastUsedProject";
import { getEngineInstances } from "./engine/_utils/getEngineInstances";
export default async function ProjectLayout(props: {
children: React.ReactNode;
@@ -20,15 +21,34 @@ export default async function ProjectLayout(props: {
}) {
const params = await props.params;
const [accountAddress, teams, account, authToken, team, project] =
await Promise.all([
getAuthTokenWalletAddress(),
getTeams(),
getValidAccount(`/team/${params.team_slug}/${params.project_slug}`),
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
const [
accountAddress,
teams,
account,
authToken,
team,
project,
hasLegacyDedicatedEngines,
] = await Promise.all([
getAuthTokenWalletAddress(),
getTeams(),
getValidAccount(`/team/${params.team_slug}/${params.project_slug}`),
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
(async () => {
const authToken = await getAuthToken();
if (!authToken) {
return false;
}
const instances = await getEngineInstances({
authToken,
teamIdOrSlug: params.team_slug,
});
// if there are any engine instances, return true, otherwise false
return !!instances?.data?.length;
})(),
]);
if (!teams || !accountAddress || !authToken) {
redirect("/login");
@@ -73,7 +93,10 @@ export default async function ProjectLayout(props: {
teamsAndProjects={teamsAndProjects}
/>
</div>
<ProjectSidebarLayout layoutPath={layoutPath}>
<ProjectSidebarLayout
layoutPath={layoutPath}
hasEngines={hasLegacyDedicatedEngines}
>
{props.children}
</ProjectSidebarLayout>
</div>
@@ -9,7 +9,7 @@ import { Card } from "@/components/ui/card";
type FeatureCardProps = {
title: string;
description: string;
description: string | undefined;
badge?: { label: string; variant?: "outline" | "success" | "default" };
id: string;
icon: React.FC<{ className?: string }>;
@@ -49,14 +49,18 @@ export function FeatureCard(props: FeatureCardProps) {
</div>
{/* title and description */}
<h3 className="font-semibold text-lg text-foreground mb-1">
{props.title}
</h3>
<p className="text-muted-foreground text-sm font-medium text-pretty">
{props.description}
</p>
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-lg text-foreground">
{props.title}
</h3>
{props.description && (
<p className="text-muted-foreground text-sm font-medium text-pretty">
{props.description}
</p>
)}
</div>
<div className="h-5" />
<div className="h-3" />
<div className="space-y-3">
{props.setupTime && (
@@ -15,14 +15,16 @@ import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow as TableRowComponent,
} from "@/components/ui/table";
import { TableData, TableHeading, TableHeadingRow } from "./common";
import { formatTokenAmount } from "./format";
import { TableRow } from "./PaymentsTableRow";
import { PurchaseTableRow } from "./PaymentsTableRow";
const pageSize = 20;
const pageSize = 10;
export function PaymentHistory(props: {
client: ThirdwebClient;
@@ -79,14 +81,15 @@ export function PaymentHistory(props: {
<TableContainer>
<Table>
<TableHeader>
<TableHeadingRow>
<TableHeading> Sent </TableHeading>
<TableHeading> Received </TableHeading>
<TableHeading>Type</TableHeading>
<TableHeading>Status</TableHeading>
<TableHeading>Recipient</TableHeading>
<TableHeading>Date</TableHeading>
</TableHeadingRow>
<TableRowComponent>
<TableHead> Sent </TableHead>
<TableHead> Received </TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Transactions</TableHead>
</TableRowComponent>
</TableHeader>
<TableBody>
{(!isEmpty || isLoading) &&
@@ -95,7 +98,7 @@ export function PaymentHistory(props: {
.filter(isBridgePayment)
.map((purchase) => {
return (
<TableRow
<PurchaseTableRow
client={props.client}
key={purchase.id}
purchase={purchase}
@@ -174,25 +177,31 @@ function getCSVData(data: BridgePayment[]) {
function SkeletonTableRow() {
return (
<tr className="border-border border-b">
<TableData>
<TableRowComponent className="h-[73px]">
<TableCell>
<Skeleton className="h-7 w-20" />
</TableData>
<TableData>
</TableCell>
<TableCell>
<Skeleton className="h-7 w-20" />
</TableData>
<TableData>
</TableCell>
<TableCell>
<Skeleton className="h-7 w-20 rounded-2xl" />
</TableData>
<TableData>
</TableCell>
<TableCell>
<Skeleton className="h-7 w-20 rounded-2xl" />
</TableData>
<TableData>
</TableCell>
<TableCell>
<Skeleton className="h-7 w-[140px]" />
</TableData>
<TableData>
</TableCell>
<TableCell>
<Skeleton className="h-7 w-[200px]" />
</TableData>
</tr>
</TableCell>
<TableCell>
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-[100px]" />
<Skeleton className="h-5 w-[100px]" />
</div>
</TableCell>
</TableRowComponent>
);
}
@@ -3,14 +3,12 @@ import { type ThirdwebClient, toTokens } from "thirdweb";
import type { BridgePayment } from "@/api/universal-bridge/developer";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import {
TableCell,
TableRow as TableRowComponent,
} from "@/components/ui/table";
import { LinkWithCopyButton } from "@/components/ui/link-with-copy-button";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { formatTokenAmount } from "./format";
export function TableRow(props: {
export function PurchaseTableRow(props: {
purchase: BridgePayment;
client: ThirdwebClient;
}) {
@@ -34,10 +32,7 @@ export function TableRow(props: {
})();
return (
<TableRowComponent
className="fade-in-0 border-border border-b duration-300"
key={purchase.id}
>
<TableRow key={purchase.id}>
{/* Paid */}
<TableCell>{`${formatTokenAmount(originAmount)} ${purchase.originToken.symbol}`}</TableCell>
@@ -88,6 +83,27 @@ export function TableRow(props: {
{format(new Date(purchase.createdAt), "LLL dd, y h:mm a")}
</p>
</TableCell>
</TableRowComponent>
{/* tx Hash */}
<TableCell>
{purchase.transactions.length > 0 ? (
<div className="flex flex-col gap-2">
{purchase.transactions.map((tx) => (
<LinkWithCopyButton
key={tx.transactionHash}
className="-translate-x-1"
href={`/${tx.chainId}/tx/${tx.transactionHash}`}
textToShow={`${tx.transactionHash.slice(0, 6)}...${tx.transactionHash.slice(-4)}`}
textToCopy={tx.transactionHash}
copyTooltip="Copy Transaction Hash"
linkIconClassName="hidden"
/>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">N/A</p>
)}
</TableCell>
</TableRow>
);
}
@@ -71,7 +71,7 @@ export function QuickStartSection(props: {
"Directly to your wallet",
]}
link={{
href: `/team/${props.teamSlug}/${props.projectSlug}/settings/payments`,
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
label: "Configure Fees",
}}
/>
@@ -72,7 +72,7 @@ export default async function Page(props: {
},
},
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/payments`,
href: `/team/${params.team_slug}/${params.project_slug}/bridge/configuration`,
},
links: [
{
@@ -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,10 +220,10 @@ 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`,
payConfig: `${projectLayout}/settings/payments`,
inAppConfig: `${projectLayout}/wallets/user-wallets/configuration`,
payConfig: `${projectLayout}/bridge/configuration`,
};
const { project } = props;
@@ -1,32 +0,0 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
export function ProjectSettingsBreadcrumb(props: {
teamSlug: string;
projectSlug: string;
page: string;
}) {
const { teamSlug, projectSlug } = props;
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={`/team/${teamSlug}/${projectSlug}/settings`}>
Project Settings
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{props.page}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
@@ -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";
@@ -32,14 +34,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
@@ -88,6 +82,7 @@ interface ServerWalletsTableProps {
client: ThirdwebClient;
solanaPermissionError?: boolean;
authToken: string;
pageSize: number;
}
export function ServerWalletsTable(props: ServerWalletsTableProps) {
@@ -105,6 +100,7 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
client,
solanaPermissionError,
authToken,
pageSize,
} = props;
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
@@ -333,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}
/>
)}
</>
@@ -349,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>
);
}
@@ -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,
@@ -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,224 +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}
authToken={authToken}
/>
)}
</div>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
@@ -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,226 +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}
authToken={authToken}
/>
)}
</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>
);
}
@@ -46,7 +46,7 @@ export default async function Layout(props: {
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Payments & Bridge",
name: "Bridge",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
},
]}
@@ -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, chartConfig } = 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"
/>
);
}

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