Compare commits

..
Author SHA1 Message Date
github-actions[bot]andJonas Daniels 56eb1d2491 Version Packages 2025-12-10 01:08:35 -08:00
Jonas Daniels 70ff608c72 Add UTF-8 JSON string parsing support 2025-12-10 00:20:56 -08:00
MananTank 7e442a4c66 [MNY-328] Dashboard: Add Bridge iframe page (#8525)
<!--

## 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 enhances the `bridge` functionality by introducing iframe support, updating component structures, and modifying environment variables. It also includes changes in layout and security policies related to the `bridge` feature.

### Detailed summary
- Added `NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID` to environment variables.
- Modified `BridgeProviders` to accept `clientId` and `forcedTheme`.
- Updated `UniversalBridgeEmbed` to use dynamic `pageType`.
- Created a new layout for `BridgeEmbedLayout`.
- Enhanced security headers for `/bridge/embed`.
- Refactored imports and paths for `data` and components.
- Added `bridge-iframe` to `pageType` types across multiple files.
- Implemented new parsing logic for search parameters in the iframe page.

> ✨ 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 new /bridge/embed page to support an embeddable bridge iframe and a "bridge-iframe" flow.

* **Security**
  * Updated content security headers and frame-ancestor rules to allow safe embedding of the bridge iframe.

* **Chores**
  * Added environment variable for the bridge iframe client ID.
  * Simplified theme behavior to rely on the default dark theme.

* **Analytics**
  * Added "bridge-iframe" page type to reporting to track iframe usage.

<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-09 22:05:25 +00:00
MananTank a5b888576f Dashboard: Login page UI tweaks, remove phone login notice (#8524)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `LoginPage.tsx` component by adding a theme toggle button, updating the layout, and improving the UI elements. It incorporates new components and refines the existing structure for better aesthetics and functionality.

### Detailed summary
- Added imports for `MoonIcon`, `SunIcon`, `DotsBackgroundPattern`, `Button`, and `Skeleton`.
- Updated header background and layout, adjusting padding and margins.
- Introduced a `ToggleThemeButton` component for theme switching.
- Removed deprecated login message and background image.
- Enhanced the `LoginPageContainer` structure for cleaner rendering.

> ✨ 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 decorative dots background pattern to enhance the login page visual design.
  * Added theme toggle button enabling light and dark mode switching.
  * Enhanced header with brand text display alongside the logo.

* **Style**
  * Streamlined login page layout with a simplified and cleaner container structure.
  * Adjusted spacing, padding, and overall dimensions for improved visual hierarchy.

<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-09 17:49:25 +00:00
FirekeeperandGitHub 53b6d1b4d6 bal override path (#8516) 2025-12-09 02:36:38 +07:00
MananTank 9856ee4010 [PRO-138] Dashboard: Update Project highlights card revenue tab (#8515)
<!--

## 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 adding new functionalities and refining existing code for better performance and readability. It introduces new data handling for `x402Settlements`, updates chart configurations, and improves conditional rendering in various components.

### Detailed summary
- Added `x402Settlements` data handling in `AsyncAppHighlightsCard`.
- Updated `CombinedBarChartConfig` to include `stackedKeys`.
- Improved conditional rendering for the `EmptyChartStateGetStartedCTA`.
- Refined the `BarChart` component for better data processing.
- Enhanced `ProjectHighlightsCard` to include `bridgeRevenue` and `x402Revenue`.
- Introduced utility functions for generating mock data in stories.
- Updated types in various components for better type safety.

> ✨ 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 stacked data visualization support to charts.
  * Integrated x402 revenue tracking into analytics highlights.
  * Added ability to hide specific chart tabs from display.

* **Improvements**
  * Refined USD currency formatting to display 2 decimal places for cleaner presentation.
  * Made analytics CTA buttons conditionally displayed based on configuration.

* **Refactor**
  * Simplified settlement data model with inheritance to reduce redundancy.

<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-08 18:55:16 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3c768a0ef9 Version Packages (#8503)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-05 23:07:48 -08:00
FirekeeperandGitHub a388439022 Add Solana facilitator guide to docs (#8511) 2025-12-06 05:53:27 +07:00
MananTank 7b873d38db [MNY-322] SDK: Update success message shown in SwapWidget component (#8509)
<!--

## 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 success messages in the `SuccessScreen` component across various widgets in the `thirdweb` package, specifically for payment and swap transactions.

### Detailed summary
- Updated success message type in `SwapWidget` to `"swap-success"`.
- Updated success message type in `BuyWidget` and `CheckoutWidget` to `"payment-success"`.
- Added a new story `BasicSwapSuccess` in `SuccessScreen.stories.tsx`.
- Modified `SuccessScreen` to handle different message types.
- Updated displayed titles and descriptions based on the success type.
- Changed button text from "View Payment Receipt" to "View Transaction Receipt".

> ✨ 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**
  * Success screens now display dynamic, context-appropriate messaging tailored to transaction type, with distinct titles and descriptions for swap versus payment transactions.
  * Enhanced user experience with improved terminology—the option to "View Transaction Receipt" now displays consistently across all payment flows for better clarity and consistency.

<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-05 20:46:01 +00:00
Jonas Daniels d33a943176 Add AI privacy policy and terms to framer-rewrites.js 2025-12-05 12:38:02 -08:00
FirekeeperandGitHub d5e0b6eb73 Update API reference links in dashboard and portal (#8508) 2025-12-06 02:57:42 +07:00
Joaquim VergesandGitHub 75e9be0358 [SDK] Update token balances query to use client instead of clientId (#8507) 2025-12-04 18:32:44 -08:00
joaquim-verges a704c3f2b0 [SDK] Allow passing activeWallet to SwapWidget (#8504)
<!--

## 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 `SwapWidget` by allowing the passing of an `activeWallet` prop and improving the project wallet functionality in the dashboard. It also includes updates to the VSCode settings and introduces new components for swapping tokens.

### Detailed summary
- Added `activeWallet` prop to `SwapWidgetProps`.
- Modified `useActiveWalletInfo` to accept an `activeWalletOverride`.
- Introduced `SwapProjectWalletModalContent` for token swapping.
- Updated `ProjectWalletDetailsSection` to include swap functionality.
- Adjusted VSCode settings for formatting preferences.

> ✨ 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 in-app token swap functionality for project wallets, allowing users to exchange tokens directly from the wallet interface with credentials management.
  * SwapWidget now supports wallet pre-selection, enabling faster and more streamlined token swap initiation.

<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-05 00:03:49 +00:00
MananTank b0eccf365a [MNY-318] SDK: Remove tabs from token selection ui in SwapWidget (#8501)
<!--

## 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 `SelectToken` component by simplifying token limit management, improving UI consistency, and refactoring token display logic. It also removes unnecessary tabs and optimizes the selection process for better user experience.

### Detailed summary
- Replaced constants `ALL_TOKENS_LIMIT` and `OWNED_TOKENS_LIMIT` with `INITIAL_LIMIT`.
- Unified token limit state management into a single `limit` state.
- Refactored token fetching and display logic to streamline data handling.
- Removed unnecessary tab UI for better clarity.
- Simplified the structure of token data handling in `SelectTokenUI`.
- Updated the rendering logic for owned and other tokens to improve performance and readability.

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

* **UI Improvements**
  * Redesigned token selection UI in SwapWidget: removed tab navigation and consolidated token lists into dedicated "Your Tokens" and "Other Tokens" sections for a clearer, more intuitive experience.

* **Bug Fixes**
  * Enhanced search functionality to properly reset token filtering, improving responsiveness.

<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 22:09:50 +00:00
MananTank f3525c7c01 [PRO-123] Dashboard: Move project webhooks (#8500)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring and reorganizing the `webhooks` feature within the application, including changes to routing and component structure, as well as the introduction of a `bridge` section for webhooks.

### Detailed summary
- Deleted multiple files related to `webhooks` under the previous structure.
- Updated routing paths from `/webhooks/payments` to `/bridge/webhooks`.
- Refactored components to align with the new `bridge` structure.
- Adjusted imports to reflect new file locations.
- Removed unused `WebhookIcon` and streamlined sidebar links.
- Introduced new layout and page components under `tokens/(general)/webhooks`.

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

* **Navigation & Organization**
  - Webhooks functionality relocated to the Bridge section for improved contextual organization
  - Updated navigation paths and sidebar structure accordingly

* **UI Improvements**
  - Redesigned empty state for webhooks with enhanced visual styling and icon display

<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 21:35:46 +00:00
MananTank e516b4ddc1 [PRO-147] Dashboard: Update empty state for project analytics charts (#8499)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of empty chart states across various components in the dashboard by standardizing the `EmptyChartState` usage and enhancing the user experience with informative content.

### Detailed summary
- Updated `EmptyChartState` components to use `content` prop instead of `type`.
- Enhanced `EmptyChartState` with more descriptive text and links.
- Introduced `EmptyChartStateGetStartedCTA` for better guidance in empty states.
- Adjusted multiple charts to incorporate the new empty state handling.
- Simplified chart rendering logic when no data is available.

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

* **UI/UX Improvements**
  * Unified and simplified empty-chart visuals (smaller message, cleaner layout, adjusted loading background) and consistent spacing across analytics cards.
  * Empty states now show contextual CTAs linking to relevant configuration/details pages.

* **New Features**
  * Reusable "Get Started" CTA for empty charts with title, description and action.

* **Refactor**
  * Charts can now accept and render provided empty-state content for a more consistent UX.

<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 18:38:25 +00:00
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
221 changed files with 7681 additions and 4823 deletions
+5 -2
View File
@@ -1,4 +1,6 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 2,
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
@@ -18,8 +20,9 @@
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.fixAll.biome": "explicit"
"quickfix.biome": "always",
"source.fixAll.biome": "always",
"source.organizeImports.biome": "always"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
+3
View File
@@ -66,3 +66,6 @@ ANALYTICS_SERVICE_URL=""
STRIPE_SECRET_KEY=""
GROWTH_PLAN_SKU=""
PAYMENT_METHOD_CONFIGURATION=""
# required for bridge iframe (/bridge/embed) page
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID=""
+3
View File
@@ -68,4 +68,7 @@ module.exports = [
"/token",
"/vault",
"/monetize/bridge",
// ai
"/ai-privacy-policy",
"/ai-terms",
];
+23
View File
@@ -18,6 +18,11 @@ const ContentSecurityPolicy = `
block-all-mixed-content;
`;
const EmbedContentSecurityPolicy = `
${ContentSecurityPolicy}
frame-ancestors *;
`;
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
@@ -138,6 +143,24 @@ const baseNextConfig: NextConfig = {
// Apply these headers to all routes in your application.
source: "/(.*)",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/embed",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/embed/:path*",
},
];
},
images: {
+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"
}
+3 -3
View File
@@ -232,13 +232,13 @@ type TokenSwapParams = {
buyTokenAddress: string;
sellTokenChainId: number;
sellTokenAddress: string;
pageType: "asset" | "bridge" | "chain";
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
};
type TokenBuyParams = {
buyTokenChainId: number | undefined;
buyTokenAddress: string | undefined;
pageType: "asset" | "bridge" | "chain";
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
};
/**
@@ -298,7 +298,7 @@ export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
* @MananTank
*/
export function reportSwapWidgetShown(properties: {
pageType: "asset" | "bridge" | "chain";
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
}) {
posthog.capture("swap widget shown", properties);
}
+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,42 +1,23 @@
"use client";
import { useId, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import { ArrowUpRightIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { LoadingDots } from "@/components/ui/LoadingDots";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
type FakeCartData = {
value: number;
};
function generateRandomData(): FakeCartData[] {
return Array.from({ length: 30 }, () => ({
value: Math.floor(Math.random() * 100 + 30),
}));
}
const skeletonChartConfig = {
value: {
color: "hsl(var(--muted)/90%)",
label: "Value",
},
} satisfies ChartConfig;
export function EmptyChartState({
children,
type,
}: {
children?: React.ReactNode;
type: "bar" | "area";
}) {
const barChartData = useMemo(() => generateRandomData(), []);
export function EmptyChartState({ content }: { content?: React.ReactNode }) {
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">
{content || (
<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-sm"> No data available </p>
</div>
)}
</div>
<SkeletonBarChart data={barChartData} type={type} />
</div>
);
}
@@ -45,7 +26,7 @@ export function LoadingChartState({ className }: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30",
"pointer-events-none flex h-full w-full items-center justify-center bg-card rounded-lg",
className,
)}
>
@@ -54,51 +35,42 @@ export function LoadingChartState({ className }: { className?: string }) {
);
}
function SkeletonBarChart(props: {
data: FakeCartData[];
type: "bar" | "area";
export function EmptyChartStateGetStartedCTA(props: {
link?: {
label: string;
href: string;
};
title: string;
description?: string;
}) {
const fillAreaSkeletonId = useId();
return (
<ChartContainer
className="pointer-events-none h-full w-full blur-[5px]"
config={skeletonChartConfig}
>
{props.type === "bar" ? (
<BarChart
data={props.data}
margin={{
top: 20,
}}
<div className="flex items-center flex-col">
<div className="rounded-full border p-2 mb-4">
<XIcon className="size-4 text-foreground" />
</div>
<div className="mb-5 space-y-1">
<h3 className="text-base text-foreground text-center font-medium">
{props.title}
</h3>
{props.description && (
<p className="text-center text-sm text-muted-foreground">
{props.description}
</p>
)}
</div>
{props.link && (
<Button
asChild
className="rounded-full gap-2"
variant="default"
size="sm"
>
<Bar dataKey="value" fill="var(--color-value)" radius={8} />
</BarChart>
) : (
<AreaChart
data={props.data}
margin={{
top: 20,
}}
>
<defs>
<linearGradient id={fillAreaSkeletonId} x1="0" x2="0" y1="0" y2="1">
<stop
offset="5%"
stopColor={"hsl(var(--muted-foreground)/0.5)"}
stopOpacity={0.8}
/>
<stop offset="95%" stopColor="transparent" stopOpacity={0.1} />
</linearGradient>
</defs>
<Area
dataKey="value"
fill={`url(#${fillAreaSkeletonId})`}
radius={8}
stroke="hsl(var(--muted-foreground))"
type="monotone"
/>
</AreaChart>
<Link href={props.link.href}>
{props.link.label} <ArrowUpRightIcon className="size-4" />
</Link>
</Button>
)}
</ChartContainer>
</div>
);
}
@@ -21,6 +21,7 @@ import {
import { Button } from "@/components/ui/button";
import {
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID,
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
} from "@/constants/public-envs";
@@ -30,7 +31,7 @@ import { getSDKTheme } from "@/utils/sdk-component-theme";
import { appMetadata } from "../../constants/connect";
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
type PageType = "asset" | "bridge" | "chain";
type PageType = "asset" | "bridge" | "chain" | "bridge-iframe";
export type BuyAndSwapEmbedProps = {
buyTab:
@@ -92,7 +93,9 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
: props.pageType === "chain"
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
: undefined,
: props.pageType === "bridge-iframe"
? NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID
: undefined,
secretKey: undefined,
teamId: undefined,
});
@@ -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";
@@ -90,9 +90,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState type="area">
{props.emptyChartState}
</EmptyChartState>
<EmptyChartState content={props.emptyChartState} />
) : (
<AreaChart
accessibilityLayer
@@ -112,7 +110,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)}>
@@ -74,16 +78,19 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState type="bar">
{props.emptyChartState}
</EmptyChartState>
<EmptyChartState content={props.emptyChartState} />
) : (
<BarChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
<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>
);
@@ -41,3 +41,6 @@ export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
@@ -13,6 +13,7 @@ import {
import {
THIRDWEB_BRIDGE_URL,
THIRDWEB_BUNDLER_DOMAIN,
THIRDWEB_ENGINE_CLOUD_URL,
THIRDWEB_INAPP_WALLET_DOMAIN,
THIRDWEB_INSIGHT_API_DOMAIN,
THIRDWEB_PAY_DOMAIN,
@@ -38,6 +39,7 @@ export function getConfiguredThirdwebClient(options: {
rpc: THIRDWEB_RPC_DOMAIN,
social: THIRDWEB_SOCIAL_API_DOMAIN,
storage: THIRDWEB_STORAGE_DOMAIN,
engineCloud: new URL(THIRDWEB_ENGINE_CLOUD_URL).hostname,
});
}
+3
View File
@@ -25,3 +25,6 @@ export const THIRDWEB_INSIGHT_API_DOMAIN =
export const THIRDWEB_BRIDGE_URL =
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb-dev.com";
export const THIRDWEB_ENGINE_CLOUD_URL =
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "https://engine.thirdweb-dev.com";
+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,
+8 -27
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;
@@ -104,36 +101,20 @@ export interface X402SettlementsOverall {
totalValueUSD: number;
}
export interface X402SettlementsByChainId {
date: string;
export interface X402SettlementsByChainId extends X402SettlementsOverall {
chainId: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByPayer {
date: string;
export interface X402SettlementsByPayer extends X402SettlementsOverall {
payer: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
interface X402SettlementsByReceiver {
date: string;
interface X402SettlementsByReceiver extends X402SettlementsOverall {
receiver: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByResource {
date: string;
export interface X402SettlementsByResource extends X402SettlementsOverall {
resource: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
interface X402SettlementsByAsset {
@@ -1,5 +1,6 @@
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
export const formatTickerNumber = (value: number) => {
+5 -5
View File
@@ -1,10 +1,10 @@
const usdCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 6, // prefix with $
minimumFractionDigits: 0, // don't show decimal places if value is a whole number
notation: "compact", // at max 2 decimal places
roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations
style: "currency", // shows 1.2M instead of 1,200,000, 1.2B instead of 1,200,000,000
maximumFractionDigits: 2,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
style: "currency",
});
export const toUSD = (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"
>
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
import { unstable_cache } from "next/cache";
import { Bridge } from "thirdweb";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { bridgeStats } from "../../../bridge/data";
import { bridgeStats } from "../../../bridge/(general)/data";
import { PageHeader } from "./components/header";
import { TokenPage } from "./components/token-page";
@@ -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()}
@@ -1,7 +1,6 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { useEffect, useMemo } from "react";
@@ -25,9 +24,6 @@ export function AppRouterProviders(props: {
children: React.ReactNode;
autoConnect: boolean;
}) {
// get the current pathname
const pathname = usePathname();
return (
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
@@ -40,9 +36,6 @@ export function AppRouterProviders(props: {
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
forcedTheme={
pathname.startsWith("/get-started") ? "dark" : undefined
}
>
<Toaster richColors />
<SanctionedAddressesChecker>
@@ -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}
@@ -63,15 +63,17 @@ function MetricPlaceholders({
{metric.label}
</span>
<div className="h-32 w-full">
<EmptyChartState type="area">
{status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)}
</EmptyChartState>
<EmptyChartState
content={
status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)
}
/>
</div>
</Card>
))}
@@ -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"
@@ -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,84 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getAiUsage } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
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`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`,
}}
title="No data available"
description="No AI usage found in selected time period"
/>
}
/>
);
}
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,92 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
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={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets/configure`,
}}
title="No data available"
description="No wallet connections found in selected time period"
/>
}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
/>
);
}
@@ -0,0 +1,102 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getUniversalBridgeUsage } from "@/api/analytics";
import {
EmptyChartState,
EmptyChartStateGetStartedCTA,
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">
{total === 0 ? (
<EmptyChartState
content={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
}}
title="No data available"
description="No bridge volume found in selected time period"
/>
}
/>
) : (
<TotalVolumePieChart data={props.data} hideTotal={true} />
)}
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import type { Meta } from "@storybook/nextjs";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import type {
InAppWalletStats,
UniversalBridgeStats,
X402SettlementsOverall,
} from "@/types/analytics";
import { ProjectHighlightsCard } from "./highlights-card-ui";
const meta = {
component: ProjectHighlightsCard,
decorators: [
(Story) => (
<ResponsiveSearchParamsProvider value={{}}>
<div className="container max-w-6xl py-10">
<Story />
</div>
</ResponsiveSearchParamsProvider>
),
],
title: "Analytics/ProjectHighlightsCard",
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof ProjectHighlightsCard>;
export default meta;
function generateDateSeries(days: number) {
const dates: string[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
dates.push(d.toISOString());
}
return dates;
}
function inAppWalletStub(days: number): InAppWalletStats[] {
const dates = generateDateSeries(days);
return dates.map((date) => {
return {
// main data
newUsers: randomValue(50, 100),
uniqueWalletsConnected: randomValue(500, 1200),
// others
authenticationMethod: "email",
date,
};
});
}
// a few aggregated entries that will be summed for the "Active Users" stat
function aggregatedInAppWalletsStub(): InAppWalletStats[] {
return Array.from({ length: 3 }).map(() => {
return {
// main data
newUsers: randomValue(50, 100),
uniqueWalletsConnected: randomValue(500, 1200),
// others
date: new Date().toISOString(),
authenticationMethod: "email",
};
});
}
function bridgeVolumeStub(days: number): UniversalBridgeStats[] {
const dates = generateDateSeries(days);
return dates.map((date) => {
return {
// main data
developerFeeUsdCents: randomValue(500 * 100, 2500 * 100),
status: "completed",
// others
chainId: 0,
count: 0,
date,
amountUsdCents: 0,
type: "onchain",
};
});
}
function randomValue(min: number, max: number) {
return Math.max(0, Math.round(min + Math.random() * (max - min)));
}
function generateX402Settlements(days: number): X402SettlementsOverall[] {
const dates = generateDateSeries(days);
return dates.map((date) => {
return {
date,
totalRequests: randomValue(10, 100),
totalValue: randomValue(500, 2500),
totalValueUSD: randomValue(500, 2500),
};
});
}
export function ThirtyDays() {
const days = 30;
const data = {
aggregatedUserStats: aggregatedInAppWalletsStub(),
userStats: inAppWalletStub(days),
bridgeVolumeStats: bridgeVolumeStub(days),
x402Settlements: generateX402Settlements(days),
};
return (
<ProjectHighlightsCard
aggregatedUserStats={data.aggregatedUserStats}
userStats={data.userStats}
volumeStats={data.bridgeVolumeStats}
x402Settlements={data.x402Settlements}
/>
);
}
export function SingleDayRevenue() {
const days = 1;
const data = {
aggregatedUserStats: [],
userStats: [],
bridgeVolumeStats: bridgeVolumeStub(days),
x402Settlements: generateX402Settlements(days),
};
return (
<ProjectHighlightsCard
aggregatedUserStats={data.aggregatedUserStats}
userStats={data.userStats}
volumeStats={data.bridgeVolumeStats}
x402Settlements={data.x402Settlements}
/>
);
}
export function SingleDayRevenueNoX402() {
const days = 1;
const data = {
aggregatedUserStats: [],
userStats: [],
bridgeVolumeStats: bridgeVolumeStub(days),
x402Settlements: [],
};
return (
<ProjectHighlightsCard
aggregatedUserStats={data.aggregatedUserStats}
userStats={data.userStats}
volumeStats={data.bridgeVolumeStats}
x402Settlements={data.x402Settlements}
/>
);
}
@@ -1,67 +1,66 @@
"use client";
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { InAppWalletStats, UniversalBridgeStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
import { useState } from "react";
import type {
InAppWalletStats,
UniversalBridgeStats,
X402SettlementsOverall,
} from "@/types/analytics";
import {
CombinedBarChartCard,
type CombinedBarChartConfig,
} from "../../../../components/Analytics/CombinedBarChartCard";
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
bridgeRevenue: number;
x402Revenue: number;
};
export function ProjectHighlightsCard(props: {
selectedChart: string | undefined;
aggregatedUserStats: InAppWalletStats[];
userStats: InAppWalletStats[];
volumeStats: UniversalBridgeStats[];
teamSlug: string;
projectSlug: string;
selectedChartQueryParam: string;
x402Settlements: X402SettlementsOverall[];
}) {
const {
selectedChart,
aggregatedUserStats,
const { aggregatedUserStats, userStats, volumeStats, x402Settlements } =
props;
const [selectedChart, setSelectedChart] = useState<string | undefined>(
"activeUsers",
);
const timeSeriesData = processTimeSeriesData(
userStats,
volumeStats,
teamSlug,
projectSlug,
selectedChartQueryParam,
} = props;
x402Settlements,
);
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const chartConfig = {
const chartConfig: CombinedBarChartConfig<keyof AggregatedMetrics> = {
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"
/>
),
newUsers: { color: "hsl(var(--chart-2))", label: "New Users" },
bridgeRevenue: {
color: "hsl(var(--chart-3))",
isCurrency: true,
label: "Total Volume",
label: "Bridge",
hideAsTab: true,
},
x402Revenue: {
color: "hsl(var(--chart-4))",
isCurrency: true,
label: "x402",
hideAsTab: true,
},
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${teamSlug}/${projectSlug}/bridge/configuration`}
metric="Fees"
/>
),
color: "hsl(var(--chart-3))",
emptyContent: undefined,
isCurrency: true,
label: "Bridge Fee Revenue",
label: "Revenue",
stackedKeys: ["bridgeRevenue", "x402Revenue"],
},
} as const;
};
return (
<CombinedBarChartCard
@@ -83,12 +82,7 @@ export function ProjectHighlightsCard(props: {
chartConfig={chartConfig}
data={timeSeriesData}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
setSelectedChart(key);
}}
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
@@ -103,6 +97,8 @@ export function ProjectHighlightsCard(props: {
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
bridgeRevenue: number;
x402Revenue: number;
};
/**
@@ -111,48 +107,51 @@ type TimeSeriesMetrics = AggregatedMetrics & {
function processTimeSeriesData(
userStats: InAppWalletStats[],
volumeStats: UniversalBridgeStats[],
x402Settlements: X402SettlementsOverall[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
const dates = [
...new Set([
...userStats.map((a) => new Date(a.date).toISOString().slice(0, 10)),
...volumeStats.map((a) => new Date(a.date).toISOString().slice(0, 10)),
...userStats.map((a) => ignoreTimePeriod(a.date)),
...volumeStats.map((a) => ignoreTimePeriod(a.date)),
...x402Settlements.map((a) => ignoreTimePeriod(a.date)),
]),
];
for (const date of dates) {
const activeUsers = userStats
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
.filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date))
.reduce((acc, curr) => acc + curr.uniqueWalletsConnected, 0);
const newUsers = userStats
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
.filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date))
.reduce((acc, curr) => acc + curr.newUsers, 0);
const volume = volumeStats
const bridgeRevenue = 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) =>
new Date(v.date).toISOString().slice(0, 10) === date &&
ignoreTimePeriod(v.date) === ignoreTimePeriod(date) &&
v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
const x402Revenue = x402Settlements
.filter((x) => ignoreTimePeriod(x.date) === ignoreTimePeriod(date))
.reduce((acc, curr) => acc + curr.totalValueUSD, 0);
metrics.push({
activeUsers: activeUsers,
date: date,
feesCollected: fees,
feesCollected: bridgeRevenue + x402Revenue,
bridgeRevenue: bridgeRevenue,
x402Revenue: x402Revenue,
newUsers: newUsers,
totalVolume: volume,
});
}
return metrics;
}
function ignoreTimePeriod(date: string) {
return new Date(date).toISOString().slice(0, 10);
}
@@ -0,0 +1,131 @@
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import {
getInAppWalletUsage,
getUniversalBridgeUsage,
getX402Settlements,
} 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"]}
>
<AsyncAppHighlightsCard
authToken={props.authToken}
client={props.client}
interval={props.interval}
params={props.params}
project={props.project}
range={props.range}
/>
</ResponsiveSuspense>
);
}
async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
client: ThirdwebClient;
params: PageParams;
authToken: string;
}) {
const [
aggregatedUserStats,
walletUserStatsTimeSeries,
universalBridgeUsage,
x402Settlements,
] = 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,
),
getX402Settlements(
{
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
: []
}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
: []
}
volumeStats={
universalBridgeUsage.status === "fulfilled"
? universalBridgeUsage.value
: []
}
x402Settlements={
x402Settlements.status === "fulfilled" ? x402Settlements.value : []
}
/>
);
}
return (
<EmptyStateCard
link="https://portal.thirdweb.com/wallets"
metric="Wallets"
/>
);
}
@@ -0,0 +1,79 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInsightStatusCodeUsage } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
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`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`,
}}
title="No data available"
description="No indexer requests found in selected time period"
/>
}
/>
);
}
@@ -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,81 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getRpcUsageByType } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
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);
const isEmpty = requestsData?.every((d) => d.count === 0);
return (
<RPCRequestsChartUI
isPending={false}
data={isEmpty ? [] : requestsData || []}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`,
}}
title="No data available"
description="No RPC requests found in selected time period"
/>
}
/>
);
}
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,13 @@
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[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
@@ -0,0 +1,93 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
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}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/x402/configuration`,
}}
title="No data available"
description="No X402 requests found in selected time period"
/>
}
/>
);
}
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,53 @@
"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;
emptyChartState?: React.ReactElement | undefined;
}) {
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`}
/>
}
emptyChartState={props.emptyChartState}
/>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
@@ -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,8 @@ export function AiTokenUsageChartCardUI(props: {
aiUsageStats: AIUsageStats[];
isPending: boolean;
title: string;
description: string;
viewMoreLink: string | undefined;
emptyChartState?: React.ReactElement | undefined;
}) {
const { aiUsageStats } = props;
@@ -52,36 +55,46 @@ 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 />}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend={false}
@@ -97,20 +110,27 @@ export function AiTokenUsageChartCardUI(props: {
);
}
function AiTokenUsageEmptyChartState() {
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"}
@@ -60,7 +60,7 @@ export function QuickStartSection(props: {
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
href: `/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`,
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`,
label: "Setup Webhooks",
}}
/>
@@ -1,4 +1,3 @@
import { WebhookIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
@@ -43,13 +42,7 @@ export default async function Layout(props: {
instantly
</>
),
actions: {
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
actions: null,
links: [
{
type: "docs",
@@ -75,6 +68,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/configuration`,
},
{
name: "Webhooks",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
},
]}
>
{props.children}
@@ -46,7 +46,7 @@ export default async function Page(props: {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={`/team/${params.team_slug}/${params.project_slug}/webhooks/payments`}
href={`/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`}
>
Webhooks
</Link>
@@ -12,6 +12,7 @@ import {
PlusIcon,
TrashIcon,
WebhookIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { type PropsWithChildren, useMemo, useState } from "react";
@@ -95,9 +96,14 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
if (!webhooksQuery.data?.length) {
return (
<div className="flex flex-col items-center gap-6 rounded-lg border border-border bg-card p-8 text-center">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-xl">No webhooks configured</h2>
<p className="text-sm text-muted-foreground">
<div className="flex flex-col items-center">
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h2 className="font-semibold text-base mb-1">
No webhooks configured
</h2>
<p className="text-muted-foreground text-sm">
Create a webhook to receive notifications for bridge, swap or onramp
events.
</p>
@@ -126,7 +132,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
webhook={webhook}
clientId={props.clientId}
teamId={props.teamId}
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`}
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`}
/>
))}
</div>
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { PayWebhooksPage } from "../../payments/webhooks/components/webhooks.client";
import { PayWebhooksPage } from "./components/webhooks.client";
export default async function Page(props: {
params: Promise<{
@@ -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} />;
}
@@ -6,7 +6,6 @@ import {
DoorOpenIcon,
HomeIcon,
Settings2Icon,
WebhookIcon,
} from "lucide-react";
import {
FullWidthSidebarLayout,
@@ -83,9 +82,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: {
@@ -119,14 +135,6 @@ export function ProjectSidebarLayout(props: {
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
@@ -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>
@@ -3,6 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ArrowLeftRightIcon,
EllipsisVerticalIcon,
RefreshCcwIcon,
@@ -10,17 +11,29 @@ import {
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { type ThirdwebClient, toWei } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import { isAddress } from "thirdweb/utils";
import {
createThirdwebClient,
Engine,
getContract,
readContract,
type ThirdwebClient,
toUnits,
} from "thirdweb";
import type { Chain } from "thirdweb/chains";
import { SwapWidget, useWalletBalance } from "thirdweb/react";
import { isAddress, shortenAddress } from "thirdweb/utils";
import { createWalletAdapter } from "thirdweb/wallets";
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";
@@ -62,6 +75,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
type GetProjectServerWallets = (params: {
@@ -78,15 +92,101 @@ 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);
const [isSwapOpen, setIsSwapOpen] = useState(false);
// Persist swap credentials in memory so users don't have to re-enter them
const [swapSecretKey, setSwapSecretKey] = useState("");
const [swapVaultAccessToken, setSwapVaultAccessToken] = useState("");
// 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 +217,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
address: projectWallet.address,
chain,
client: props.client,
tokenAddress: selectedTokenAddress,
});
const canChangeWallet =
@@ -126,62 +227,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 +241,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 +251,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 +280,97 @@ 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>
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSwapOpen(true)}
>
<ArrowLeftRightIcon className="size-4" />
Swap
</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 +384,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
open={isSendOpen}
publishableKey={project.publishableKey}
teamId={project.teamId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
/>
@@ -283,6 +415,25 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setIsSwapOpen} open={isSwapOpen}>
<DialogContent className="gap-0 p-0 overflow-hidden max-w-md">
<SwapProjectWalletModalContent
client={props.client}
chainId={selectedChainId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
chain={chain}
isManagedVault={isManagedVault}
publishableKey={project.publishableKey}
secretKey={swapSecretKey}
setSecretKey={setSwapSecretKey}
vaultAccessToken={swapVaultAccessToken}
setVaultAccessToken={setSwapVaultAccessToken}
onClose={() => setIsSwapOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
);
}
@@ -462,11 +613,197 @@ function ChangeProjectWalletDialogContent(props: {
);
}
type SwapProjectWalletModalContentProps = {
client: ThirdwebClient;
chainId: number;
tokenAddress: string | undefined;
walletAddress: string;
chain: Chain;
isManagedVault: boolean;
publishableKey: string;
secretKey: string;
setSecretKey: (value: string) => void;
vaultAccessToken: string;
setVaultAccessToken: (value: string) => void;
onClose: () => void;
};
function SwapProjectWalletModalContent(
props: SwapProjectWalletModalContentProps,
) {
const {
chainId,
tokenAddress,
walletAddress,
chain,
isManagedVault,
publishableKey,
secretKey,
setSecretKey,
vaultAccessToken,
setVaultAccessToken,
onClose,
} = props;
const [screen, setScreen] = useState<"credentials" | "swap">("credentials");
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
const hasRequiredCredentials = isManagedVault
? secretKey.trim().length > 0
: secretKey.trim().length > 0 && vaultAccessToken.trim().length > 0;
const swapClient = useMemo(() => {
if (!secretKey.trim()) {
return null;
}
// use the inputted secret key to create the client used for the serverWallet
return createThirdwebClient({
clientId: publishableKey,
secretKey: secretKey.trim(),
});
}, [secretKey, publishableKey]);
const activeWallet = useMemo(() => {
if (!swapClient) {
return undefined;
}
const vaultAccessTokenValue = vaultAccessToken.trim();
return createWalletAdapter({
adaptedAccount: Engine.serverWallet({
client: swapClient,
address: walletAddress,
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
}),
chain: chain,
client: swapClient,
onDisconnect: () => {},
switchChain: () => {},
});
}, [swapClient, walletAddress, chain, vaultAccessToken]);
// Screen 1: Credentials
if (screen === "credentials") {
return (
<div>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Enter your credentials to swap tokens from your project wallet
</DialogDescription>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="swap-secret-key"
className="text-sm font-medium leading-none"
>
Project secret key
</label>
<Input
id="swap-secret-key"
type="password"
placeholder="Enter your project secret key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">{secretKeyHelper}</p>
</div>
{!isManagedVault && (
<div className="space-y-2">
<label
htmlFor="swap-vault-access-token"
className="text-sm font-medium leading-none"
>
Vault access token
</label>
<Input
id="swap-vault-access-token"
type="password"
placeholder="Enter a vault access token"
value={vaultAccessToken}
onChange={(e) => setVaultAccessToken(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
{vaultAccessTokenHelper}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button
onClick={() => setScreen("swap")}
type="button"
disabled={!hasRequiredCredentials}
>
Continue
</Button>
</div>
</div>
);
}
// Screen 2: Swap Widget
return (
<div className="w-full">
<DialogHeader className="p-4 lg:p-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="p-1 h-auto w-auto"
onClick={() => setScreen("credentials")}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Swap tokens from your project wallet
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 flex justify-center">
{activeWallet && (
<SwapWidget
client={props.client}
prefill={{
sellToken: {
chainId: chainId,
tokenAddress: tokenAddress,
},
}}
activeWallet={activeWallet}
theme={getSDKTheme(t)}
/>
)}
</div>
</div>
);
}
const createSendFormSchema = (secretKeyLabel: string) =>
z.object({
chainId: z.number({
required_error: "Select a network",
}),
tokenAddress: z.string().optional(),
toAddress: z
.string()
.trim()
@@ -491,6 +828,7 @@ type SendProjectWalletModalProps = {
publishableKey: string;
teamId: string;
chainId: number;
tokenAddress?: string;
label: string;
client: ReturnType<typeof getClientThirdwebClient>;
isManagedVault: boolean;
@@ -528,6 +866,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
publishableKey,
teamId,
chainId,
tokenAddress,
label,
client,
isManagedVault,
@@ -539,6 +878,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
defaultValues: {
amount: "0",
chainId,
tokenAddress,
secretKey: "",
vaultAccessToken: "",
toAddress: "",
@@ -548,10 +888,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 +917,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 +977,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 +988,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 +1044,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 +1065,9 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
{...field}
/>
</FormControl>
<FormDescription>{secretKeyHelper}</FormDescription>
<FormDescription className="text-muted-foreground text-xs">
{secretKeyHelper}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -694,21 +1079,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 +1100,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>
@@ -98,7 +98,7 @@ export default async function Page(props: {
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/contracts",
href: "https://api.thirdweb.com/reference#tag/gateway",
},
{
type: "webhooks",
@@ -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}
/>
)}
@@ -3,8 +3,8 @@ import { format } from "date-fns";
import { useMemo } from "react";
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 +15,15 @@ const defaultLabel = 200;
export function RequestsByStatusGraph(props: {
data: InsightStatusCodeStats[];
isPending: boolean;
title: string;
description: string;
viewMoreLink: string | undefined;
emptyChartState?: React.ReactElement | 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,20 +94,18 @@ 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" />}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend
@@ -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,34 @@
"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;
emptyChartState?: React.ReactElement | undefined;
}) {
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 +52,34 @@ 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
emptyChartState={props.emptyChartState}
/>
);
}
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>
);
}
@@ -39,7 +39,7 @@ export function AdvancedSection({
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
href: `/team/${teamSlug}/${projectSlug}/webhooks/payments`,
href: `/team/${teamSlug}/${projectSlug}/bridge/webhooks`,
label: "Setup Webhooks",
}}
/>
@@ -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}
/>
@@ -66,7 +66,7 @@ export default async function Page(props: {
),
},
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
href: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
@@ -85,7 +85,7 @@ export default async function Page(props: {
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/payments",
href: "https://api.thirdweb.com/reference#tag/x402",
},
],
}}
@@ -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",
@@ -1,16 +1,10 @@
import { Button } from "@workspace/ui/components/button";
import { PlusIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { ClientOnly } from "@/components/blocks/client-only";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ContractTable } from "@/components/contract-components/tables/contract-table";
import {
Dialog,
DialogContent,
@@ -21,10 +15,11 @@ import {
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TokenIcon } from "@/icons/TokenIcon";
import { loginRedirect } from "@/utils/redirects";
import { Cards, ImportTokenButton } from "./cards";
import { Cards, ImportTokenButton } from "../cards";
export default async function Page(props: {
export default async function Layout(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
children: React.ReactNode;
}) {
const params = await props.params;
@@ -110,48 +105,19 @@ export default async function Page(props: {
},
],
}}
tabs={[
{
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/tokens`,
exactMatch: true,
},
{
name: "Webhooks",
path: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
},
]}
>
<Suspense fallback={<GenericLoadingPage />}>
<AssetsPageAsync
authToken={authToken}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
teamSlug={params.team_slug}
/>
</Suspense>
{props.children}
</ProjectPage>
);
}
async function AssetsPageAsync(props: {
teamId: string;
projectId: string;
authToken: string;
client: ThirdwebClient;
teamSlug: string;
projectSlug: string;
}) {
const deployedContracts = await getFilteredProjectContracts({
authToken: props.authToken,
type: "token-contracts",
projectId: props.projectId,
teamId: props.teamId,
});
return (
<ClientOnly ssr={<GenericLoadingPage />}>
<ContractTable
client={props.client}
contracts={deployedContracts}
pageSize={10}
projectId={props.projectId}
projectSlug={props.projectSlug}
teamId={props.teamId}
teamSlug={props.teamSlug}
variant="asset"
/>
</ClientOnly>
);
}
@@ -0,0 +1,84 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { ClientOnly } from "@/components/blocks/client-only";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ContractTable } from "@/components/contract-components/tables/contract-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const [authToken, team, project] = await Promise.all([
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/tokens`);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<Suspense fallback={<GenericLoadingPage />}>
<AssetsPageAsync
authToken={authToken}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
teamSlug={params.team_slug}
/>
</Suspense>
);
}
async function AssetsPageAsync(props: {
teamId: string;
projectId: string;
authToken: string;
client: ThirdwebClient;
teamSlug: string;
projectSlug: string;
}) {
const deployedContracts = await getFilteredProjectContracts({
authToken: props.authToken,
type: "token-contracts",
projectId: props.projectId,
teamId: props.teamId,
});
return (
<ClientOnly ssr={<GenericLoadingPage />}>
<ContractTable
client={props.client}
contracts={deployedContracts}
pageSize={10}
projectId={props.projectId}
projectSlug={props.projectSlug}
teamId={props.teamId}
teamSlug={props.teamSlug}
variant="asset"
/>
</ClientOnly>
);
}
@@ -13,7 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import type { WebhookFormValues } from "../utils/webhookTypes";
import type { WebhookFormValues } from "./utils/webhookTypes";
interface BasicInfoStepProps {
form: UseFormReturn<WebhookFormValues>;
@@ -16,26 +16,26 @@ import {
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useAbiMultiFetch } from "../hooks/useAbiProcessing";
import { useTestWebhook } from "../hooks/useTestWebhook";
import BasicInfoStep from "./BasicInfoStep";
import { FilterDetailsStep } from "./FilterDetailsStep";
import ReviewStep from "./ReviewStep";
import { useAbiMultiFetch } from "./useAbiProcessing";
import { useTestWebhook } from "./useTestWebhook";
import {
extractEventSignatures,
extractFunctionSignatures,
} from "../utils/abiUtils";
import type { WebhookPayload } from "../utils/webhookPayloadUtils";
} from "./utils/abiUtils";
import type { WebhookPayload } from "./utils/webhookPayloadUtils";
import {
buildEventWebhookPayload,
buildTransactionWebhookPayload,
} from "../utils/webhookPayloadUtils";
} from "./utils/webhookPayloadUtils";
import {
type WebhookFormStep,
WebhookFormSteps,
type WebhookFormValues,
webhookFormSchema,
} from "../utils/webhookTypes";
import BasicInfoStep from "./BasicInfoStep";
import { FilterDetailsStep } from "./FilterDetailsStep";
import ReviewStep from "./ReviewStep";
} from "./utils/webhookTypes";
interface CreateWebhookModalProps {
projectClientId: string;
@@ -17,13 +17,13 @@ import {
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { truncateMiddle } from "../utils/abiUtils";
import { truncateMiddle } from "./utils/abiUtils";
import type {
AbiData,
EventSignature,
FunctionSignature,
WebhookFormValues,
} from "../utils/webhookTypes";
} from "./utils/webhookTypes";
interface FilterDetailsStepProps {
form: UseFormReturn<WebhookFormValues>;
@@ -3,8 +3,8 @@ import { CheckIcon, PlayIcon, XIcon } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner";
import { truncateMiddle } from "../utils/abiUtils";
import type { WebhookFormValues } from "../utils/webhookTypes";
import { truncateMiddle } from "./utils/abiUtils";
import type { WebhookFormValues } from "./utils/webhookTypes";
interface WebhookTestResult {
status: string;
@@ -17,9 +17,9 @@ import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useTestWebhook } from "../hooks/useTestWebhook";
import { CreateContractWebhookButton } from "./CreateWebhookModal";
import { RelativeTime } from "./RelativeTime";
import { useTestWebhook } from "./useTestWebhook";
function getEventType(filters: WebhookFilters): string {
if (!filters || typeof filters !== "object") {
@@ -1,8 +1,9 @@
import { XIcon } from "lucide-react";
import { getWebhooks, type WebhookResponse } from "@/api/insight/webhooks";
import type { Project } from "@/api/project/projects";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { CreateContractWebhookButton } from "../components/CreateWebhookModal";
import { ContractsWebhooksTable } from "../components/WebhooksTable";
import { CreateContractWebhookButton } from "./CreateWebhookModal";
import { ContractsWebhooksTable } from "./WebhooksTable";
export async function ContractsWebhooksPageContent(props: {
project: Project;
@@ -50,11 +51,14 @@ export async function ContractsWebhooksPageContent(props: {
webhooks={webhooks}
/>
) : (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border py-14 px-6 text-center">
<div>
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
<p className="text-muted-foreground">
Create a webhook to get started.
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h3 className="mb-1 font-semibold text-base">No webhooks found</h3>
<p className="text-muted-foreground text-sm">
Create a webhook to get started
</p>
</div>
<CreateContractWebhookButton
@@ -5,12 +5,12 @@ import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getContract, resolveAbiFromContractApi } from "thirdweb/contract";
import { useGetV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { parseAddresses } from "../utils/webhookPayloadUtils";
import { parseAddresses } from "./utils/webhookPayloadUtils";
import type {
AbiData,
EventSignature,
FunctionSignature,
} from "../utils/webhookTypes";
} from "./utils/webhookTypes";
export function useAbiMultiFetch({
isOpen,

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