Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
dffa457a39 Version Packages (#8498)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 19:50:37 -08:00
Joaquim VergesandGitHub 859032dc17 [SDK] Skip swap review step when coming from swap widget (#8496) 2025-12-03 19:23:42 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e0ee69b061 Version Packages (#8494)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 17:47:50 -08:00
Joaquim VergesandGitHub b6be8d67ef [SDK] Faster token balance load query (#8495) 2025-12-03 17:38:16 -08:00
Joaquim VergesandGitHub 6ddb15cf02 [SDK] Optimize payment methods query by removing quotes and improving balance checks (#8493) 2025-12-03 17:00:25 -08:00
joaquim-verges 551ec680df [SDK] Add payment selection tracking (#8490)
<!--

## 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 payment selection tracking throughout the `thirdweb` package by replacing `useQuery` calls with `useEffect` hooks to ensure that payment events are tracked only once per component lifecycle.

### Detailed summary
- Replaced `useQuery` for tracking payment events with `useEffect` in multiple components.
- Added `useRef` to prevent multiple event firings.
- Updated tracking events in:
  - `UnsupportedTokenScreen`
  - `BridgeWidget`
  - `ErrorBanner`
  - `QuoteLoader`
  - `PaymentSelection`
  - `PaymentDetails`
  - `SwapWidget`
  - `SuccessScreen`
  - `CheckoutWidget`
  - `TransactionWidget`
  - `BuyWidget`
  - `BridgeWidget`

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

<!-- end pr-codex -->

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

* **Chores**
  * Refined Bridge telemetry: replaced reactive query-based tracking with guarded one-time render and interaction events to reduce duplicates.
  * Expanded analytics coverage for widgets, payment selection, loading/quote stages, payment details, swaps, unsupported-token and success flows.
  * Cleaned up and simplified tracking behavior: some legacy query-driven trackers were removed or consolidated for clearer, more reliable events.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 00:32:15 +00:00
Jonas Daniels 7b9ce493c8 upgrade next and react 2025-12-03 14:38:01 -08:00
0xFirekeeper b27fcd3ba0 Add Solana wallet docs and sidebar link (#8489)
Added a new sidebar entry for Solana Transactions in the wallets section and introduced a comprehensive Solana server wallet documentation page covering wallet management, balances, transactions, signing, swaps, and example API workflows.

Closes BLD-414

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for managing Solana transactions in the application, including wallet creation, balance checking, token transfers, and swaps. It enhances the user interface with detailed documentation and examples for developers.

### Detailed summary
- Added a new sidebar entry for `Solana Transactions`.
- Created a detailed `page.mdx` for Solana server wallets.
- Included metadata for the `Solana Server Wallets` page.
- Documented capabilities: wallet management, balance checking, signing, token swaps, and custom transactions.
- Provided example workflows using `curl` commands for various operations.
- Defined network identifiers for `Mainnet` and `Devnet`.

> ✨ 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 Solana Transactions link in Server Wallets navigation for streamlined access to Solana wallet operations

* **Documentation**
  * New Solana server wallets documentation page with getting started guides, API workflows for wallet creation, balance retrieval, token transfers, transaction signing, and token swap operations

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-03 19:08:53 +00:00
MananTank c40603f36e [PRO-134] Add Bridge Volume pie chart in project overview page (#8488)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the analytics components by refining data formatting, adding currency support, and improving the UI for better data visualization in the dashboard.

### Detailed summary
- Added `maximumFractionDigits` to formatting utilities.
- Integrated currency formatting in `PieChart` and `TotalValueChartHeader`.
- Updated `ProjectHighlightsCard` to remove total volume and adjust labels.
- Introduced `BridgeChartCard` for displaying bridge usage data.
- Enhanced `TotalVolumePieChart` to conditionally display total volume and adjusted data handling.

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

<!-- end pr-codex -->
2025-12-03 18:46:13 +00:00
MananTank 458246fea1 Dashboard: Project analytics code organization refactor (#8487)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces several analytics components for a dashboard application, enhancing the project's data visualization capabilities. It includes new types for page parameters and search parameters, as well as multiple asynchronous chart components for different metrics.

### Detailed summary
- Added `PageParams` and `PageSearchParams` types for handling routing parameters.
- Implemented `AsyncRPCRequestsChartCard` and `RPCRequestsChartCard` for RPC usage analytics.
- Created `AsyncAiAnalytics` and `AIAnalyticsChartCard` for AI usage analytics.
- Introduced `AsyncIndexerRequestsChartCard` and `IndexerRequestsChartCard` for indexer request analytics.
- Developed `AsyncX402RequestsChart` and `X402RequestsChartCard` for x402 settlements analytics.
- Added `AsyncAppHighlightsCard` and `ProjectHighlightCard` for displaying project highlights.
- Refactored `ProjectAnalytics` function to integrate new chart components.
- Removed redundant code and improved structure for better maintainability.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added AI token volume analytics chart to project dashboard
  * Added indexer/gateway request analytics chart
  * Added RPC usage analytics chart
  * Added X402 settlements analytics chart
  * Introduced project highlights card displaying wallet and bridge usage metrics

* **Refactor**
  * Reorganized project analytics dashboard structure for improved layout and user experience

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-03 18:46:13 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1b3e938b06 Version Packages (#8470)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 01:13:36 -08:00
Joaquim VergesandGitHub 3ebca18f9e [SDK] Add error tracking for payment execution failures (#8486) 2025-12-03 00:55:49 -08:00
Joaquim VergesandGitHub 4c5696e414 [SDK] Add caching and timeout for fetching capabilities (#8485) 2025-12-02 17:50:58 -08:00
Joaquim VergesandGitHub 9e43da4ec0 [SDK] Support ERC5792 batch transactions for swaps, add slippage option to Bridge API (#8484) 2025-12-02 15:49:37 -08:00
MananTank 22cea5bb8f Dashboard: Update bridge stats (#8483)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `supportedTokens` values in the `data.ts` file to reflect an increase in the number of supported tokens for the bridge feature.

### Detailed summary
- Updated `supportedTokens` from `"10,000+"` to `"14,000+"` (string format).
- Updated `supportedTokens` from `10000` to `14000` (numeric format).

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Updates**
  * Bridge now supports 14,000+ tokens, increased from 10,000+.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-02 21:43:51 +00:00
MananTank d0e4db26ce [PRO-136] Add x402 requests chart in project overview (#8482)
<!--

## 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 chart components and their presentation in the dashboard, including layout adjustments, new chart configurations, and improved user feedback for empty states.

### Detailed summary
- Updated `Card` component in `bar-chart.tsx` to include additional class names.
- Adjusted `aspect-auto` for `ThirdwebBarChart` in `AiTokenUsageChartCard`.
- Modified `WaitingForIntegrationCard` to conditionally render `TabButtons`.
- Enhanced empty state in `EmptyChartState` with a visual indicator and message.
- Added `aspect-auto` class to `SkeletonBarChart`.
- Introduced `X402RequestsChartCardUI` for handling X402 request data.
- Refactored project analytics page to include `X402RequestsChartCardUI` and manage its data fetching.

> ✨ 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 X402 requests analytics chart with per-period totals to the project dashboard.

* **Bug Fixes**
  * Tab selector now appears only when multiple code tabs exist.
  * Time filters are shown only when relevant data is present.
  * Empty-chart state now shows a clear "No data available" display.

* **Style**
  * Header styling refined for improved visuals on large screens.
  * Chart containers adjusted for better aspect/overflow handling.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-02 21:32:12 +00:00
MananTank 1056696696 [PRO-145] Dashboard: Remove Engine Cloud Requests chart from project overview (#8481)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on the removal of the `EngineCloudChartCard` component and its related files, as well as the `EngineCloudStats` interface from the analytics types. It also updates the `ProjectAnalytics` component to eliminate the usage of `EngineCloudChartCardAsync`.

### Detailed summary
- Deleted files related to `EngineCloudChartCard`:
  - `index.tsx`
  - `EngineCloudBarChartCardUI.tsx`
  - `EngineCloudBarChartCard.stories.tsx`
- Removed `EngineCloudStats` interface from `analytics.ts`.
- Eliminated `EngineCloudChartCardAsync` import and usage in `ProjectAnalytics`.
- Added revalidation settings to cache functions in `analytics.ts`.

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

<!-- end pr-codex -->
2025-12-02 17:55:23 +00:00
MananTank 48297993d8 [PRO-132] Dashboard: Update Wallet Connection charts in project (#8480)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the analytics and chart components related to wallet connections in the application. It introduces new types, refactors existing components, and updates links and chart functionalities to improve usability and data representation.

### Detailed summary
- Deleted unused files related to wallet analytics charts.
- Introduced `WalletStatsWithName` type for wallet statistics.
- Updated links to point to new endpoints.
- Refactored chart components to improve data handling and display.
- Added new `EOAConnectionsChart` and `UserWalletConnectionsChart` components.
- Replaced `InAppWalletAnalytics` with `UserWalletConnectionsChart`.
- Enhanced `RPCRequestsChartUI` to include an `isPending` prop.
- Updated metrics and labels in various charts for better clarity.
- Improved error handling in API calls for wallet connections.
- Removed deprecated code and streamlined analytics data fetching.

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

<!-- end pr-codex -->
2025-12-02 17:55:23 +00:00
MananTank cd9e42911e [PRO-135] Add AI token volume chart in project overview page (#8472)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the AI features in the dashboard by updating UI components, improving navigation, and adding analytics capabilities for AI token usage.

### Detailed summary
- Changed `variant` of a `Button` in `ChatBar.tsx` from `primary` to `default`.
- Added `viewMoreLink` prop to `AiTokenUsageChartCardUI` in `index.tsx`.
- Updated sidebar structure in `ProjectSidebarLayout.tsx` to include submenus and links for AI.
- Modified redirection logic in `page.tsx` for missing sessions.
- Adjusted styling and structure of `ExportToCSVButton` in `ExportToCSVButton.tsx`.
- Removed the `Analytics` link from `ChatSidebar.tsx`.
- Removed unused state for token symbol in `project-wallet-details.tsx`.
- Introduced `AsyncAiAnalytics` function to fetch AI usage data in `page.tsx`.
- Updated `AiTokenUsageChartCardUI` to handle new props and improve chart presentation.
- Enhanced empty state message in `AiTokenUsageChartCardUI` with a button linking to AI chat documentation.

> ✨ 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**
  * AI token usage analytics card added to project dashboard with totals and chart.
  * AI sidebar reorganized into Chat and Analytics entries; AI analytics accessible from project pages.

* **Bug Fixes**
  * Missing chat session now redirects back to the AI section instead of showing an error.

* **Style**
  * Button and spinner visuals refined (export and send controls); chart sizing and empty-state text adjusted; chat sidebar streamlined.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-02 17:55:23 +00:00
Jonas Daniels 41e2ccb67d Add isLegacyPlan flag to Team type and UI components 2025-12-01 14:22:34 -08:00
Joaquim VergesandGitHub 68a1dcac26 [Playground] Improve token selection and UI for x402 playground (#8471) 2025-11-30 17:07:30 -08:00
Joaquim VergesandGitHub 98d8f292db [Dashboard] Show project server wallet balance for any token (#8469) 2025-11-30 15:01:14 -08:00
FirekeeperandGitHub 4f2dbccba4 small fixes to dotnet docs (#8468) 2025-11-27 17:38:46 +07:00
MananTank 908142f8fa [PRO-116] Move Server wallets table to separate page (#8467)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `Wallets` section to the application, enhancing the wallet management interface. It updates the routing and adds functionality to handle EVM and Solana wallets, including error handling and data fetching improvements.

### Detailed summary
- Added a new route for `Wallets` in `layout.tsx`.
- Updated the redirect path in `page.tsx` for unauthorized access.
- Removed deprecated error handling for EVM wallets.
- Implemented new logic for fetching and displaying wallets, including Solana accounts.
- Enhanced error handling for wallet retrieval and connection issues.

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

<!-- end pr-codex -->
2025-11-26 22:17:40 +00:00
MananTank 4d065c3774 [PRO-137] Add indexer and RPC charts in project overview page (#8464)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the chart components in the dashboard by updating properties for better data visualization and integrating new functionalities for displaying RPC and status code requests.

### Detailed summary
- Updated `xAxis` property from `sameDay` to `showHour` in `rate-graph.tsx` and `count-graph.tsx`.
- Increased height of `Card` in `RpcMethodBarChartCardUI.tsx`.
- Changed title and description handling in `RequestsByStatusGraph.tsx`.
- Added `className` prop to `ThirdwebBarChart` component.
- Replaced `RequestsGraph` with `RPCRequestsChartUI` in `RPCAnalytics.tsx`.
- Introduced a new component `TotalValueChartHeader` for displaying total values.
- Updated `RequestsGraph` to include a breakdown by hour.
- Added asynchronous functions for fetching indexer and RPC request data in `page.tsx`.

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

<!-- end pr-codex -->
2025-11-26 22:05:19 +00:00
MananTank 426229050c [PRO-129] Dashboard: Remove AA analytics from project overview (#8463)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on renaming and updating references from `Account Abstraction` to `Gas Sponsorship` across various components in the codebase, as well as removing the `TotalSponsoredCard` functionality.

### Detailed summary
- Removed `total-sponsored.tsx`.
- Updated `EmptyStateCard` metric from `Account Abstraction` to `Gas Sponsorship`.
- Modified `featureDescription` in `UpsellContent` to include `Gas Sponsorship`.
- Changed alert title from `Account Abstraction service is disabled` to `Gas Sponsorship is disabled`.
- Increased padding in `usage/layout.tsx`.
- Renamed usage path from `Account Abstraction` to `Gas Sponsorship`.
- Removed `TotalSponsoredCard` and its related functionality from `page.tsx`.

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

<!-- end pr-codex -->
2025-11-26 22:05:18 +00:00
MananTank 0fd55a2a13 [PRO-130] Move RPC methods chart from project overview to RPC tab (#8462)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the UI components of the RPC analytics dashboard by refining styles, improving layout, and optimizing the rendering of charts and tables.

### Detailed summary
- Added `titleClassName` to `RequestsGraph` for improved styling.
- Removed unnecessary `showLegend` from `RequestsGraph`.
- Simplified `CardHeader` and adjusted padding in `RpcMethodBarChartCardCardUI`.
- Reintroduced `RpcMethodBarChartCardAsync` in `RPCAnalytics` with proper parameters.
- Updated layout and styling in `TopRPCMethodsTable`, replacing `Card` with a `div` and enhancing header styles.

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

<!-- end pr-codex -->
2025-11-26 22:05:18 +00:00
MananTank 59ad7e8b09 [PRO-127] Dashboard: Move user wallets table to separate tab (#8460)
<!--

## 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 user wallet components in the dashboard application, replacing the `InAppWalletUsersPageContent` with `UserWalletsTable` and enhancing the user wallet functionalities.

### Detailed summary
- Added a new route for `Users` in `layout.tsx`.
- Replaced `InAppWalletUsersPageContent` with `UserWalletsTable` in `page.tsx`.
- Introduced a new `Page` component for user wallets with authentication and project validation.
- Updated `UserWalletsTable` to include necessary props and improve layout.

> ✨ 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 "Users" tab in the wallets area and a dedicated Users page showing user wallets (search, download and pagination available).

* **Refactor**
  * Consolidated and simplified the user-wallets layout and table for clearer hierarchy and tighter spacing.
  * Moved action controls (search/download) into the header area and adjusted alignment for improved usability.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-26 22:05:18 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fda2284036 Version Packages (#8466)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-26 14:03:39 -08:00
Joaquim VergesandGitHub 629efc13b6 [SDK] Add Monad chain definition (#8465) 2025-11-26 13:46:02 -08:00
Joaquim VergesandGitHub 512f9dd3c9 [Docs] Fix AI model response handling in server example (#8459) 2025-11-26 11:05:01 -08:00
jnsdls 919230f0c8 Add daily revalidation to TypeScript pages (#8456)
Added daily revalidation to TypeScript reference pages

This PR adds a revalidation period of 24 hours (86400 seconds) to TypeScript reference pages to ensure content stays fresh while minimizing unnecessary rebuilds.

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

* **Chores**
  * Introduced daily automatic revalidation across multiple portal pages and layouts so content refreshes every 24 hours. This enables incremental static regeneration for affected pages, improving cache freshness and helping ensure users see up-to-date content with reduced stale data risk.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a `revalidate` constant set to `86400` seconds (1 day) in multiple files, which specifies that the cached data should be revalidated every day.

### Detailed summary
- Added `export const revalidate = 86400; // revalidate every day` to:
  - `apps/portal/src/app/contracts/extensions/page.tsx`
  - `apps/portal/src/app/react/v5/[...slug]/page.tsx`
  - `apps/portal/src/app/contracts/layout.tsx`
  - `apps/portal/src/app/typescript/v5/[...slug]/page.tsx`
  - `apps/portal/src/app/react/v5/layout.tsx`
  - `apps/portal/src/app/typescript/v5/layout.tsx`
  - `apps/portal/src/app/references/typescript/[version]/[[...slug]]/page.tsx`

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

<!-- end pr-codex -->
2025-11-25 17:23:17 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3ac8cba2e8 Version Packages (#8458)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-25 17:04:57 -08:00
Joaquim VergesandGitHub 35aaf24e8a [SDK] Add payment scheme and improve type documentation for x402 (#8457) 2025-11-25 16:49:40 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ad2d1757e2 Version Packages (#8452)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-24 15:18:50 -08:00
Joaquim VergesandGitHub 52aba0ffb8 [SDK] Skip chain switching for Coinbase Wallet if already on correct chain (#8453) 2025-11-24 15:13:30 -08:00
Joaquim VergesandGitHub 9809d5cf66 [SDK] Add useFetchWithPayment React hook for x402 payment handling (#8444) 2025-11-24 14:42:50 -08:00
0xFirekeeper 8b10674481 Port gas sponsorship analytics to Team usage tab (#8445)
Updated AccountAbstractionAnalytics to support both team and project variants, allowing for flexible rendering based on context. Refactored usage and summary components to accept optional projectId, and updated the team usage page to use the new analytics and summary components. Adjusted SponsoredTransactionsTable usage to match the new props structure.

Closes BLD-500

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `AccountAbstractionAnalytics` and `AccountAbstractionSummary` components to support additional parameters for date ranges and project identification, improving data handling for user operation statistics.

### Detailed summary
- Added `from` and `to` props to `AccountAbstractionSummary` and `AccountAbstractionAnalytics`.
- Made `projectId` optional in `AsyncAccountAbstractionSummary` and `AccountAbstractionSummary`.
- Updated `AccountAbstractionAnalytics` to handle different variants (`project` and `team`).
- Introduced date range handling in the main page logic to fetch user operation statistics based on selected ranges.

> ✨ 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**
  * Account abstraction views now support team- and project-level variants and explicit date-range (from/to) filtering.
  * Analytics components expose flexible props to render project or team analytics based on resolved ranges.

* **Improvements**
  * Data loading sequence refined to fetch authentication, team lookup, and usage metrics before render.
  * UI updated to show a summary section followed by analytics with improved spacing and conditional rendering.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-24 15:45:06 +00:00
179 changed files with 7540 additions and 4365 deletions
+130 -130
View File
@@ -1,132 +1,132 @@
{
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^7.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.1.0",
"react-dropzone": "^14.3.8",
"react-error-boundary": "6.0.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-table": "^7.8.0",
"recharts": "2.15.3",
"remark-gfm": "4.0.1",
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "15.3.5",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/color": "4.2.0",
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit"
},
"version": "3.0.0"
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^7.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.2.1",
"react-dropzone": "^14.3.8",
"react-error-boundary": "6.0.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-table": "^7.8.0",
"recharts": "2.15.3",
"remark-gfm": "4.0.1",
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.6",
"@next/eslint-plugin-next": "15.3.6",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/color": "4.2.0",
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit"
},
"version": "3.0.0"
}
+110 -45
View File
@@ -1,19 +1,20 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
import { normalizeTime } from "@/lib/time";
import type {
AIUsageStats,
AnalyticsQueryParams,
EcosystemWalletStats,
EngineCloudStats,
InAppWalletStats,
TransactionStats,
UniversalBridgeStats,
UniversalBridgeWalletStats,
UserOpStats,
WalletStats,
WalletStatsWithName,
WebhookLatencyStats,
WebhookSummaryStats,
X402QueryParams,
@@ -148,13 +149,7 @@ const cached_getWalletConnections = unstable_cache(
}
const json = await res.json();
return (json.data as WalletStats[]).filter(
(w) =>
w.walletType !== "smart" &&
w.walletType !== "smartWallet" &&
w.walletType !== "inApp" &&
w.walletType !== "inAppWallet",
);
return json.data as WalletStats[];
},
["getWalletConnections"],
{
@@ -162,11 +157,116 @@ const cached_getWalletConnections = unstable_cache(
},
);
export function getWalletConnections(
const cache_getEOAWalletConnections = unstable_cache(
async (
params: AnalyticsQueryParams,
authToken: string,
): Promise<WalletStatsWithName[]> => {
const result = await cached_getWalletConnections(
normalizedParams(params),
authToken,
);
const filteredResult = result.filter(
(w) =>
w.walletType !== "smart" &&
w.walletType !== "smartWallet" &&
w.walletType !== "inApp" &&
w.walletType !== "inAppWallet",
);
const modifiedResult = await Promise.all(
filteredResult.map(async (w) => {
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => undefined,
);
if (!wallet) {
return {
...w,
walletName: w.walletType,
};
}
return {
...w,
walletName: wallet.name,
};
}),
);
return modifiedResult;
},
["getEOAWalletConnections"],
{
revalidate: 60 * 60, // 1 hour
},
);
export async function getEOAWalletConnections(
params: AnalyticsQueryParams,
authToken: string,
) {
return cached_getWalletConnections(normalizedParams(params), authToken);
return cache_getEOAWalletConnections(normalizedParams(params), authToken);
}
const cache_getEOAAndInAppWalletConnections = unstable_cache(
async (
params: AnalyticsQueryParams,
authToken: string,
): Promise<WalletStatsWithName[]> => {
const result = await cached_getWalletConnections(
normalizedParams(params),
authToken,
);
const modifiedResult = await Promise.all(
result.map(async (w) => {
if (
w.walletType === "inApp" ||
w.walletType === "inAppWallet" ||
w.walletType === "smart" ||
w.walletType === "smartWallet"
) {
return {
...w,
walletName: "User wallet",
};
}
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => undefined,
);
if (!wallet) {
return {
...w,
walletName: w.walletType,
};
}
return {
...w,
walletName: wallet.name,
};
}),
);
return modifiedResult;
},
["getEOAAndInAppWalletConnections"],
{
revalidate: 60 * 60, // 1 hour
},
);
export async function getEOAAndInAppWalletConnections(
params: AnalyticsQueryParams,
authToken: string,
): Promise<WalletStatsWithName[]> {
return cache_getEOAAndInAppWalletConnections(
normalizedParams(params),
authToken,
);
}
const cached_getInAppWalletUsage = unstable_cache(
@@ -691,41 +791,6 @@ export function getUniversalBridgeWalletUsage(
);
}
const cached_getEngineCloudMethodUsage = unstable_cache(
async (
params: AnalyticsQueryParams,
authToken: string,
): Promise<EngineCloudStats[]> => {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics({
authToken,
url: `v2/engine-cloud/requests?${searchParams.toString()}`,
init: {
method: "GET",
},
});
if (res?.status !== 200) {
console.error("Failed to fetch Engine Cloud method usage");
return [];
}
const json = await res.json();
return json.data as EngineCloudStats[];
},
["getEngineCloudMethodUsage"],
{
revalidate: 60 * 60, // 1 hour
},
);
export function getEngineCloudMethodUsage(
params: AnalyticsQueryParams,
authToken: string,
) {
return cached_getEngineCloudMethodUsage(normalizedParams(params), authToken);
}
const _cached_getWebhookSummary = unstable_cache(
async (
params: AnalyticsQueryParams & { webhookId: string },
+4 -1
View File
@@ -8,7 +8,10 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import { getMemberByAccountId } from "./team-members";
export type Team = TeamResponse & { stripeCustomerId: string | null };
export type Team = TeamResponse & {
stripeCustomerId: string | null;
isLegacyPlan: boolean;
};
export async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
@@ -1,4 +1,5 @@
"use client";
import { XIcon } from "lucide-react";
import { useId, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
@@ -27,16 +28,23 @@ export function EmptyChartState({
type,
}: {
children?: React.ReactNode;
type: "bar" | "area";
type: "bar" | "area" | "none";
}) {
const barChartData = useMemo(() => generateRandomData(), []);
return (
<div className="relative z-0 h-full w-full">
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center font-medium text-base text-muted-foreground">
{children ?? "No data available"}
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
{children || (
<div className="flex items-center gap-3 flex-col">
<div className="rounded-full border p-2 bg-background">
<XIcon className="size-4" />
</div>
<p className="text-base"> No data available </p>
</div>
)}
</div>
<SkeletonBarChart data={barChartData} type={type} />
{type !== "none" && <SkeletonBarChart data={barChartData} type={type} />}
</div>
);
}
@@ -61,7 +69,7 @@ function SkeletonBarChart(props: {
const fillAreaSkeletonId = useId();
return (
<ChartContainer
className="pointer-events-none h-full w-full blur-[5px]"
className="pointer-events-none h-full w-full blur-[5px] aspect-auto"
config={skeletonChartConfig}
>
{props.type === "bar" ? (
@@ -34,7 +34,8 @@ export function ExportToCSVButton(props: {
return (
<Button
className={cn("flex items-center gap-2 border text-xs", props.className)}
className={cn("gap-2 rounded-full", props.className)}
size="sm"
disabled={props.disabled || exportMutation.isPending}
onClick={async () => {
exportMutation.mutate();
@@ -42,16 +43,11 @@ export function ExportToCSVButton(props: {
variant="outline"
>
{exportMutation.isPending ? (
<>
Downloading
<Spinner className="size-3" />
</>
<Spinner className="size-3.5 text-muted-foreground" />
) : (
<>
<DownloadIcon className="size-3" />
Export as CSV
</>
<DownloadIcon className="size-3.5 text-muted-foreground" />
)}
Export
</Button>
);
}
@@ -70,6 +70,7 @@ function Variants() {
key={currentPlan}
requiredPlan={requiredPlan}
teamSlug="foo"
isLegacyPlan={false}
/>
</BadgeContainer>
))}
@@ -14,6 +14,7 @@ type GatedSwitchProps = {
trackingLabel?: string;
currentPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
teamSlug: string;
switchProps?: SwitchProps;
};
@@ -33,7 +34,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
<div className="w-full min-w-[280px]">
<h3 className="font-medium text-base">
<span className="capitalize">
{getTeamPlanBadgeLabel(props.requiredPlan)}+
{getTeamPlanBadgeLabel(props.requiredPlan, false)}+
</span>{" "}
plan required
</h3>
@@ -48,7 +49,8 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
rel="noopener noreferrer"
target="_blank"
>
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan)} plan
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan, false)}{" "}
plan
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
@@ -61,6 +63,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
{isUpgradeRequired && (
<TeamPlanBadge
plan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
postfix="+"
teamSlug={props.teamSlug}
/>
@@ -23,28 +23,42 @@ const teamPlanToBadgeVariant: Record<
starter: "warning",
};
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
export function getTeamPlanBadgeLabel(
plan: Team["billingPlan"],
isLegacyPlan: boolean,
) {
if (plan === "growth_legacy") {
return "Growth - Legacy";
}
if (isLegacyPlan) {
return `${plan} - Legacy`;
}
return plan;
}
export function TeamPlanBadge(props: {
teamSlug: string;
plan: Team["billingPlan"];
isLegacyPlan: boolean;
className?: string;
postfix?: string;
}) {
const router = useDashboardRouter();
function handleNavigateToBilling(e: React.MouseEvent | React.KeyboardEvent) {
e.stopPropagation();
e.preventDefault();
if (props.isLegacyPlan) {
router.push(`/team/${props.teamSlug}/~/billing`);
return;
}
if (props.plan !== "free") {
return;
}
e.stopPropagation();
e.preventDefault();
router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
}
@@ -57,11 +71,13 @@ export function TeamPlanBadge(props: {
handleNavigateToBilling(e);
}
}}
role={props.plan === "free" ? "button" : undefined}
tabIndex={props.plan === "free" ? 0 : undefined}
variant={teamPlanToBadgeVariant[props.plan]}
role={props.plan === "free" || props.isLegacyPlan ? "button" : undefined}
tabIndex={props.plan === "free" || props.isLegacyPlan ? 0 : undefined}
variant={
props.isLegacyPlan ? "warning" : teamPlanToBadgeVariant[props.plan]
}
>
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
{`${getTeamPlanBadgeLabel(props.plan, props.isLegacyPlan)}${props.postfix || ""}`}
</Badge>
);
}
@@ -40,7 +40,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
yAxis?: boolean;
xAxis?: {
sameDay?: boolean;
showHour?: boolean;
};
variant?: "stacked" | "individual";
@@ -112,7 +112,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
tickFormatter={(value) =>
format(
new Date(value),
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
)
}
tickLine={false}
@@ -1,12 +1,11 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import type { InAppWalletAuth } from "thirdweb/wallets";
import { BadgeContainer } from "@/storybook/utils";
import type { InAppWalletStats } from "@/types/analytics";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
import { AutoMergeBarChart, type StatData } from "./automerge-barchart";
const meta = {
component: Component,
title: "Charts/InAppWallets",
title: "Charts/AutoMergeBarChart",
} satisfies Meta<typeof Component>;
export default meta;
@@ -22,57 +21,106 @@ function Component() {
"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">
<InAppWalletUsersChartCardUI
<BadgeContainer label="10 days, view more variant">
<AutoMergeBarChart
viewMoreLink={"#"}
description={description}
inAppWalletStats={createInAppWalletStatsStub(30)}
stats={createStatsStub(10)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={undefined}
/>
</BadgeContainer>
<BadgeContainer label="30 days">
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
stats={createStatsStub(30)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="60 days">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(60)}
stats={createStatsStub(60)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="120 days">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(120)}
stats={createStatsStub(120)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(10)}
stats={createStatsStub(10)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="Empty List">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(0)}
stats={createStatsStub(0)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="Pending">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={[]}
stats={[]}
isPending={true}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
</div>
@@ -111,17 +159,16 @@ const pickRandomAuthMethod = () => {
return capitalized;
};
function createInAppWalletStatsStub(days: number): InAppWalletStats[] {
const stubbedData: InAppWalletStats[] = [];
function createStatsStub(days: number): StatData[] {
const stubbedData: StatData[] = [];
let d = days;
while (d !== 0) {
const uniqueWallets = Math.floor(Math.random() * 100);
stubbedData.push({
authenticationMethod: pickRandomAuthMethod(),
label: pickRandomAuthMethod(),
date: new Date(2024, 11, d).toLocaleString(),
uniqueWalletsConnected: uniqueWallets,
newUsers: Math.floor(Math.random() * 100) + 1,
count: uniqueWallets,
});
if (Math.random() > 0.7) {
@@ -0,0 +1,173 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import type { ChartConfig } from "@/components/ui/chart";
import { TotalValueChartHeader } from "./chart-header";
export type StatData = {
date: string;
count: number;
label: string;
};
type ChartData = Record<string, number> & {
time: string; // human readable date
};
const defaultLabel = "Unknown";
// merges the multiple stats in single bar if they share the same date
// shows the top `maxLabelsToShow` labels, merge the rest into "Others"
export function AutoMergeBarChart(props: {
stats: StatData[];
isPending: boolean;
title: string;
emptyChartState: React.ReactElement | undefined;
description: string | undefined;
maxLabelsToShow: number;
exportButton:
| {
fileName: string;
}
| undefined;
viewMoreLink: string | undefined;
}) {
const { stats, maxLabelsToShow } = props;
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const labelToCountMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of stats) {
const chartData = _chartDataMap.get(stat.date);
// if no data for current day - create new entry
if (!chartData && stat.count > 0) {
_chartDataMap.set(stat.date, {
time: stat.date,
[stat.label || defaultLabel]: stat.count,
} as ChartData);
} else if (chartData) {
chartData[stat.label || defaultLabel] =
(chartData[stat.label || defaultLabel] || 0) + stat.count;
}
labelToCountMap.set(
stat.label || defaultLabel,
stat.count + (labelToCountMap.get(stat.label || defaultLabel) || 0),
);
}
const labelsSorted = Array.from(labelToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const labelsToShow = labelsSorted.slice(0, maxLabelsToShow);
const labelsToShowAsOther = labelsSorted.slice(maxLabelsToShow);
// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const dataKey in data) {
if (labelsToShowAsOther.includes(dataKey)) {
data.others = (data.others || 0) + (data[dataKey] || 0);
delete data[dataKey];
}
}
}
labelsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
color: `hsl(var(--chart-${(i % 10) + 1}))`,
label: labelsToShow[i],
};
});
// Add Other
if (labelsToShow.length >= maxLabelsToShow) {
labelsToShow.push("others");
_chartConfig.others = {
color: "hsl(var(--muted-foreground))",
label: "Others",
};
}
return {
chartConfig: _chartConfig,
chartData: Array.from(_chartDataMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
),
};
}, [stats, maxLabelsToShow]);
const uniqueLabels = Object.keys(chartConfig);
const total = useMemo(() => {
return props.stats.reduce((acc, stat) => acc + stat.count, 0);
}, [props.stats]);
return (
<ThirdwebBarChart
chartClassName="h-[275px] w-full"
config={chartConfig}
customHeader={
props.viewMoreLink ? (
<TotalValueChartHeader
total={total}
title={props.title}
isPending={props.isPending}
viewMoreLink={props.viewMoreLink}
/>
) : (
<div className="relative px-6 pt-6">
<div className="space-y-1">
<h3 className="font-semibold text-lg tracking-tight leading-none">
{props.title}
</h3>
{props.description && (
<p className="text-muted-foreground text-sm">
{props.description}
</p>
)}
</div>
{props.exportButton && props.stats.length > 0 && (
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
fileName={props.exportButton.fileName}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Time", ...uniqueLabels];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
new Date(time).toISOString(),
...uniqueLabels.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
)}
</div>
)
}
data={chartData}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -44,6 +44,10 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
hideLabel?: boolean;
emptyChartState?: React.ReactElement;
className?: string;
xAxis?: {
showHour?: boolean;
};
};
export function ThirdwebBarChart<TConfig extends ChartConfig>(
@@ -55,7 +59,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
return (
<Card>
<Card className={cn("overflow-hidden", props.className)}>
{props.header && (
<CardHeader>
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
@@ -83,7 +87,12 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
<XAxis
axisLine={false}
dataKey="time"
tickFormatter={(value) => format(new Date(value), "MMM d")}
tickFormatter={(value) =>
format(
new Date(value),
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
)
}
tickLine={false}
tickMargin={10}
/>
@@ -0,0 +1,60 @@
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { SkeletonContainer } from "@/components/ui/skeleton";
export function TotalValueChartHeader(props: {
total: number;
title: string;
isUsd?: boolean;
isPending: boolean;
viewMoreLink: string | undefined;
}) {
return (
<div className="space-y-1 p-6 pb-0 flex justify-between items-start gap-3">
<div>
<SkeletonContainer
loadedData={!props.isPending ? props.total : undefined}
skeletonData={100}
render={(value) => {
return (
<p className="text-3xl font-semibold tracking-tight">
{props.isUsd
? compactUSDFormatter.format(value)
: compactNumberFormatter.format(value)}
</p>
);
}}
/>
<h3 className="text-muted-foreground">{props.title}</h3>
</div>
{props.viewMoreLink && (
<Button
asChild
size="sm"
variant="outline"
className="gap-2 rounded-full text-muted-foreground hover:text-foreground"
>
<Link href={props.viewMoreLink}>
<span>View More</span>
<ArrowUpRightIcon className="size-4" />
</Link>
</Button>
)}
</div>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
const compactUSDFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
style: "currency",
currency: "USD",
});
@@ -22,6 +22,7 @@ interface UpsellWrapperProps {
isLocked?: boolean;
requiredPlan: Team["billingPlan"];
currentPlan?: Team["billingPlan"];
isLegacyPlan?: boolean;
featureName: string;
featureDescription: string;
benefits?: {
@@ -41,6 +42,7 @@ export function UpsellWrapper({
featureDescription,
benefits = [],
className,
isLegacyPlan = false,
}: UpsellWrapperProps) {
if (!isLocked) {
return <>{children}</>;
@@ -66,6 +68,7 @@ export function UpsellWrapper({
featureDescription={featureDescription}
featureName={featureName}
requiredPlan={requiredPlan}
isLegacyPlan={isLegacyPlan}
teamSlug={teamSlug}
/>
</div>
@@ -79,6 +82,7 @@ export function UpsellContent(props: {
featureDescription: string;
requiredPlan: Team["billingPlan"];
currentPlan: Team["billingPlan"];
isLegacyPlan: boolean;
benefits?: {
description: string;
status: "available" | "soon";
@@ -96,6 +100,7 @@ export function UpsellContent(props: {
plan={props.requiredPlan}
postfix=" Feature"
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
<div className="space-y-1">
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
@@ -69,7 +69,7 @@ export default function Component() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}},
);
@@ -124,7 +124,7 @@ export default function Component() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}}
);
@@ -214,7 +214,7 @@ function App() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}}
);`,
dotnet: `using Thirdweb;
@@ -272,7 +272,7 @@ export default function Component() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}}
);
@@ -1103,7 +1103,7 @@ function AccountFactorySection(props: {
<AlertDescription>
The recommended way to use account factories is to integrate the{" "}
<UnderlineLink
href="https://portal.thirdweb.com/transactions/sponsor"
href="https://portal.thirdweb.com/wallets/sponsor-gas"
rel="noopener noreferrer"
target="_blank"
>
@@ -64,7 +64,7 @@ const truncateIdentifier = (value: string) => {
return `${value.slice(0, 8)}...${value.slice(-4)}`;
};
export function InAppWalletUsersPageContent(
export function UserWalletsTable(
props: {
authToken: string;
client: ThirdwebClient;
@@ -288,83 +288,81 @@ export function InAppWalletUsersPageContent(
]);
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<UserIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
User Wallets
</h2>
<p className="text-muted-foreground text-sm">
View and manage your project's users
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="w-full lg:w-auto lg:min-w-[320px]">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2 bg-background rounded-full"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="User wallets"
/>
<div className="flex justify-center gap-3 border-t bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</>
)}
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<UserIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
User Wallets
</h2>
<p className="text-muted-foreground text-sm">
View and manage your project's users
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="w-full lg:w-auto lg:min-w-[320px]">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2 bg-background rounded-full"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
</div>
<div>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="User wallets"
/>
<div className="flex justify-center gap-3 border-t bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
);
+1
View File
@@ -30,6 +30,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
billingPlan: billingPlan,
billingStatus: "validPayment",
canCreatePublicChains: null,
isLegacyPlan: false,
capabilities: {
bundler: {
enabled: true,
+4 -7
View File
@@ -7,6 +7,10 @@ export interface WalletStats {
walletType: string;
}
export type WalletStatsWithName = WalletStats & {
walletName: string;
};
export interface InAppWalletStats {
date: string;
authenticationMethod: string;
@@ -35,13 +39,6 @@ export interface TransactionStats {
count: number;
}
export interface EngineCloudStats {
date: string;
chainId: string;
pathname: string;
totalRequests: number;
}
export interface UniversalBridgeStats {
date: string;
chainId: number;
@@ -1,5 +1,6 @@
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
export const formatTickerNumber = (value: number) => {
@@ -22,7 +22,7 @@ export function AccountsPage(props: {
View list of smart accounts that have been created for this
contract.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/transactions/sponsor"
href="https://portal.thirdweb.com/wallets/sponsor-gas"
target="_blank"
rel="noopener noreferrer"
>
@@ -18,7 +18,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { SearchInput } from "../components/SearchInput";
export function AccountTeamsUI(props: {
@@ -117,8 +116,6 @@ function TeamRow(props: {
role: TeamAccountRole;
client: ThirdwebClient;
}) {
const plan = getValidTeamPlan(props.team);
return (
<div className="flex items-center justify-between gap-2">
{/* start */}
@@ -133,7 +130,11 @@ function TeamRow(props: {
<div>
<div className="flex items-center gap-3">
<p className="font-semibold text-sm">{props.team.name}</p>
<TeamPlanBadge plan={plan} teamSlug={props.team.slug} />
<TeamPlanBadge
plan={props.team.billingPlan}
teamSlug={props.team.slug}
isLegacyPlan={props.team.isLegacyPlan}
/>
</div>
<p className="text-muted-foreground text-sm capitalize">
{props.role.toLowerCase()}
@@ -6,13 +6,12 @@ import {
} from "responsive-rsc";
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import {
getClientTransactions,
getEOAWalletConnections,
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
} from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team/get-team";
@@ -26,7 +25,7 @@ import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UserOpStats,
WalletStats,
WalletStatsWithName,
} from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
@@ -240,7 +239,7 @@ async function AsyncWalletDistributionCard(props: {
range: Range;
authToken: string;
}) {
const walletConnections = await getWalletConnections(
const walletConnections = await getEOAWalletConnections(
{
from: props.range.from,
period: "all",
@@ -373,29 +372,19 @@ async function AsyncTotalSponsoredCard(props: {
) : (
<EmptyStateCard
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
metric="Account Abstraction"
metric="Gas Sponsorship"
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data.map(async (w) => {
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => ({ name: w.walletType }),
);
return {
totalConnections: w.totalConnections,
uniqueWalletsConnected: w.uniqueWalletsConnected,
walletName: wallet.name,
walletType: w.walletType,
};
}),
);
async function WalletDistributionCard({
data,
}: {
data: WalletStatsWithName[];
}) {
return (
<PieChartCard
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
data={data.map(({ walletName, uniqueWalletsConnected }) => {
return {
label: walletName,
value: uniqueWalletsConnected,
@@ -73,21 +73,24 @@ export function PlanInfoCardUI(props: {
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{validPlan === "growth_legacy" ? "Growth" : validPlan} Plan
</h3>
{validPlan.includes("legacy") && (
{props.team.isLegacyPlan && (
<Badge variant="warning">Legacy</Badge>
)}
{trialEndsInFuture && <Badge variant="default">Trial</Badge>}
</div>
{validPlan.includes("legacy") && (
{props.team.isLegacyPlan && (
<p className="text-sm text-yellow-600">
You are on the legacy plan. You may save by upgrading to new
plan.{" "}
Legacy plans will be upgraded to new plans automatically on
January 1st, 2026.
<br />
<UnderlineLink
className="decoration-yellow-600/50"
href="/pricing"
href="https://blog.thirdweb.com/retiring-legacy-pricing-plans-on-january-1st-2026"
rel="noopener noreferrer"
target="_blank"
>
Learn More
Learn more about changes to legacy plans
</UnderlineLink>
</p>
)}
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/team/ecosystems";
import { getTeamBySlug } from "@/api/team/get-team";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
@@ -30,7 +30,7 @@ export default async function EcosystemUsersPage(props: {
});
return (
<InAppWalletUsersPageContent
<UserWalletsTable
authToken={authToken}
client={client}
ecosystemSlug={ecosystem.slug}
@@ -28,6 +28,7 @@ export default async function Page(props: {
<div className="flex flex-col grow justify-center items-center">
<UpsellContent
currentPlan={team.billingPlan}
isLegacyPlan={team.isLegacyPlan}
featureDescription="Create support cases for your projects"
featureName="Support Center"
requiredPlan="starter"
@@ -1,20 +1,29 @@
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getUserOpUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProjects } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { getTeamSubscriptions } from "@/api/team/team-subscription";
import { SponsoredTransactionsTable } from "@/components/sponsored-transactions-table/SponsoredTransactionsTable";
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 "../../../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { AccountAbstractionAnalytics } from "../../../../[project_slug]/(sidebar)/account-abstraction/aa-analytics";
import { searchParamLoader } from "../../../../[project_slug]/(sidebar)/account-abstraction/search-params";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<SearchParams>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
const [params, parsedSearchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
@@ -22,6 +31,8 @@ export default async function Page(props: {
loginRedirect(`/team/${params.team_slug}/~/usage/account-abstraction`);
}
const team = await getTeamBySlug(params.team_slug);
if (!team) {
redirect("/team");
}
@@ -44,20 +55,51 @@ export default async function Page(props: {
);
}
const interval = parsedSearchParams.interval ?? "week";
const rangeType = parsedSearchParams.range || "last-120";
const range: Range = {
from:
rangeType === "custom"
? parsedSearchParams.from
: getLastNDaysRange(rangeType).from,
to:
rangeType === "custom"
? parsedSearchParams.to
: getLastNDaysRange(rangeType).to,
type: rangeType,
};
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
teamId: team.id,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<div className="flex flex-col gap-14">
<SponsoredTransactionsTable
<div className="flex flex-col gap-10">
<AccountAbstractionSummary
authToken={authToken}
teamId={team.id}
from={range.from}
to={range.to}
/>
<AccountAbstractionAnalytics
client={client}
from={usageSubscription.currentPeriodStart}
projects={projects}
teamId={team.id}
teamSlug={team.slug}
to={usageSubscription.currentPeriodEnd}
userOpStats={userOpStats}
variant="team"
/>
</div>
@@ -13,7 +13,7 @@ export default async function Layout(props: {
return (
<div className="flex grow flex-col">
<div className="border-border pt-10 pb-4">
<div className="border-border pt-10 pb-6">
<div className="container max-w-7xl flex flex-col gap-4 lg:flex-row lg:justify-between">
<h1 className="font-semibold text-3xl tracking-tight">Usage</h1>
<div className="items-center gap-3 hidden lg:flex">
@@ -47,7 +47,7 @@ export default async function Layout(props: {
path: `${usagePath}/storage`,
},
{
name: "Account Abstraction",
name: "Gas Sponsorship",
path: `${usagePath}/account-abstraction`,
},
]}
@@ -43,7 +43,8 @@ export default async function Page(props: {
<div className="grow flex flex-col justify-center items-center">
<UpsellContent
currentPlan={team.billingPlan}
featureDescription="View RPC, Wallet, Storage, Account Abstraction, Engine Cloud, Webhooks usage and more"
isLegacyPlan={team.isLegacyPlan}
featureDescription="View RPC, Wallet, Storage, Gas Sponsorship, Engine Cloud, Webhooks usage and more"
featureName="Usage"
requiredPlan="starter"
teamSlug={params.team_slug}
@@ -57,7 +57,7 @@ export function CountGraph(props: {
return format(label, "MMM dd, HH:mm");
}}
xAxis={{
sameDay: true,
showHour: true,
}}
yAxis
/>
@@ -57,7 +57,7 @@ export function RateGraph(props: {
return format(label, "MMM dd, HH:mm");
}}
xAxis={{
sameDay: true,
showHour: true,
}}
yAxis
/>
@@ -33,7 +33,6 @@ export default async function RPCUsage(props: {
redirect("/team");
}
const currentPlan = team.billingPlan;
const currentRateLimit = team.capabilities.rpc.rateLimit;
const apiData = await getLast24HoursRPCUsage({
@@ -102,7 +101,11 @@ export default async function RPCUsage(props: {
<div className="font-bold text-2xl capitalize">
{currentRateLimit.toLocaleString()} RPS
</div>
<TeamPlanBadge plan={currentPlan} teamSlug={team.slug} />
<TeamPlanBadge
plan={team.billingPlan}
teamSlug={team.slug}
isLegacyPlan={team.isLegacyPlan}
/>
</div>
</CardContent>
</Card>
@@ -0,0 +1,73 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getAiUsage } from "@/api/analytics";
import { AiTokenUsageChartCardUI } from "../ai/analytics/chart/AiTokenUsageChartCard";
async function AsyncAiAnalytics(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const stats = await getAiUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<AiTokenUsageChartCardUI
title="AI token volume"
isPending={false}
aiUsageStats={stats}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
/>
);
}
export function AIAnalyticsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={
<AiTokenUsageChartCardUI
isPending={true}
title="AI token volume"
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
aiUsageStats={[]}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAiAnalytics
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
from={props.from}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
@@ -0,0 +1,82 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
import {
AutoMergeBarChart,
type StatData,
} from "@/components/blocks/charts/automerge-barchart";
type AllWalletConnectionsChartProps = {
teamId: string;
projectId: string;
authToken: string;
from: Date;
to: Date;
interval: "day" | "week";
teamSlug: string;
projectSlug: string;
};
export function AllWalletConnectionsChart(
props: AllWalletConnectionsChartProps,
) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<AllWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
}
>
<AsyncAllWalletConnectionsChart {...props} />
</ResponsiveSuspense>
);
}
async function AsyncAllWalletConnectionsChart(
props: AllWalletConnectionsChartProps,
) {
const stats = await getEOAAndInAppWalletConnections(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<AllWalletConnectionsChartUI
{...props}
isPending={false}
stats={stats.map((stat) => ({
date: stat.date,
count: stat.uniqueWalletsConnected,
label: stat.walletName,
}))}
/>
);
}
function AllWalletConnectionsChartUI(props: {
stats: StatData[];
teamSlug: string;
projectSlug: string;
isPending: boolean;
}) {
return (
<AutoMergeBarChart
title="Wallet connections"
description={undefined}
stats={props.stats || []}
isPending={props.isPending}
exportButton={undefined}
maxLabelsToShow={5}
emptyChartState={undefined}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
/>
);
}
@@ -0,0 +1,83 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getUniversalBridgeUsage } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { UniversalBridgeStats } from "@/types/analytics";
import { TotalVolumePieChart } from "../payments/components/TotalVolumePieChart";
async function AsyncBridgeCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const data = await getUniversalBridgeUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<BridgeChartCardUI
data={data}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
);
}
export function BridgeChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={<LoadingChartState className="min-h-[40px] h-full border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncBridgeCard {...props} />
</ResponsiveSuspense>
);
}
function BridgeChartCardUI(props: {
data: UniversalBridgeStats[];
teamSlug: string;
projectSlug: string;
}) {
const total =
props.data.reduce((acc, curr) => acc + curr.amountUsdCents, 0) / 100;
return (
<div className="flex flex-col bg-card border rounded-lg">
<TotalValueChartHeader
title="Bridge Volume"
total={total}
isUsd={true}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/bridge`}
/>
<div className="p-6 grow flex items-center justify-center">
<TotalVolumePieChart data={props.data} hideTotal={true} />
</div>
</div>
);
}
@@ -8,7 +8,6 @@ import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedB
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
@@ -37,18 +36,6 @@ export function ProjectHighlightsCard(props: {
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
@@ -59,7 +46,7 @@ export function ProjectHighlightsCard(props: {
/>
),
isCurrency: true,
label: "Bridge Fee Revenue",
label: "Bridge Revenue",
},
} as const;
@@ -129,14 +116,6 @@ function processTimeSeriesData(
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
.reduce((acc, curr) => acc + curr.newUsers, 0);
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString().slice(0, 10) === date &&
v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
@@ -150,7 +129,6 @@ function processTimeSeriesData(
date: date,
feesCollected: fees,
newUsers: newUsers,
totalVolume: volume,
});
}
@@ -0,0 +1,122 @@
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import { getInAppWalletUsage, getUniversalBridgeUsage } from "@/api/analytics";
import type { Project } from "@/api/project/projects";
import type { Range } from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { ProjectHighlightsCard } from "./highlights-card-ui";
import type { PageParams, PageSearchParams } from "./types";
export function ProjectHighlightCard(props: {
authToken: string;
client: ThirdwebClient;
interval: "day" | "week";
params: PageParams;
range: Range;
searchParams: PageSearchParams;
project: Project;
}) {
return (
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
>
<AsyncAppHighlightsCard
authToken={props.authToken}
client={props.client}
interval={props.interval}
params={props.params}
project={props.project}
range={props.range}
selectedChart={
typeof props.searchParams.appHighlights === "string"
? props.searchParams.appHighlights
: undefined
}
selectedChartQueryParam="appHighlights"
/>
</ResponsiveSuspense>
);
}
async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
params: PageParams;
authToken: string;
}) {
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getInAppWalletUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getUniversalBridgeUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
]);
if (
walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled"
) {
return (
<ProjectHighlightsCard
aggregatedUserStats={
aggregatedUserStats.status === "fulfilled"
? aggregatedUserStats.value
: []
}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
teamSlug={props.params.team_slug}
projectSlug={props.params.project_slug}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
: []
}
volumeStats={
universalBridgeUsage.status === "fulfilled"
? universalBridgeUsage.value
: []
}
/>
);
}
return (
<EmptyStateCard
link="https://portal.thirdweb.com/wallets"
metric="Wallets"
/>
);
}
@@ -0,0 +1,68 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInsightStatusCodeUsage } from "@/api/analytics";
import { RequestsByStatusGraph } from "../gateway/indexer/components/RequestsByStatusGraph";
export function IndexerRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={
<RequestsByStatusGraph
data={[]}
isPending={true}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncIndexerRequestsChartCard
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
from={props.from}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
async function AsyncIndexerRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getInsightStatusCodeUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return (
<RequestsByStatusGraph
data={requestsData && "data" in requestsData ? requestsData.data : []}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
/>
);
}
@@ -0,0 +1,144 @@
import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import type { Range } from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { TransactionsChartCardAsync } from "../components/Transactions";
import { AIAnalyticsChartCard } from "./ai-card";
import { AllWalletConnectionsChart } from "./all-wallet-connections-chart";
import { BridgeChartCard } from "./bridge-card";
import { ProjectHighlightCard } from "./highlights-card";
import { IndexerRequestsChartCard } from "./indexer-card";
import { RPCRequestsChartCard } from "./rpc-card";
import type { PageParams, PageSearchParams } from "./types";
import { X402RequestsChartCard } from "./x402-card";
export async function ProjectAnalytics(props: {
project: Project;
params: PageParams;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
authToken: string;
}) {
const { project, params, range, interval, searchParams, client, authToken } =
props;
return (
<div className="flex grow flex-col gap-6">
{/* highlights */}
<ProjectHighlightCard
authToken={authToken}
client={client}
interval={interval}
params={params}
project={project}
range={range}
searchParams={searchParams}
/>
<div className="grid gap-6 md:grid-cols-2">
{/* wallets */}
<AllWalletConnectionsChart
teamSlug={params.team_slug}
projectSlug={params.project_slug}
authToken={authToken}
projectId={project.id}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
{/* bridge */}
<BridgeChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Indexer */}
<IndexerRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
{/* RPC */}
<RPCRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* x402 */}
<X402RequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
{/* AI */}
<AIAnalyticsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</div>
{/* Transactions, Chains, Contracts */}
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
>
<TransactionsChartCardAsync
client={client}
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
selectedChartQueryParam="client_transactions"
/>
</ResponsiveSuspense>
</div>
);
}
@@ -0,0 +1,68 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getRpcUsageByType } from "@/api/analytics";
import { RPCRequestsChartUI } from "../gateway/rpc/components/RequestsGraph";
async function AsyncRPCRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getRpcUsageByType(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return (
<RPCRequestsChartUI
isPending={false}
data={requestsData || []}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
/>
);
}
export function RPCRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={
<RPCRequestsChartUI
isPending={true}
data={[]}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncRPCRequestsChartCard
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
from={props.from}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
@@ -0,0 +1,14 @@
export type PageParams = {
team_slug: string;
project_slug: string;
};
export type PageSearchParams = {
from: string | undefined | string[];
to: string | undefined | string[];
type: string | undefined | string[];
interval: string | undefined | string[];
appHighlights: string | undefined | string[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
@@ -0,0 +1,82 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { X402RequestsChartCardUI } from "./x402-requests-chart";
async function AsyncX402RequestsChart(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const stats = await getX402Settlements(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
return (
<X402RequestsChartCardUI
stats={
isAllEmpty
? []
: stats.map((stat) => ({
requests: stat.totalRequests,
time: stat.date,
}))
}
isPending={false}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
);
}
export function X402RequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<X402RequestsChartCardUI
stats={[]}
isPending={true}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
}
>
<AsyncX402RequestsChart
from={props.from}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
@@ -0,0 +1,51 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
export function X402RequestsChartCardUI(props: {
stats: Array<{ requests: number; time: string }>;
isPending: boolean;
teamSlug: string;
projectSlug: string;
}) {
const total = useMemo(() => {
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
}, [props.stats]);
return (
<ThirdwebBarChart
chartClassName="w-full h-[275px]"
data={props.stats}
config={{
requests: {
color: "hsl(var(--chart-1))",
label: "Requests",
},
}}
isPending={props.isPending}
hideLabel={false}
toolTipLabelFormatter={(label) => {
return format(label, "MMM dd");
}}
toolTipValueFormatter={(value) => {
return compactNumberFormatter.format(value as number);
}}
customHeader={
<TotalValueChartHeader
total={total}
isPending={props.isPending}
title="X402 Requests"
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
/>
}
/>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
@@ -34,13 +34,17 @@ function AccountAbstractionSummaryInner(props: {
async function AsyncAccountAbstractionSummary(props: {
teamId: string;
projectId: string;
projectId?: string;
authToken: string;
from?: Date;
to?: Date;
}) {
const aggregateUserOpStats = await getAggregateUserOpUsage(
{
projectId: props.projectId,
teamId: props.teamId,
from: props.from,
to: props.to,
},
props.authToken,
);
@@ -55,8 +59,10 @@ async function AsyncAccountAbstractionSummary(props: {
export function AccountAbstractionSummary(props: {
teamId: string;
projectId: string;
projectId?: string;
authToken: string;
from?: Date;
to?: Date;
}) {
return (
<Suspense
@@ -71,6 +77,8 @@ export function AccountAbstractionSummary(props: {
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
from={props.from}
to={props.to}
/>
</Suspense>
);
@@ -15,13 +15,30 @@ import { GasMetricsChartCard } from "./AccountAbstractionAnalytics/GasMetricsCha
import { TotalSponsoredChartCard } from "./AccountAbstractionAnalytics/TotalSponsoredChartCard";
import { searchParams } from "./search-params";
export function AccountAbstractionAnalytics(props: {
type AccountAbstractionAnalyticsProps = {
userOpStats: UserOpStats[];
teamId: string;
teamSlug: string;
client: ThirdwebClient;
projectId: string;
}) {
} & (
| {
variant: "project";
projectId: string;
}
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
}
);
export function AccountAbstractionAnalytics(
props: AccountAbstractionAnalyticsProps,
) {
const [isLoading, startTransition] = useTransition();
const [rangeType, setRangeType] = useQueryState(
@@ -62,6 +79,8 @@ export function AccountAbstractionAnalytics(props: {
to: rangeType === "custom" ? to : getLastNDaysRange(rangeType).to,
type: rangeType,
};
const resolvedFrom = range.from;
const resolvedTo = range.to;
return (
<div>
@@ -105,15 +124,18 @@ export function AccountAbstractionAnalytics(props: {
userOpStats={props.userOpStats}
/>
<SponsoredTransactionsTable
client={props.client}
from={from.toISOString()}
projectId={props.projectId}
teamId={props.teamId}
teamSlug={props.teamSlug}
to={to.toISOString()}
variant="project"
/>
{resolvedFrom && resolvedTo && (
<SponsoredTransactionsTable
client={props.client}
from={resolvedFrom.toISOString()}
teamId={props.teamId}
teamSlug={props.teamSlug}
to={resolvedTo.toISOString()}
{...(props.variant === "project"
? { projectId: props.projectId, variant: "project" as const }
: { projects: props.projects, variant: "team" as const })}
/>
)}
</div>
</div>
);
@@ -44,6 +44,7 @@ type AccountAbstractionSettingsPageProps = {
teamId: string;
teamSlug: string;
validTeamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
client: ThirdwebClient;
};
@@ -565,6 +566,7 @@ export function AccountAbstractionSettingsPage(
<GatedSwitch
currentPlan={props.validTeamPlan}
requiredPlan="starter"
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: field.value.enabled,
id: "server-verifier-switch",
@@ -1,10 +1,12 @@
"use client";
import { format } from "date-fns";
import { BotIcon } from "lucide-react";
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { DocLink } from "@/components/blocks/DocLink";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import { Button } from "@/components/ui/button";
import type { ChartConfig } from "@/components/ui/chart";
import type { AIUsageStats } from "@/types/analytics";
@@ -17,7 +19,7 @@ export function AiTokenUsageChartCardUI(props: {
aiUsageStats: AIUsageStats[];
isPending: boolean;
title: string;
description: string;
viewMoreLink: string | undefined;
}) {
const { aiUsageStats } = props;
@@ -52,33 +54,43 @@ export function AiTokenUsageChartCardUI(props: {
chartData.length === 0 ||
chartData.every((data) => data.tokens === 0);
const total = useMemo(() => {
return chartData.reduce((acc, curr) => acc + curr.tokens, 0);
}, [chartData]);
return (
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
chartClassName="h-[275px] w-full aspect-auto"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
disabled={disableActions}
fileName="AI Token Usage"
getData={async () => {
const header = ["Date", "Tokens"];
const rows = chartData.map((data) => [
data.time,
data.tokens.toString(),
]);
return { header, rows };
}}
props.viewMoreLink ? (
<TotalValueChartHeader
isPending={props.isPending}
total={total}
title={props.title}
viewMoreLink={props.viewMoreLink}
/>
</div>
) : (
<div className="relative px-6 pt-6">
<h3 className="font-semibold text-xl tracking-tight">
{props.title}
</h3>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
disabled={disableActions}
fileName="AI Token Usage"
getData={async () => {
const header = ["Date", "Tokens"];
const rows = chartData.map((data) => [
data.time,
data.tokens.toString(),
]);
return { header, rows };
}}
/>
</div>
)
}
data={chartData}
emptyChartState={<AiTokenUsageEmptyChartState />}
@@ -100,17 +112,24 @@ export function AiTokenUsageChartCardUI(props: {
function AiTokenUsageEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
No data available
</h3>
<p className="mb-6 text-center text-sm">
Integrate thirdweb AI to interact with any EVM chain using natural
language
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
icon={BotIcon}
label="Get Started"
link="https://portal.thirdweb.com/ai/chat"
/>
</div>
</p>
<Button
asChild
size="sm"
variant="default"
className="rounded-full gap-2"
>
<Link href="https://portal.thirdweb.com/ai/chat" target="_blank">
Get Started
<ArrowUpRightIcon className="size-4" />
</Link>
</Button>
</div>
);
}
@@ -18,9 +18,9 @@ function AiAnalyticsUI({ stats, isPending }: AiAnalyticsProps) {
return (
<AiTokenUsageChartCardUI
title="Token Usage"
description="The total number of tokens used for AI interactions on your project."
aiUsageStats={stats || []}
isPending={isPending}
viewMoreLink={undefined}
/>
);
}
@@ -1,4 +1,4 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getSessionById, getSessions } from "../../api/session";
@@ -39,7 +39,7 @@ export default async function Page(props: {
]);
if (!session) {
notFound();
redirect(`/team/${params.team_slug}/${params.project_slug}/ai`);
}
return (
@@ -410,7 +410,7 @@ export function ChatBar(props: {
if (message.trim() === "" && images.length === 0) return;
handleSubmit(message);
}}
variant="primary"
variant="default"
>
<ArrowUpIcon className="size-4" />
</Button>
@@ -1,7 +1,6 @@
"use client";
import {
BotIcon,
ChartLineIcon,
ChevronDownIcon,
ChevronRightIcon,
FileCode2Icon,
@@ -83,16 +82,6 @@ export function ChatSidebar(props: {
<div className="h-5" />
<div className="flex flex-col gap-3 border-t border-dashed px-4 py-4">
<Link
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
href={`/team/${props.team_slug}/${props.project.slug}/ai/analytics`}
>
<div className="flex items-center gap-2">
<ChartLineIcon className="size-3.5 text-muted-foreground" />
Analytics
</div>
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
</Link>
<Link
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
href={"https://portal.thirdweb.com/ai/chat"}
@@ -1,56 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "@/storybook/utils";
import type { EngineCloudStats } from "@/types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
const meta = {
component: Component,
title: "Analytics/EngineCloudBarChartCard",
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
parameters: {
viewport: { defaultViewport: "desktop" },
},
};
const generateTimeSeriesData = (days: number, pathnames: string[]) => {
const data: EngineCloudStats[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0];
for (const pathname of pathnames) {
data.push({
chainId: "84532",
// biome-ignore lint/style/noNonNullAssertion: we know this is not null
date: dateStr!,
pathname,
totalRequests: Math.floor(Math.random() * 1000) + 100,
});
}
}
return data;
};
function Component() {
return (
<div className="container max-w-6xl space-y-10 py-10">
<BadgeContainer label="Multiple Pathnames">
<EngineCloudBarChartCardUI
rawData={generateTimeSeriesData(30, [
"/v1/write/transaction",
"/v1/sign/transaction",
"/v1/sign/message",
])}
/>
</BadgeContainer>
</div>
);
}
@@ -1,146 +0,0 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
BarChart as RechartsBarChart,
XAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { EngineCloudStats } from "@/types/analytics";
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
export function EngineCloudBarChartCardUI({
rawData,
}: {
rawData: EngineCloudStats[];
}) {
const { data, pathnames, chartConfig, isAllEmpty } = useMemo(() => {
// Dynamically collect all unique pathnames
const pathnameSet = new Set<string>();
for (const item of rawData) {
// Ignore empty pathname ''.
if (item.pathname) {
pathnameSet.add(item.pathname);
}
}
const pathnames = Array.from(pathnameSet);
// Group by date, then by pathname
const dateMap = new Map<string, Record<string, number>>();
for (const { date, pathname, totalRequests } of rawData) {
const map = dateMap.get(date) ?? {};
map[pathname] = Number(totalRequests) || 0;
dateMap.set(date, map);
}
// Build data array for recharts
const data = Array.from(dateMap.entries()).map(([date, value]) => {
let total = 0;
for (const pathname of pathnames) {
if (!value[pathname]) value[pathname] = 0;
total += value[pathname];
}
return { date, ...value, total };
});
// Chart config
const chartConfig: ChartConfig = {};
for (const pathname of pathnames) {
chartConfig[pathname] = { label: pathname };
}
return {
chartConfig,
data,
isAllEmpty: data.every((d) => d.total === 0),
pathnames,
};
}, [rawData]);
if (data.length === 0 || isAllEmpty) {
return (
<EmptyStateCard
link="https://portal.thirdweb.com/transactions"
metric="Transactions"
/>
);
}
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
<CardTitle className="font-semibold text-lg">
Engine Cloud Requests
</CardTitle>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<ChartContainer
className="aspect-auto h-[250px] w-full pt-6"
config={chartConfig}
>
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
});
}}
tickLine={false}
tickMargin={8}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(d) => format(new Date(d), "MMM d")}
valueFormatter={(_value) => {
const value = typeof _value === "number" ? _value : 0;
return <span className="inline-flex gap-1.5">{value}</span>;
}}
/>
}
/>
{pathnames.map((pathname, idx) => (
<Bar
className="stroke-background"
dataKey={pathname}
fill={`hsl(var(--chart-${idx + 1}))`}
key={pathname}
radius={[
idx === pathnames.length - 1 ? 4 : 0,
idx === pathnames.length - 1 ? 4 : 0,
idx === 0 ? 4 : 0,
idx === 0 ? 4 : 0,
]}
stackId="a"
strokeWidth={1}
/>
))}
</RechartsBarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -1,14 +0,0 @@
import { getEngineCloudMethodUsage } from "@/api/analytics";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
export async function EngineCloudChartCardAsync(props: {
params: AnalyticsQueryParams;
authToken: string;
}) {
const rawData = await getEngineCloudMethodUsage(
props.params,
props.authToken,
);
return <EngineCloudBarChartCardUI rawData={rawData} />;
}
@@ -83,9 +83,26 @@ export function ProjectSidebarLayout(props: {
label: "Tokens",
},
{
href: `${props.layoutPath}/ai`,
icon: BotIcon,
label: "AI",
subMenu: {
icon: BotIcon,
label: "AI",
},
links: [
{
href: `${props.layoutPath}/ai`,
label: "Chat",
isActive: (pathname) => {
return (
pathname === `${props.layoutPath}/ai` ||
pathname.startsWith(`${props.layoutPath}/ai/chat`)
);
},
},
{
href: `${props.layoutPath}/ai/analytics`,
label: "Analytics",
},
],
},
{
subMenu: {
@@ -109,14 +109,14 @@ export function RpcMethodBarChartCardUI({
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
<CardHeader className="flex flex-col items-stretch space-y-0 p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6 pb-0">
<CardTitle className="font-semibold text-lg">RPC Methods</CardTitle>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<ChartContainer
className="aspect-auto h-[250px] w-full pt-6"
className="aspect-auto h-[275px] w-full pt-6"
config={chartConfig}
>
<RechartsBarChart
@@ -20,7 +20,7 @@ export function WaitingForIntegrationCard(props: {
const [selectedTab, setSelectedTab] = useState(props.codeTabs[0]?.label);
return (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<div className="border-b px-4 py-4 lg:px-6 lg:py-5 border-dashed">
<h2 className="font-semibold text-xl tracking-tight">{props.title}</h2>
</div>
@@ -28,14 +28,19 @@ export function WaitingForIntegrationCard(props: {
{props.children}
{/* Code */}
<div>
<TabButtons
tabs={props.codeTabs.map((tab) => ({
isActive: tab.label === selectedTab,
name: tab.label,
onClick: () => setSelectedTab(tab.label),
}))}
/>
<div className="h-2" />
{props.codeTabs.length > 1 && (
<>
<TabButtons
tabs={props.codeTabs.map((tab) => ({
isActive: tab.label === selectedTab,
name: tab.label,
onClick: () => setSelectedTab(tab.label),
}))}
/>
<div className="h-2" />
</>
)}
{props.codeTabs.find((tab) => tab.label === selectedTab)?.code}
</div>
</div>
@@ -10,17 +10,24 @@ import {
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { type ThirdwebClient, toWei } from "thirdweb";
import {
getContract,
readContract,
type ThirdwebClient,
toUnits,
} from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import { isAddress } from "thirdweb/utils";
import { isAddress, shortenAddress } from "thirdweb/utils";
import { z } from "zod";
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
import type { Project } from "@/api/project/projects";
import type { TokenMetadata } from "@/api/universal-bridge/types";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { TokenSelector } from "@/components/blocks/TokenSelector";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -78,15 +85,96 @@ type ProjectWalletControlsProps = {
client: ThirdwebClient;
};
const STORAGE_KEY_PREFIX = "project-wallet-selection";
function getStorageKey(projectId: string): string {
return `${STORAGE_KEY_PREFIX}-${projectId}`;
}
type StoredSelection = {
chainId: number;
tokenAddress: string | undefined;
};
function readStoredSelection(projectId: string): StoredSelection | null {
if (typeof window === "undefined") {
return null;
}
try {
const stored = localStorage.getItem(getStorageKey(projectId));
if (stored) {
return JSON.parse(stored) as StoredSelection;
}
} catch {
// Ignore parse errors
}
return null;
}
function saveStoredSelection(projectId: string, selection: StoredSelection) {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(getStorageKey(projectId), JSON.stringify(selection));
} catch {
// Ignore storage errors
}
}
export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
const { projectWallet, project, defaultChainId } = props;
const [isSendOpen, setIsSendOpen] = useState(false);
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
// Initialize chain and token from localStorage or defaults
const [selectedChainId, setSelectedChainId] = useState(() => {
const stored = readStoredSelection(project.id);
return stored?.chainId ?? defaultChainId ?? 1;
});
const [selectedTokenAddress, setSelectedTokenAddress] = useState<
string | undefined
>(() => {
const stored = readStoredSelection(project.id);
if (stored) {
return stored.tokenAddress;
}
return undefined;
});
const chain = useV5DashboardChain(selectedChainId);
// Handle chain change - reset token to native when chain changes
const handleChainChange = useCallback(
(newChainId: number) => {
setSelectedChainId((prevChainId) => {
if (prevChainId !== newChainId) {
// Reset token to native (undefined) when chain changes
setSelectedTokenAddress(undefined);
saveStoredSelection(project.id, {
chainId: newChainId,
tokenAddress: undefined,
});
}
return newChainId;
});
},
[project.id],
);
// Handle token change
const handleTokenChange = useCallback(
(token: TokenMetadata) => {
setSelectedTokenAddress(token.address);
saveStoredSelection(project.id, {
chainId: selectedChainId,
tokenAddress: token.address,
});
},
[project.id, selectedChainId],
);
const engineCloudService = useMemo(
() => project.services?.find((service) => service.name === "engineCloud"),
[project.services],
@@ -117,6 +205,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
address: projectWallet.address,
chain,
client: props.client,
tokenAddress: selectedTokenAddress,
});
const canChangeWallet =
@@ -126,62 +215,12 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
return (
<div>
<div className="rounded-xl border border-border bg-card relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Open wallet actions"
className="p-2 h-auto w-auto absolute right-4 top-4 z-10"
variant="ghost"
>
<EllipsisVerticalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 rounded-xl">
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsSendOpen(true)}
>
<SendIcon className="size-4 text-muted-foreground" />
Send funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<WalletIcon className="size-4 text-muted-foreground" />
Receive funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
View transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<ShuffleIcon className="size-4 text-muted-foreground" />
Change project wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="p-5 relative flex flex-col gap-5">
<div>
<div className="rounded-xl border border-border bg-card">
<div className="p-5 flex flex-col lg:flex-row gap-5">
<div className="flex-1">
<p className="text-sm text-foreground mb-1">Wallet Address</p>
<CopyTextButton
textToShow={projectWallet.address}
textToShow={shortenAddress(projectWallet.address, 6)}
textToCopy={projectWallet.address}
tooltip="Copy wallet address"
copyIconPosition="right"
@@ -190,7 +229,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</div>
<div>
<div className="flex-1">
<p className="text-sm text-foreground mb-1">Wallet Label</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
@@ -200,7 +239,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
</div>
</div>
<div className="p-5 border-t border-dashed flex justify-between items-center">
<div className="p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
<div>
<p className="text-sm text-foreground mb-1">Balance</p>
<div className="flex items-center gap-1">
@@ -229,17 +268,88 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
</div>
</div>
<SingleNetworkSelector
chainId={selectedChainId}
className="w-fit rounded-full bg-background hover:bg-accent/50"
client={props.client}
disableDeprecated
disableChainId
onChange={setSelectedChainId}
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<SingleNetworkSelector
chainId={selectedChainId}
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
client={props.client}
disableDeprecated
disableChainId
onChange={handleChainChange}
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
<TokenSelector
selectedToken={
selectedTokenAddress
? { chainId: selectedChainId, address: selectedTokenAddress }
: undefined
}
onChange={handleTokenChange}
chainId={selectedChainId}
client={props.client}
enabled={true}
showCheck={true}
addNativeTokenIfMissing={true}
placeholder="Native token"
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSendOpen(true)}
>
<SendIcon className="size-4" />
Withdraw
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="px-2 bg-background hover:bg-accent/50"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<WalletIcon className="size-4 text-muted-foreground" />
Deposit
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
Transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<ShuffleIcon className="size-4 text-muted-foreground" />
Change Wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -253,6 +363,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
open={isSendOpen}
publishableKey={project.publishableKey}
teamId={project.teamId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
/>
@@ -467,6 +578,7 @@ const createSendFormSchema = (secretKeyLabel: string) =>
chainId: z.number({
required_error: "Select a network",
}),
tokenAddress: z.string().optional(),
toAddress: z
.string()
.trim()
@@ -491,6 +603,7 @@ type SendProjectWalletModalProps = {
publishableKey: string;
teamId: string;
chainId: number;
tokenAddress?: string;
label: string;
client: ReturnType<typeof getClientThirdwebClient>;
isManagedVault: boolean;
@@ -528,6 +641,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
publishableKey,
teamId,
chainId,
tokenAddress,
label,
client,
isManagedVault,
@@ -539,6 +653,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
defaultValues: {
amount: "0",
chainId,
tokenAddress,
secretKey: "",
vaultAccessToken: "",
toAddress: "",
@@ -548,10 +663,24 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
});
const selectedChain = useV5DashboardChain(form.watch("chainId"));
const selectedFormChainId = form.watch("chainId");
const sendMutation = useMutation({
mutationFn: async (values: SendFormValues) => {
const quantityWei = toWei(values.amount).toString();
let decimals = 18;
if (values.tokenAddress) {
const decimalsRpc = await readContract({
contract: getContract({
address: values.tokenAddress,
chain: selectedChain,
client,
}),
method: "function decimals() view returns (uint8)",
params: [],
});
decimals = Number(decimalsRpc);
}
const quantityWei = toUnits(values.amount, decimals).toString();
const secretKeyValue = values.secretKey.trim();
const vaultAccessTokenValue = values.vaultAccessToken.trim();
@@ -563,16 +692,16 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
teamId,
walletAddress,
secretKey: secretKeyValue,
...(values.tokenAddress ? { tokenAddress: values.tokenAddress } : {}),
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
});
if (!result.ok) {
const errorMessage =
typeof result.error === "string"
? result.error
: "Failed to send funds";
const errorMessage = result.error
? JSON.stringify(result.error, null, 2)
: "Failed to send funds";
throw new Error(errorMessage);
}
@@ -623,6 +752,8 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
disableDeprecated
onChange={(nextChainId) => {
field.onChange(nextChainId);
// Reset token to native when chain changes
form.setValue("tokenAddress", undefined);
}}
placeholder="Select network"
/>
@@ -632,6 +763,39 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
)}
/>
<FormField
control={form.control}
name="tokenAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Token</FormLabel>
<FormControl>
<TokenSelector
selectedToken={
field.value
? {
chainId: selectedFormChainId,
address: field.value,
}
: undefined
}
onChange={(token) => {
field.onChange(token.address);
}}
chainId={selectedFormChainId}
client={client}
enabled={true}
showCheck={true}
addNativeTokenIfMissing={true}
placeholder="Native token"
className="w-full bg-card"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="toAddress"
@@ -655,12 +819,6 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
<FormControl>
<Input inputMode="decimal" min="0" step="any" {...field} />
</FormControl>
<FormDescription>
Sending native token
{selectedChain?.nativeCurrency?.symbol
? ` (${selectedChain.nativeCurrency.symbol})`
: ""}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -682,7 +840,9 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
{...field}
/>
</FormControl>
<FormDescription>{secretKeyHelper}</FormDescription>
<FormDescription className="text-muted-foreground text-xs">
{secretKeyHelper}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -694,21 +854,20 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
name="vaultAccessToken"
render={({ field }) => (
<FormItem>
<FormLabel>
Vault access token
<span className="text-muted-foreground"> (optional)</span>
</FormLabel>
<FormLabel>Vault access token</FormLabel>
<FormControl>
<Input
autoComplete="off"
autoCorrect="off"
placeholder="Enter a vault access token (optional)"
placeholder="Enter a vault access token"
spellCheck={false}
type="password"
{...field}
/>
</FormControl>
<FormDescription>{vaultAccessTokenHelper}</FormDescription>
<FormDescription className="text-muted-foreground text-xs">
{vaultAccessTokenHelper}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -716,7 +875,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
<div className="flex justify-end gap-3 border-t bg-card p-4">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
@@ -183,9 +183,8 @@ export async function InsightAnalytics(props: {
) : (
<RequestsByStatusGraph
data={"data" in statusCodesData ? statusCodesData.data : []}
description="The number of requests by status code over time."
isPending={false}
title="Requests by Status Code"
viewMoreLink={undefined}
/>
)}
@@ -5,6 +5,7 @@ import { shortenLargeNumber } from "thirdweb/utils";
import type { InsightStatusCodeStats } from "@/api/analytics";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { ChartConfig } from "@/components/ui/chart";
type ChartData = Record<string, number> & {
@@ -15,11 +16,14 @@ const defaultLabel = 200;
export function RequestsByStatusGraph(props: {
data: InsightStatusCodeStats[];
isPending: boolean;
title: string;
description: string;
viewMoreLink: string | undefined;
}) {
const topStatusCodesToShow = 10;
const total = useMemo(() => {
return props.data.reduce((acc, curr) => acc + curr.totalRequests, 0);
}, [props.data]);
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
@@ -90,17 +94,15 @@ export function RequestsByStatusGraph(props: {
return (
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
chartClassName="aspect-auto h-[275px]"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
</div>
<TotalValueChartHeader
isPending={props.isPending}
total={total}
title="Indexer Requests"
viewMoreLink={props.viewMoreLink}
/>
}
data={chartData}
emptyChartState={<EmptyChartState type="bar" />}
@@ -4,7 +4,6 @@ import type { ThirdwebClient } from "thirdweb";
import { shortenLargeNumber } from "thirdweb/utils";
import type { RpcMethodStats } from "@/api/analytics";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Card } from "@/components/ui/card";
import { SkeletonContainer } from "@/components/ui/skeleton";
import {
Table,
@@ -15,7 +14,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CardHeading } from "../../../payments/components/common";
export function TopRPCMethodsTable(props: {
data: RpcMethodStats[];
@@ -41,18 +39,22 @@ export function TopRPCMethodsTable(props: {
const isEmpty = useMemo(() => sortedData.length === 0, [sortedData]);
return (
<Card className="relative flex flex-col rounded-xl border border-border bg-card p-4">
<div className="rounded-lg border bg-card">
{/* header */}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<CardHeading>Top EVM Methods Called </CardHeading>
<div className="p-6 border-b">
<h2 className="tracking-tight font-semibold text-lg">
Top EVM Methods Called
</h2>
</div>
<div className="h-5" />
<TableContainer scrollableContainerClassName="h-[280px]">
<TableContainer
scrollableContainerClassName="max-h-[380px] min-h-[200px]"
className="border-none"
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead>Method</TableHead>
<TableHead className="lg:w-[320px]">Method</TableHead>
<TableHead>Requests</TableHead>
</TableRow>
</TableHeader>
@@ -85,7 +87,7 @@ export function TopRPCMethodsTable(props: {
/>
</div>
)}
</Card>
</div>
);
}
@@ -1,14 +1,33 @@
"use client";
import { format } from "date-fns";
import { shortenLargeNumber } from "thirdweb/utils";
import { differenceInCalendarDays, format } from "date-fns";
import { useMemo } from "react";
import type { RpcUsageTypeStats } from "@/api/analytics";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
export function RPCRequestsChartUI(props: {
data: RpcUsageTypeStats[];
viewMoreLink: string | undefined;
isPending: boolean;
}) {
const total = useMemo(() => {
return props.data.reduce((acc, curr) => acc + curr.count, 0);
}, [props.data]);
const showBreakdownByHour = useMemo(() => {
if (props.data.length === 0) return true;
const firstData = props.data[0];
const lastData = props.data[props.data.length - 1];
if (!firstData || !lastData) return true;
const firstDate = new Date(firstData.date);
const lastDate = new Date(lastData.date);
return Math.abs(differenceInCalendarDays(lastDate, firstDate)) < 2;
}, [props.data]);
export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
return (
<ThirdwebAreaChart
chartClassName="aspect-[1.5] lg:aspect-[4]"
<ThirdwebBarChart
chartClassName="aspect-auto h-[275px]"
config={{
requests: {
color: "hsl(var(--chart-1))",
@@ -32,23 +51,33 @@ export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
},
[] as { requests: number; time: string }[],
)}
header={{
description: "Requests over time.",
title: "RPC Requests",
}}
customHeader={
<TotalValueChartHeader
total={total}
isPending={props.isPending}
title="RPC Requests"
viewMoreLink={props.viewMoreLink}
/>
}
hideLabel={false}
isPending={false}
showLegend
isPending={props.isPending}
toolTipLabelFormatter={(label) => {
return format(label, "MMM dd, HH:mm");
if (showBreakdownByHour) {
return format(label, "MMM dd, HH:mm");
}
return format(label, "MMM dd");
}}
toolTipValueFormatter={(value) => {
return shortenLargeNumber(value as number);
return compactNumberFormatter.format(value as number);
}}
xAxis={{
sameDay: true,
showHour: showBreakdownByHour,
}}
yAxis
/>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
@@ -3,10 +3,12 @@ import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import { getRpcMethodUsage, getRpcUsageByType } from "@/api/analytics";
import type { Range } from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { StatCard } from "@/components/analytics/stat";
import { Skeleton } from "@/components/ui/skeleton";
import { RpcMethodBarChartCardAsync } from "../../../components/RpcMethodBarChartCard";
import { TopRPCMethodsTable } from "./MethodsTable";
import { RequestsGraph } from "./RequestsGraph";
import { RPCRequestsChartUI } from "./RequestsGraph";
import { RpcAnalyticsFilter } from "./RpcAnalyticsFilter";
import { RpcFTUX } from "./RpcFtux";
@@ -21,7 +23,7 @@ export async function RPCAnalytics(props: {
}) {
const { projectId, teamId, range, interval, authToken } = props;
// TODO: add requests by status code, but currently not performant enough
// TODO: add requests by status code filter, but currently not performant enough
const allRequestsByUsageTypePromise = getRpcUsageByType(
{
from: range.from,
@@ -96,13 +98,35 @@ export async function RPCAnalytics(props: {
value={totalRequests}
/>
</div>
<RequestsGraph data={usageData} />
<RPCRequestsChartUI
data={usageData}
viewMoreLink={undefined}
isPending={false}
/>
<TopRPCMethodsTable
client={props.client}
data={evmMethodsData || []}
/>
</div>
</ResponsiveSuspense>
<div className="h-6" />
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[377px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<RpcMethodBarChartCardAsync
params={{
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
}}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
);
}
@@ -1,71 +0,0 @@
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
type TimeSeriesMetrics = {
date: string;
mainnet: number;
testnet: number;
total: number;
};
export function TotalSponsoredCardUI(props: {
selectedChart: string | undefined;
selectedChartQueryParam: string;
timeSeriesData: TimeSeriesMetrics[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
const {
selectedChart,
selectedChartQueryParam,
processedAggregatedData,
timeSeriesData,
} = props;
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
return (
<CombinedBarChartCard
activeChart={(selectedChart as keyof typeof chartConfig) ?? "mainnet"}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
data={timeSeriesData}
isCurrency
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
title="Gas Sponsored"
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
@@ -1,74 +1,26 @@
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { redirect } from "next/navigation";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
} from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import {
type ChainMetadata,
defineChain,
getChainMetadata,
} from "thirdweb/chains";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import {
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
isProjectActive,
} from "@/api/analytics";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { isProjectActive } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject, type Project } from "@/api/project/projects";
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UserOpStats,
WalletStats,
} from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
import { ProjectAnalytics } from "./_analytics/project-analytics";
import type { PageParams, PageSearchParams } from "./_analytics/types";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { ProjectWalletSection } from "./components/project-wallet/project-wallet";
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { ProjectHighlightsCard } from "./overview/highlights-card";
import { TotalSponsoredCardUI } from "./overview/total-sponsored";
type PageParams = {
team_slug: string;
project_slug: string;
};
type PageSearchParams = {
from: string | undefined | string[];
to: string | undefined | string[];
type: string | undefined | string[];
interval: string | undefined | string[];
appHighlights: string | undefined | string[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
type PageProps = {
params: Promise<PageParams>;
searchParams: Promise<PageSearchParams>;
};
// Revalidate this page data every 30 seconds
export const revalidate = 30;
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
props.params,
@@ -112,25 +64,25 @@ export default async function ProjectOverviewPage(props: PageProps) {
const projectWallet = await getProjectWallet(project);
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
header={{
client,
isProjectIcon: true,
icon: () => (
<ProjectAvatar
className="size-11"
client={client}
src={project.image ?? ""}
/>
),
title: project.name,
description: "Your project's overview and analytics",
// explicitly NO actions on the overview page
actions: null,
}}
>
{isActive ? (
<ProjectPage
header={{
client,
isProjectIcon: true,
icon: () => (
<ProjectAvatar
className="size-11"
client={client}
src={project.image ?? ""}
/>
),
title: project.name,
description: "Your project's overview and analytics",
// explicitly NO actions on the overview page
actions: null,
}}
>
{isActive ? (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-10">
<ProjectWalletSection
project={project}
@@ -152,469 +104,24 @@ export default async function ProjectOverviewPage(props: PageProps) {
/>
</div>
</div>
) : (
<div className="pt-6">
<ProjectFTUX
project={project}
teamSlug={params.team_slug}
projectWalletSection={
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
layout="row"
/>
}
/>
</div>
)}
</ProjectPage>
</ResponsiveSearchParamsProvider>
);
}
async function ProjectAnalytics(props: {
project: Project;
params: PageParams;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
authToken: string;
}) {
const { project, params, range, interval, searchParams, client, authToken } =
props;
return (
<div className="flex grow flex-col gap-6">
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
>
<AsyncAppHighlightsCard
authToken={authToken}
client={client}
interval={interval}
params={params}
project={project}
range={range}
selectedChart={
typeof searchParams.appHighlights === "string"
? searchParams.appHighlights
: undefined
}
selectedChartQueryParam="appHighlights"
/>
</ResponsiveSuspense>
<div className="grid gap-6 md:grid-cols-2">
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncWalletDistributionCard
</ResponsiveSearchParamsProvider>
) : (
<div className="pt-6">
<ProjectFTUX
project={project}
range={range}
authToken={authToken}
teamSlug={params.team_slug}
projectWalletSection={
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
layout="row"
/>
}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAuthMethodDistributionCard
project={project}
range={range}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
>
<TransactionsChartCardAsync
client={client}
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
selectedChartQueryParam="client_transactions"
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "totalSponsored"]}
>
<AsyncTotalSponsoredCard
interval={interval}
project={project}
range={range}
selectedChart={
typeof searchParams.totalSponsored === "string"
? searchParams.totalSponsored
: undefined
}
selectedChartQueryParam="totalSponsored"
authToken={authToken}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[377px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<RpcMethodBarChartCardAsync
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[377px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<EngineCloudChartCardAsync
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
);
}
export async function AsyncTotalSponsoredCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
selectedChart: string | undefined;
selectedChartQueryParam: string;
authToken: string;
}) {
const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([
getUserOpUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getUserOpUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
]);
return userOpUsageTimeSeries.status === "fulfilled" &&
userOpUsage.status === "fulfilled" &&
userOpUsage.value.length > 0 ? (
<TotalSponsoredCard
aggregatedData={userOpUsage.value}
data={userOpUsageTimeSeries.value}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
/>
) : (
<EmptyStateCard
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
metric="Account Abstraction"
/>
);
}
async function AsyncAuthMethodDistributionCard(props: {
project: Project;
range: Range;
authToken: string;
}) {
const inAppWalletUsage = await getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
).catch(() => undefined);
return inAppWalletUsage && inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
) : (
<EmptyStateCard
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
metric="Wallets"
/>
);
}
async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
params: PageParams;
authToken: string;
}) {
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getInAppWalletUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getUniversalBridgeUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
]);
if (
walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled"
) {
return (
<ProjectHighlightsCard
aggregatedUserStats={
aggregatedUserStats.status === "fulfilled"
? aggregatedUserStats.value
: []
}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
teamSlug={props.params.team_slug}
projectSlug={props.params.project_slug}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
: []
}
volumeStats={
universalBridgeUsage.status === "fulfilled"
? universalBridgeUsage.value
: []
}
/>
);
}
return (
<EmptyStateCard
link="https://portal.thirdweb.com/wallets"
metric="Wallets"
/>
);
}
async function AsyncWalletDistributionCard(props: {
project: Project;
range: Range;
authToken: string;
}) {
const walletConnections = await getWalletConnections(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
).catch(() => undefined);
return walletConnections && walletConnections.length > 0 ? (
<WalletDistributionCard data={walletConnections} />
) : (
<EmptyStateCard
link="https://portal.thirdweb.com/wallets/external-wallets"
metric="External Wallets"
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data.map(async (w) => {
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => ({ name: w.walletType }),
);
return {
totalConnections: w.totalConnections,
uniqueWalletsConnected: w.uniqueWalletsConnected,
walletName: wallet.name,
walletType: w.walletType,
};
}),
);
return (
<PieChartCard
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
return {
label: walletName,
value: uniqueWalletsConnected,
};
})}
title="External Wallets Connected"
/>
);
}
function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
return (
<PieChartCard
data={data.map(({ authenticationMethod, uniqueWalletsConnected }) => ({
label: authenticationMethod,
value: uniqueWalletsConnected,
}))}
title="Social Authentication"
/>
);
}
async function TotalSponsoredCard({
data,
aggregatedData,
selectedChart,
selectedChartQueryParam,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const chains = await Promise.all(
data.map(
(item) =>
// eslint-disable-next-line no-restricted-syntax
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
for (const item of data) {
const chain = chains.find((c) => c.chainId === Number(item.chainId));
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
if (chain?.testnet) {
existing.testnet += item.sponsoredUsd;
} else {
existing.mainnet += item.sponsoredUsd;
}
dateMap.set(item.date, existing);
}
// Convert to array and sort by date
const timeSeriesData = Array.from(dateMap.entries())
.map(([date, values]) => ({
date,
mainnet: values.mainnet,
testnet: values.testnet,
total: values.mainnet + values.testnet,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const processedAggregatedData = {
mainnet: aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
};
return (
<TotalSponsoredCardUI
processedAggregatedData={processedAggregatedData}
selectedChart={selectedChart}
selectedChartQueryParam={selectedChartQueryParam}
timeSeriesData={timeSeriesData}
/>
);
}
export function Header(props: {
title: string;
showRangeSelector: boolean;
defaultRange: DurationId;
}) {
const { title, showRangeSelector, defaultRange } = props;
return (
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
{title}
</h1>
</div>
{showRangeSelector && (
<div className="max-sm:w-full">
<ResponsiveTimeFilters defaultRange={defaultRange} />
</div>
)}
</div>
</ProjectPage>
);
}
@@ -67,24 +67,9 @@ export async function PayAnalytics(props: {
walletDataPromise,
]);
const hasVolume = volumeData.some((d) => d.amountUsdCents > 0);
const hasWallet = walletData.some((d) => d.count > 0);
if (!hasVolume && !hasWallet) {
return null;
}
return (
<div>
<div>
<div>
<h2 className="text-xl font-semibold tracking-tight mb-1">
Analytics
</h2>
<p className="text-muted-foreground mb-4 text-sm">
Track Bridge volume, customers, payouts, and success rates.
</p>
</div>
<div className="mb-4 flex justify-start">
<PayAnalyticsFilter />
</div>
@@ -12,7 +12,10 @@ type VolData = {
color: string;
};
export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
export function TotalVolumePieChart(props: {
data: UniversalBridgeStats[];
hideTotal?: boolean;
}) {
const data = props.data;
const isEmpty =
data.length === 0 || data.every((x) => x.amountUsdCents === 0);
@@ -36,20 +39,20 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
.filter((x) => x.type === "onramp")
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const volumeData: VolData[] = !isEmpty
? [
{
amount: cryptoTotalUSD,
color: "hsl(var(--chart-1))",
name: "Crypto",
},
{
amount: fiatTotalUSD,
color: "hsl(var(--chart-2))",
name: "Fiat",
},
]
: skeletonData;
const volumeData: VolData[] = [
{
amount: cryptoTotalUSD,
color: "hsl(var(--chart-1))",
name: "Crypto",
},
{
amount: fiatTotalUSD,
color: "hsl(var(--chart-2))",
name: "Fiat",
},
];
const chartVolumeData = !isEmpty ? volumeData : skeletonData;
return (
<section className="w-full">
@@ -62,7 +65,7 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
cornerRadius={100}
cx="50%"
cy="50%"
data={volumeData}
data={chartVolumeData}
dataKey="amount"
innerRadius="80%"
outerRadius="100%"
@@ -72,41 +75,45 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
outline: "none",
}}
>
{volumeData.map((entry, index) => (
{chartVolumeData.map((entry, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<Cell fill={entry.color} key={index} />
))}
</Pie>
</PieChart>
<div className="absolute inset-0 flex items-center justify-center p-10">
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-sm"> Total Volume</p>
{!props.hideTotal && (
<div className="absolute inset-0 flex items-center justify-center p-10">
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-sm text-muted-foreground">
Total Volume
</p>
<SkeletonContainer
loadedData={
!data
? undefined
: data.length > 0
? toUSD(cryptoTotalUSD + fiatTotalUSD)
: "NA"
}
render={(totalAmount) => {
return (
<p
className={cn(
"font-semibold text-3xl tracking-tighter",
totalAmount.length > 6 ? "text-3xl" : "text-4xl",
)}
>
{totalAmount}
</p>
);
}}
skeletonData="$100"
/>
<SkeletonContainer
loadedData={
!data
? undefined
: data.length > 0
? toUSD(cryptoTotalUSD + fiatTotalUSD)
: "NA"
}
render={(totalAmount) => {
return (
<p
className={cn(
"font-semibold text-2xl tracking-tighter",
totalAmount.length > 6 ? "text-2xl" : "text-3xl",
)}
>
{totalAmount}
</p>
);
}}
skeletonData="$100"
/>
</div>
</div>
</div>
)}
</div>
{/* Right */}
<div className="items-center border-border border-t pt-5 lg:flex lg:border-none lg:pt-0 lg:pr-10">
@@ -114,9 +121,9 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
{volumeData.map((v) => (
<VolumeLegend
amount={
!data ? undefined : data.length > 0 ? toUSD(v.amount) : "NA"
isEmpty ? "NA" : data.length > 0 ? toUSD(v.amount) : "NA"
}
color={v.color}
color={isEmpty ? "hsl(var(--muted))" : v.color}
key={v.name}
label={v.name}
/>
@@ -52,6 +52,7 @@ function Variants(props: { currentPlan: Team["billingPlan"] }) {
<div className="mx-auto w-full max-w-[1140px] px-4 py-6">
<div className="flex flex-col gap-10">
<InAppWalletSettingsUI
isLegacyPlan={false}
client={storybookThirdwebClient}
embeddedWalletService={{
actions: [],
@@ -45,6 +45,7 @@ type InAppWalletSettingsPageProps = {
teamId: string;
teamSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
smsCountryTiers: SMSCountryTiers;
client: ThirdwebClient;
};
@@ -170,6 +171,7 @@ export const InAppWalletSettingsUI: React.FC<
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
isLegacyPlan={props.isLegacyPlan}
/>
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
@@ -196,6 +198,7 @@ export const InAppWalletSettingsUI: React.FC<
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
<div className="my-5 border-t border-dashed" />
@@ -205,6 +208,7 @@ export const InAppWalletSettingsUI: React.FC<
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
<div className="my-5 border-t border-dashed" />
@@ -215,6 +219,7 @@ export const InAppWalletSettingsUI: React.FC<
smsCountryTiers={props.smsCountryTiers}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
</Fieldset>
</form>
@@ -227,6 +232,7 @@ function BrandingFieldset(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
client: ThirdwebClient;
isUpdating: boolean;
}) {
@@ -247,6 +253,7 @@ function BrandingFieldset(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("branding"),
id: "branding-switch",
@@ -391,6 +398,7 @@ function SMSCountryFields(props: {
teamPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
teamSlug: string;
isLegacyPlan: boolean;
}) {
return (
<div>
@@ -402,6 +410,7 @@ function SMSCountryFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("smsEnabledCountryISOs").length,
id: "sms-switch",
@@ -446,6 +455,7 @@ function JSONWebTokenFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
return (
<div>
@@ -468,6 +478,7 @@ function JSONWebTokenFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("customAuthentication"),
id: "authentication-switch",
@@ -533,6 +544,7 @@ function AuthEndpointFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const expandCustomAuthEndpointField =
props.form.watch("customAuthEndpoint") !== undefined;
@@ -559,6 +571,7 @@ function AuthEndpointFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: expandCustomAuthEndpointField,
id: "auth-endpoint-switch",
@@ -32,6 +32,7 @@ export function CreateNFTPageUI(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const [step, setStep] =
useState<keyof typeof nftCreationPages>("collection-info");
@@ -135,6 +136,7 @@ export function CreateNFTPageUI(props: {
{step === nftCreationPages["launch-nft"] && (
<LaunchNFT
isLegacyPlan={props.isLegacyPlan}
client={props.client}
createNFTFunctions={props.createNFTFunctions}
onLaunchSuccess={props.onLaunchSuccess}
@@ -42,6 +42,7 @@ export function CreateNFTPage(props: {
teamId: string;
projectId: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
@@ -69,6 +69,7 @@ export function LaunchNFT(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const formValues = props.values;
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
@@ -716,6 +717,7 @@ export function LaunchNFT(props: {
</div>
<GatedSwitch
currentPlan={props.teamPlan}
isLegacyPlan={props.isLegacyPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
@@ -50,6 +50,7 @@ export default async function Page(props: {
<div className="container max-w-5xl pt-8 pb-32">
<CreateNFTPage
accountAddress={accountAddress}
isLegacyPlan={team.isLegacyPlan}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
@@ -49,6 +49,7 @@ export function CreateTokenAssetPage(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
@@ -443,6 +444,7 @@ export function CreateTokenAssetPage(props: {
return (
<CreateTokenAssetPageUI
accountAddress={props.accountAddress}
isLegacyPlan={props.isLegacyPlan}
client={props.client}
createTokenFunctions={{
ERC20Asset: {
@@ -66,6 +66,7 @@ export function CreateTokenAssetPageUI(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
"token-info",
@@ -228,6 +229,7 @@ export function CreateTokenAssetPageUI(props: {
{step === "launch" && (
<LaunchTokenStatus
isLegacyPlan={props.isLegacyPlan}
client={props.client}
createTokenFunctions={props.createTokenFunctions}
onLaunchSuccess={props.onLaunchSuccess}
@@ -57,6 +57,7 @@ const mockCreateTokenFunctions: CreateTokenFunctions = {
export const Default: Story = {
args: {
isLegacyPlan: false,
accountAddress: "0x1234567890123456789012345678901234567890",
client: storybookThirdwebClient,
createTokenFunctions: mockCreateTokenFunctions,
@@ -69,6 +70,7 @@ export const Default: Story = {
export const ErrorOnDeploy: Story = {
args: {
isLegacyPlan: false,
accountAddress: "0x1234567890123456789012345678901234567890",
client: storybookThirdwebClient,
createTokenFunctions: {
@@ -90,6 +92,7 @@ export const ErrorOnDeploy: Story = {
export const StorageErrorOnDeploy: Story = {
args: {
isLegacyPlan: false,
accountAddress: "0x1234567890123456789012345678901234567890",
client: storybookThirdwebClient,
createTokenFunctions: {
@@ -63,6 +63,7 @@ export function LaunchTokenStatus(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const formValues = props.values;
const { createTokenFunctions } = props;
@@ -358,6 +359,7 @@ export function LaunchTokenStatus(props: {
</div>
<GatedSwitch
currentPlan={props.teamPlan}
isLegacyPlan={props.isLegacyPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
@@ -50,6 +50,7 @@ export default async function Page(props: {
<div className="container max-w-5xl pt-8 pb-32">
<CreateTokenAssetPage
accountAddress={accountAddress}
isLegacyPlan={team.isLegacyPlan}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
@@ -1,188 +0,0 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { DocLink } from "@/components/blocks/DocLink";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import type { ChartConfig } from "@/components/ui/chart";
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
import type { InAppWalletStats } from "@/types/analytics";
type ChartData = Record<string, number> & {
time: string; // human readable date
};
const defaultLabel = "Unknown Auth";
export function InAppWalletUsersChartCardUI(props: {
inAppWalletStats: InAppWalletStats[];
isPending: boolean;
title: string;
description: string;
}) {
const { inAppWalletStats } = props;
const topChainsToShow = 10;
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const authMethodToVolumeMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of inAppWalletStats) {
const chartData = _chartDataMap.get(stat.date);
const { authenticationMethod } = stat;
// if no data for current day - create new entry
if (!chartData && stat.uniqueWalletsConnected > 0) {
_chartDataMap.set(stat.date, {
time: stat.date,
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
} as ChartData);
} else if (chartData) {
chartData[authenticationMethod || defaultLabel] =
(chartData[authenticationMethod || defaultLabel] || 0) +
stat.uniqueWalletsConnected;
}
authMethodToVolumeMap.set(
authenticationMethod || defaultLabel,
stat.uniqueWalletsConnected +
(authMethodToVolumeMap.get(authenticationMethod || defaultLabel) ||
0),
);
}
const authMethodsSorted = Array.from(authMethodToVolumeMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const authMethodsToShow = authMethodsSorted.slice(0, topChainsToShow);
const authMethodsAsOther = authMethodsSorted.slice(topChainsToShow);
// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const authMethod in data) {
if (authMethodsAsOther.includes(authMethod)) {
data.others = (data.others || 0) + (data[authMethod] || 0);
delete data[authMethod];
}
}
}
authMethodsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
color: `hsl(var(--chart-${(i % 10) + 1}))`,
label: authMethodsToShow[i],
};
});
if (authMethodsToShow.length > topChainsToShow) {
// Add Other
authMethodsToShow.push("others");
_chartConfig.others = {
color: "hsl(var(--muted-foreground))",
label: "Others",
};
}
return {
chartConfig: _chartConfig,
chartData: Array.from(_chartDataMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
),
};
}, [inAppWalletStats]);
const uniqueAuthMethods = Object.keys(chartConfig);
const disableActions =
props.isPending ||
chartData.length === 0 ||
chartData.every((data) => data.sponsoredUsd === 0);
return (
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
disabled={disableActions}
fileName="Connect Wallets"
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
}
data={chartData}
emptyChartState={<InAppWalletUsersEmptyChartState />}
hideLabel={false}
isPending={props.isPending}
showLegend
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
function InAppWalletUsersEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
icon={TypeScriptIcon}
label="TypeScript"
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
/>
<DocLink
icon={ReactIcon}
label="React"
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
/>
<DocLink
icon={ReactIcon}
label="React Native"
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
/>
<DocLink
icon={UnityIcon}
label="Unity"
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
/>
<DocLink
icon={UnrealIcon}
label="Unreal Engine"
link="https://portal.thirdweb.com/unreal-engine/getting-started"
/>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAWalletConnections } from "@/api/analytics";
import {
AutoMergeBarChart,
type StatData,
} from "@/components/blocks/charts/automerge-barchart";
type EOAConnectionsChartProps = {
teamId: string;
projectId: string;
authToken: string;
from: Date;
to: Date;
interval: "day" | "week";
};
export function EOAConnectionsChart(props: EOAConnectionsChartProps) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<EOAConnectionsChartUI {...props} isPending={true} stats={[]} />
}
>
<AsyncEOAConnectionsChart {...props} />
</ResponsiveSuspense>
);
}
async function AsyncEOAConnectionsChart(props: EOAConnectionsChartProps) {
const stats = await getEOAWalletConnections(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<EOAConnectionsChartUI
{...props}
isPending={false}
stats={stats.map((stat) => ({
date: stat.date,
count: stat.uniqueWalletsConnected,
label: stat.walletName,
}))}
/>
);
}
function EOAConnectionsChartUI({
stats,
isPending,
}: {
stats: StatData[];
isPending: boolean;
}) {
return (
<AutoMergeBarChart
viewMoreLink={undefined}
title="EOA wallet connections"
description="Total number of unique EOA wallets connected"
stats={stats || []}
isPending={isPending}
exportButton={{
fileName: "eoa-connections",
}}
maxLabelsToShow={5}
emptyChartState={undefined}
/>
);
}
@@ -1,80 +0,0 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInAppWalletUsage } from "@/api/analytics";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import type { InAppWalletStats } from "@/types/analytics";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
type InAppWalletAnalyticsProps = {
interval: "day" | "week";
range: Range;
stats: InAppWalletStats[];
isPending: boolean;
};
function InAppWalletAnalyticsUI({
stats,
isPending,
}: InAppWalletAnalyticsProps) {
return (
<InAppWalletUsersChartCardUI
title="Unique Users"
description="The total number of active user wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
/>
);
}
type AsyncInAppWalletAnalyticsProps = Omit<
InAppWalletAnalyticsProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncInAppWalletAnalytics(
props: AsyncInAppWalletAnalyticsProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getInAppWalletUsage(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<InAppWalletAnalyticsUI
{...props}
isPending={false}
range={range}
stats={stats}
/>
);
}
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<InAppWalletAnalyticsUI {...props} isPending={true} stats={[]} />
}
>
<AsyncInAppWalletAnalytics {...props} />
</ResponsiveSuspense>
);
}
@@ -0,0 +1,83 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInAppWalletUsage } from "@/api/analytics";
import {
AutoMergeBarChart,
type StatData,
} from "@/components/blocks/charts/automerge-barchart";
type UserWalletConnectionsChartProps = {
teamId: string;
projectId: string;
authToken: string;
from: Date;
to: Date;
interval: "day" | "week";
};
export function UserWalletConnectionsChart(
props: UserWalletConnectionsChartProps,
) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<UserWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
}
>
<AsyncUserWalletConnectionsChart {...props} />
</ResponsiveSuspense>
);
}
async function AsyncUserWalletConnectionsChart(
props: UserWalletConnectionsChartProps,
) {
const stats = await getInAppWalletUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<UserWalletConnectionsChartUI
{...props}
isPending={false}
stats={stats.map((stat) => ({
date: stat.date,
count: stat.uniqueWalletsConnected,
label: stat.authenticationMethod,
}))}
/>
);
}
function UserWalletConnectionsChartUI({
stats,
isPending,
}: {
stats: StatData[];
isPending: boolean;
}) {
return (
<AutoMergeBarChart
viewMoreLink={undefined}
title="User wallet connections"
description="Total number of unique user wallets connected"
stats={stats || []}
isPending={isPending}
exportButton={{
fileName: "user-wallet-connections",
}}
maxLabelsToShow={5}
emptyChartState={undefined}
/>
);
}
@@ -57,6 +57,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `${basePath}/configuration`,
},
{
name: "Wallets",
path: `${basePath}/wallets`,
},
]}
>
{props.children}
@@ -4,9 +4,10 @@ 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 { getProjectWallet } from "@/lib/server/project-wallet";
import { loginRedirect } from "@/utils/redirects";
import { ProjectWalletSection } from "../../components/project-wallet/project-wallet";
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";
@@ -16,9 +17,6 @@ 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;
@@ -33,7 +31,7 @@ export default async function Page(props: {
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/wallets`,
);
}
@@ -103,9 +101,6 @@ export default async function Page(props: {
};
}
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const testTxWithWallet =
typeof searchParams.testTxWithWallet === "string"
? searchParams.testTxWithWallet
@@ -122,8 +117,17 @@ export default async function Page(props: {
teamId: project.teamId,
});
const projectWallet = await getProjectWallet(project);
return (
<div className="flex flex-col gap-10">
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
layout="column"
/>
<TransactionsAnalyticsPageContent
authToken={authToken}
client={client}
@@ -139,34 +143,6 @@ export default async function Page(props: {
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,138 @@
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 { 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<{
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 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 client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<div className="flex flex-col gap-10">
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
</div>
) : (
<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>
);
}
@@ -49,9 +49,9 @@ export default async function Page(props: {
{!bundlerService ? (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
<AlertTitle>Gas Sponsorship is disabled</AlertTitle>
<AlertDescription>
Enable Account Abstraction service in{" "}
Enable Gas Sponsorship in{" "}
<UnderlineLink href={`/team/${team_slug}/${project_slug}/settings`}>
project settings
</UnderlineLink>{" "}
@@ -61,6 +61,7 @@ export default async function Page(props: {
) : (
<>
<AccountAbstractionSettingsPage
isLegacyPlan={team.isLegacyPlan}
bundlerService={bundlerService}
client={client}
project={project}
@@ -43,7 +43,7 @@ export default async function Layout(props: {
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions/sponsor",
href: "https://portal.thirdweb.com/wallets/sponsor-gas",
},
],
}}
@@ -99,10 +99,13 @@ export default async function Page(props: {
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
from={range.from}
to={range.to}
/>
<AccountAbstractionAnalytics
client={client}
variant="project"
projectId={project.id}
teamId={project.teamId}
teamSlug={params.team_slug}
@@ -47,6 +47,7 @@ type InAppWalletSettingsPageProps = {
teamId: string;
teamSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
smsCountryTiers: SMSCountryTiers;
client: ThirdwebClient;
};
@@ -248,6 +249,7 @@ const InAppWalletSettingsUI: React.FC<
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
isLegacyPlan={props.isLegacyPlan}
/>
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
@@ -271,6 +273,7 @@ const InAppWalletSettingsUI: React.FC<
>
<JSONWebTokenFields
form={form}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
@@ -280,6 +283,7 @@ const InAppWalletSettingsUI: React.FC<
<AuthEndpointFields
form={form}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
@@ -289,6 +293,7 @@ const InAppWalletSettingsUI: React.FC<
<SMSCountryFields
form={form}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={authRequiredPlan}
smsCountryTiers={props.smsCountryTiers}
teamPlan={props.teamPlan}
@@ -305,6 +310,7 @@ function BrandingFieldset(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
client: ThirdwebClient;
isUpdating: boolean;
}) {
@@ -324,6 +330,7 @@ function BrandingFieldset(props: {
<div className="absolute top-8 right-6">
<GatedSwitch
currentPlan={props.teamPlan}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("branding"),
@@ -468,6 +475,7 @@ function SMSCountryFields(props: {
smsCountryTiers: SMSCountryTiers;
teamPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
teamSlug: string;
}) {
return (
@@ -480,6 +488,7 @@ function SMSCountryFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("smsEnabledCountryISOs").length,
id: "sms-switch",
@@ -521,6 +530,7 @@ function JSONWebTokenFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
return (
<div>
@@ -543,6 +553,7 @@ function JSONWebTokenFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("customAuthentication"),
id: "authentication-switch",
@@ -608,6 +619,7 @@ function AuthEndpointFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const expandCustomAuthEndpointField =
props.form.watch("customAuthEndpoint") !== undefined;
@@ -634,6 +646,7 @@ function AuthEndpointFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: expandCustomAuthEndpointField,
id: "auth-endpoint-switch",
@@ -42,6 +42,7 @@ export default async function Page(props: {
return (
<div className="flex flex-col gap-6">
<InAppWalletSettingsPage
isLegacyPlan={team.isLegacyPlan}
client={client}
project={project}
smsCountryTiers={smsCountryTiers}
@@ -57,6 +57,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `${basePath}/configuration`,
},
{
name: "Users",
path: `${basePath}/users`,
},
]}
>
{props.children}
@@ -4,12 +4,11 @@ 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 { EOAConnectionsChart } from "../analytics/chart/eoa-connections-chart";
import { InAppWalletsSummary } from "../analytics/chart/Summary";
import { UserWalletConnectionsChart } from "../analytics/chart/user-wallet-connections-chart";
export const dynamic = "force-dynamic";
@@ -47,11 +46,6 @@ export default async function Page(props: {
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">
@@ -62,17 +56,21 @@ export default async function Page(props: {
range={range}
teamId={project.teamId}
/>
<InAppWalletAnalytics
<UserWalletConnectionsChart
authToken={authToken}
interval={interval}
projectId={project.id}
range={range}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
<InAppWalletUsersPageContent
<EOAConnectionsChart
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
projectId={project.id}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
</div>
@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets/users`,
);
}
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 (
<UserWalletsTable
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
);
}
@@ -80,11 +80,11 @@ export default async function Page(props: {
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
{totalPayments === 0 ? (
<X402EmptyState walletAddress={projectWallet?.address} />
) : (
<>
<ResponsiveTimeFilters defaultRange={defaultRange} />
<X402Summary
authToken={authToken}
projectId={project.id}
@@ -8,13 +8,16 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart";
import { formatTickerNumber } from "@/utils/format-utils";
import { toUSD } from "@/utils/number";
export function PieChart({
title,
data,
isCurrency = false,
}: {
title: string;
data: { value: number; label: string; fill?: string }[];
isCurrency?: boolean;
}) {
const chartConfig: ChartConfig = Object.fromEntries(
Object.entries(data).map(([name, value]) => [
@@ -42,7 +45,11 @@ export function PieChart({
content={
<ChartTooltipContent
hideLabel
valueFormatter={(v: unknown) => formatTickerNumber(v as number)}
valueFormatter={(v: unknown) =>
isCurrency
? toUSD(v as number)
: formatTickerNumber(v as number)
}
/>
}
cursor={false}
@@ -1,4 +1,9 @@
import Link from "next/link";
import { useMemo } from "react";
import {
EmptyChartState,
LoadingChartState,
} from "@/components/analytics/empty-chart-state";
import {
Card,
CardContent,
@@ -18,10 +23,18 @@ export function PieChartCard({
title,
data,
aggregateFn = (data) => data.reduce((acc, curr) => acc + curr.value, 0),
isCurrency = false,
customHeader,
isPending = false,
emptyChartState,
}: {
title: string;
data: ChartData[];
aggregateFn?: (data: ChartData[]) => number;
isCurrency?: boolean;
customHeader?: React.ReactNode;
isPending?: boolean;
emptyChartState?: React.ReactNode;
}) {
const processedData = (() => {
// Sort by value descending
@@ -53,48 +66,69 @@ export function PieChartCard({
fill: item.fill || `hsl(var(--chart-${index + 1}))`,
}));
const isAllEmpty = useMemo(() => {
return data.every((item) => item.value === 0);
}, [data]);
return (
<Card className="flex flex-col">
<CardHeader className="border-border border-b p-0">
<Stat label={title} value={aggregateFn(data)} />
</CardHeader>
{customHeader || (
<CardHeader className="border-border border-b p-0">
<Stat label={title} value={aggregateFn(data)} />
</CardHeader>
)}
<CardContent className="flex-1 p-4">
<PieChart data={processedData} title={title} />
{isPending ? (
<LoadingChartState />
) : isAllEmpty ? (
<EmptyChartState type="none"> {emptyChartState} </EmptyChartState>
) : (
<PieChart
data={processedData}
title={title}
isCurrency={isCurrency}
/>
)}
</CardContent>
<CardFooter className="no-scrollbar flex max-w-full justify-center p-6 pt-0 max-md:overflow-x-auto">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2.5">
{processedData.map(
({
label,
fill,
link,
}: {
label: string;
fill?: string;
link?: string;
}) => (
<div className="flex items-center gap-2" key={fill}>
<div
className="size-2 rounded-full"
style={{ background: fill }}
/>
{link ? (
<Link
className="text-muted-foreground text-xs hover:text-link-foreground"
href={link}
rel="noopener noreferrer"
target="_blank"
>
{label}
</Link>
) : (
<span className="text-muted-foreground text-xs">{label}</span>
)}
</div>
),
)}
</div>
</CardFooter>
{!isAllEmpty && (
<CardFooter className="no-scrollbar flex max-w-full justify-center p-6 pt-0 max-md:overflow-x-auto">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2.5">
{processedData.map(
({
label,
fill,
link,
}: {
label: string;
fill?: string;
link?: string;
}) => (
<div className="flex items-center gap-2" key={fill}>
<div
className="size-2 rounded-full"
style={{ background: fill }}
/>
{link ? (
<Link
className="text-muted-foreground text-xs hover:text-link-foreground"
href={link}
rel="noopener noreferrer"
target="_blank"
>
{label}
</Link>
) : (
<span className="text-muted-foreground text-xs">
{label}
</span>
)}
</div>
),
)}
</div>
</CardFooter>
)}
</Card>
);
}
@@ -72,7 +72,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
<TeamVerifiedIcon domain={currentTeam.verifiedDomain} />
</Link>
{/* may render its own link so has to be outside of the link */}
<TeamPlanBadge plan={teamPlan} teamSlug={currentTeam.slug} />
<TeamPlanBadge
plan={teamPlan}
teamSlug={currentTeam.slug}
isLegacyPlan={currentTeam.isLegacyPlan}
/>
</span>
<TeamAndProjectSelectorPopoverButton
@@ -117,6 +117,7 @@ export function TeamSelectionUI(props: {
<TeamPlanBadge
plan={team.billingPlan}
isLegacyPlan={team.isLegacyPlan}
teamSlug={team.slug}
/>
</Link>
@@ -66,6 +66,7 @@ export function ProjectAndTeamSelectorCard(props: {
<span className="font-medium text-sm">{team.name}</span>
<TeamPlanBadge
plan={team.billingPlan}
isLegacyPlan={team.isLegacyPlan}
teamSlug={team.slug}
/>
</div>
@@ -62,7 +62,11 @@ export function TeamSelectorCard(props: {
>
{team.name}
</Link>
<TeamPlanBadge plan={team.billingPlan} teamSlug={team.slug} />
<TeamPlanBadge
plan={team.billingPlan}
teamSlug={team.slug}
isLegacyPlan={team.isLegacyPlan}
/>
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground group-hover:text-foreground" />
</div>
);
+2 -2
View File
@@ -1,11 +1,11 @@
export const bridgeStats = {
supportedChains: "95+",
supportedTokens: "10,000+",
supportedTokens: "14,000+",
supportedRoutes: "15M+",
};
export const bridgeStatsNumbers = {
supportedChains: 95,
supportedTokens: 10000,
supportedTokens: 14000,
supportedRoutes: 15000000,
};

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