Compare commits

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing chart components and their presentation in the dashboard, including layout adjustments, new chart configurations, and improved user feedback for empty states.

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

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

<!-- end pr-codex -->

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

* **New Features**
  * Added X402 requests analytics chart with per-period totals to the project dashboard.

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

<!-- end pr-codex -->

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

* **New Features**
  * AI token usage analytics card added to project dashboard with totals and chart.
  * AI sidebar reorganized into Chat and Analytics entries; AI analytics accessible from project pages.

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the user wallet components in the dashboard application, replacing the `InAppWalletUsersPageContent` with `UserWalletsTable` and enhancing the user wallet functionalities.

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

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

<!-- end pr-codex -->

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

* **New Features**
  * Added a "Users" tab in the wallets area and a dedicated Users page showing user wallets (search, download and pagination available).

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

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

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

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

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

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

<!-- start pr-codex -->

---

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

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

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

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

Closes BLD-500

<!-- start pr-codex -->

---

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

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

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

<!-- end pr-codex -->

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

* **New Features**
  * Account abstraction views now support team- and project-level variants and explicit date-range (from/to) filtering.
  * Analytics components expose flexible props to render project or team analytics based on resolved ranges.

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

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-24 15:45:06 +00:00
146 changed files with 4006 additions and 1592 deletions
+110 -45
View File
@@ -1,19 +1,20 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
import { normalizeTime } from "@/lib/time";
import type {
AIUsageStats,
AnalyticsQueryParams,
EcosystemWalletStats,
EngineCloudStats,
InAppWalletStats,
TransactionStats,
UniversalBridgeStats,
UniversalBridgeWalletStats,
UserOpStats,
WalletStats,
WalletStatsWithName,
WebhookLatencyStats,
WebhookSummaryStats,
X402QueryParams,
@@ -148,13 +149,7 @@ const cached_getWalletConnections = unstable_cache(
}
const json = await res.json();
return (json.data as WalletStats[]).filter(
(w) =>
w.walletType !== "smart" &&
w.walletType !== "smartWallet" &&
w.walletType !== "inApp" &&
w.walletType !== "inAppWallet",
);
return json.data as WalletStats[];
},
["getWalletConnections"],
{
@@ -162,11 +157,116 @@ const cached_getWalletConnections = unstable_cache(
},
);
export function getWalletConnections(
const cache_getEOAWalletConnections = unstable_cache(
async (
params: AnalyticsQueryParams,
authToken: string,
): Promise<WalletStatsWithName[]> => {
const result = await cached_getWalletConnections(
normalizedParams(params),
authToken,
);
const filteredResult = result.filter(
(w) =>
w.walletType !== "smart" &&
w.walletType !== "smartWallet" &&
w.walletType !== "inApp" &&
w.walletType !== "inAppWallet",
);
const modifiedResult = await Promise.all(
filteredResult.map(async (w) => {
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => undefined,
);
if (!wallet) {
return {
...w,
walletName: w.walletType,
};
}
return {
...w,
walletName: wallet.name,
};
}),
);
return modifiedResult;
},
["getEOAWalletConnections"],
{
revalidate: 60 * 60, // 1 hour
},
);
export async function getEOAWalletConnections(
params: AnalyticsQueryParams,
authToken: string,
) {
return cached_getWalletConnections(normalizedParams(params), authToken);
return cache_getEOAWalletConnections(normalizedParams(params), authToken);
}
const cache_getEOAAndInAppWalletConnections = unstable_cache(
async (
params: AnalyticsQueryParams,
authToken: string,
): Promise<WalletStatsWithName[]> => {
const result = await cached_getWalletConnections(
normalizedParams(params),
authToken,
);
const modifiedResult = await Promise.all(
result.map(async (w) => {
if (
w.walletType === "inApp" ||
w.walletType === "inAppWallet" ||
w.walletType === "smart" ||
w.walletType === "smartWallet"
) {
return {
...w,
walletName: "User wallet",
};
}
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => undefined,
);
if (!wallet) {
return {
...w,
walletName: w.walletType,
};
}
return {
...w,
walletName: wallet.name,
};
}),
);
return modifiedResult;
},
["getEOAAndInAppWalletConnections"],
{
revalidate: 60 * 60, // 1 hour
},
);
export async function getEOAAndInAppWalletConnections(
params: AnalyticsQueryParams,
authToken: string,
): Promise<WalletStatsWithName[]> {
return cache_getEOAAndInAppWalletConnections(
normalizedParams(params),
authToken,
);
}
const cached_getInAppWalletUsage = unstable_cache(
@@ -691,41 +791,6 @@ export function getUniversalBridgeWalletUsage(
);
}
const cached_getEngineCloudMethodUsage = unstable_cache(
async (
params: AnalyticsQueryParams,
authToken: string,
): Promise<EngineCloudStats[]> => {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics({
authToken,
url: `v2/engine-cloud/requests?${searchParams.toString()}`,
init: {
method: "GET",
},
});
if (res?.status !== 200) {
console.error("Failed to fetch Engine Cloud method usage");
return [];
}
const json = await res.json();
return json.data as EngineCloudStats[];
},
["getEngineCloudMethodUsage"],
{
revalidate: 60 * 60, // 1 hour
},
);
export function getEngineCloudMethodUsage(
params: AnalyticsQueryParams,
authToken: string,
) {
return cached_getEngineCloudMethodUsage(normalizedParams(params), authToken);
}
const _cached_getWebhookSummary = unstable_cache(
async (
params: AnalyticsQueryParams & { webhookId: string },
+4 -1
View File
@@ -8,7 +8,10 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import { getMemberByAccountId } from "./team-members";
export type Team = TeamResponse & { stripeCustomerId: string | null };
export type Team = TeamResponse & {
stripeCustomerId: string | null;
isLegacyPlan: boolean;
};
export async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
@@ -1,4 +1,5 @@
"use client";
import { XIcon } from "lucide-react";
import { useId, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
@@ -33,8 +34,15 @@ export function EmptyChartState({
return (
<div className="relative z-0 h-full w-full">
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center font-medium text-base text-muted-foreground">
{children ?? "No data available"}
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
{children ?? (
<div className="flex items-center gap-3 flex-col">
<div className="rounded-full border p-2 bg-background">
<XIcon className="size-4" />
</div>
<p className="text-base"> No data available </p>
</div>
)}
</div>
<SkeletonBarChart data={barChartData} type={type} />
</div>
@@ -61,7 +69,7 @@ function SkeletonBarChart(props: {
const fillAreaSkeletonId = useId();
return (
<ChartContainer
className="pointer-events-none h-full w-full blur-[5px]"
className="pointer-events-none h-full w-full blur-[5px] aspect-auto"
config={skeletonChartConfig}
>
{props.type === "bar" ? (
@@ -34,7 +34,8 @@ export function ExportToCSVButton(props: {
return (
<Button
className={cn("flex items-center gap-2 border text-xs", props.className)}
className={cn("gap-2 rounded-full", props.className)}
size="sm"
disabled={props.disabled || exportMutation.isPending}
onClick={async () => {
exportMutation.mutate();
@@ -42,16 +43,11 @@ export function ExportToCSVButton(props: {
variant="outline"
>
{exportMutation.isPending ? (
<>
Downloading
<Spinner className="size-3" />
</>
<Spinner className="size-3.5 text-muted-foreground" />
) : (
<>
<DownloadIcon className="size-3" />
Export as CSV
</>
<DownloadIcon className="size-3.5 text-muted-foreground" />
)}
Export
</Button>
);
}
@@ -70,6 +70,7 @@ function Variants() {
key={currentPlan}
requiredPlan={requiredPlan}
teamSlug="foo"
isLegacyPlan={false}
/>
</BadgeContainer>
))}
@@ -14,6 +14,7 @@ type GatedSwitchProps = {
trackingLabel?: string;
currentPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
teamSlug: string;
switchProps?: SwitchProps;
};
@@ -33,7 +34,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
<div className="w-full min-w-[280px]">
<h3 className="font-medium text-base">
<span className="capitalize">
{getTeamPlanBadgeLabel(props.requiredPlan)}+
{getTeamPlanBadgeLabel(props.requiredPlan, false)}+
</span>{" "}
plan required
</h3>
@@ -48,7 +49,8 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
rel="noopener noreferrer"
target="_blank"
>
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan)} plan
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan, false)}{" "}
plan
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
@@ -61,6 +63,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
{isUpgradeRequired && (
<TeamPlanBadge
plan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
postfix="+"
teamSlug={props.teamSlug}
/>
@@ -23,28 +23,42 @@ const teamPlanToBadgeVariant: Record<
starter: "warning",
};
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
export function getTeamPlanBadgeLabel(
plan: Team["billingPlan"],
isLegacyPlan: boolean,
) {
if (plan === "growth_legacy") {
return "Growth - Legacy";
}
if (isLegacyPlan) {
return `${plan} - Legacy`;
}
return plan;
}
export function TeamPlanBadge(props: {
teamSlug: string;
plan: Team["billingPlan"];
isLegacyPlan: boolean;
className?: string;
postfix?: string;
}) {
const router = useDashboardRouter();
function handleNavigateToBilling(e: React.MouseEvent | React.KeyboardEvent) {
e.stopPropagation();
e.preventDefault();
if (props.isLegacyPlan) {
router.push(`/team/${props.teamSlug}/~/billing`);
return;
}
if (props.plan !== "free") {
return;
}
e.stopPropagation();
e.preventDefault();
router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
}
@@ -57,11 +71,13 @@ export function TeamPlanBadge(props: {
handleNavigateToBilling(e);
}
}}
role={props.plan === "free" ? "button" : undefined}
tabIndex={props.plan === "free" ? 0 : undefined}
variant={teamPlanToBadgeVariant[props.plan]}
role={props.plan === "free" || props.isLegacyPlan ? "button" : undefined}
tabIndex={props.plan === "free" || props.isLegacyPlan ? 0 : undefined}
variant={
props.isLegacyPlan ? "warning" : teamPlanToBadgeVariant[props.plan]
}
>
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
{`${getTeamPlanBadgeLabel(props.plan, props.isLegacyPlan)}${props.postfix || ""}`}
</Badge>
);
}
@@ -40,7 +40,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
yAxis?: boolean;
xAxis?: {
sameDay?: boolean;
showHour?: boolean;
};
variant?: "stacked" | "individual";
@@ -112,7 +112,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
tickFormatter={(value) =>
format(
new Date(value),
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
)
}
tickLine={false}
@@ -1,12 +1,11 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import type { InAppWalletAuth } from "thirdweb/wallets";
import { BadgeContainer } from "@/storybook/utils";
import type { InAppWalletStats } from "@/types/analytics";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
import { AutoMergeBarChart, type StatData } from "./automerge-barchart";
const meta = {
component: Component,
title: "Charts/InAppWallets",
title: "Charts/AutoMergeBarChart",
} satisfies Meta<typeof Component>;
export default meta;
@@ -22,57 +21,106 @@ function Component() {
"This is an example of a description about user wallet usage chart";
return (
<div className="container flex max-w-6xl flex-col gap-10 py-10">
<BadgeContainer label="30 days">
<InAppWalletUsersChartCardUI
<BadgeContainer label="10 days, view more variant">
<AutoMergeBarChart
viewMoreLink={"#"}
description={description}
inAppWalletStats={createInAppWalletStatsStub(30)}
stats={createStatsStub(10)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={undefined}
/>
</BadgeContainer>
<BadgeContainer label="30 days">
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
stats={createStatsStub(30)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="60 days">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(60)}
stats={createStatsStub(60)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="120 days">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(120)}
stats={createStatsStub(120)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(10)}
stats={createStatsStub(10)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="Empty List">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={createInAppWalletStatsStub(0)}
stats={createStatsStub(0)}
isPending={false}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
<BadgeContainer label="Pending">
<InAppWalletUsersChartCardUI
<AutoMergeBarChart
viewMoreLink={undefined}
description={description}
inAppWalletStats={[]}
stats={[]}
isPending={true}
title={title}
maxLabelsToShow={5}
emptyChartState={undefined}
exportButton={{
fileName: "in-app-wallets",
}}
/>
</BadgeContainer>
</div>
@@ -111,17 +159,16 @@ const pickRandomAuthMethod = () => {
return capitalized;
};
function createInAppWalletStatsStub(days: number): InAppWalletStats[] {
const stubbedData: InAppWalletStats[] = [];
function createStatsStub(days: number): StatData[] {
const stubbedData: StatData[] = [];
let d = days;
while (d !== 0) {
const uniqueWallets = Math.floor(Math.random() * 100);
stubbedData.push({
authenticationMethod: pickRandomAuthMethod(),
label: pickRandomAuthMethod(),
date: new Date(2024, 11, d).toLocaleString(),
uniqueWalletsConnected: uniqueWallets,
newUsers: Math.floor(Math.random() * 100) + 1,
count: uniqueWallets,
});
if (Math.random() > 0.7) {
@@ -0,0 +1,173 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import type { ChartConfig } from "@/components/ui/chart";
import { TotalValueChartHeader } from "./chart-header";
export type StatData = {
date: string;
count: number;
label: string;
};
type ChartData = Record<string, number> & {
time: string; // human readable date
};
const defaultLabel = "Unknown";
// merges the multiple stats in single bar if they share the same date
// shows the top `maxLabelsToShow` labels, merge the rest into "Others"
export function AutoMergeBarChart(props: {
stats: StatData[];
isPending: boolean;
title: string;
emptyChartState: React.ReactElement | undefined;
description: string | undefined;
maxLabelsToShow: number;
exportButton:
| {
fileName: string;
}
| undefined;
viewMoreLink: string | undefined;
}) {
const { stats, maxLabelsToShow } = props;
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const labelToCountMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of stats) {
const chartData = _chartDataMap.get(stat.date);
// if no data for current day - create new entry
if (!chartData && stat.count > 0) {
_chartDataMap.set(stat.date, {
time: stat.date,
[stat.label || defaultLabel]: stat.count,
} as ChartData);
} else if (chartData) {
chartData[stat.label || defaultLabel] =
(chartData[stat.label || defaultLabel] || 0) + stat.count;
}
labelToCountMap.set(
stat.label || defaultLabel,
stat.count + (labelToCountMap.get(stat.label || defaultLabel) || 0),
);
}
const labelsSorted = Array.from(labelToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const labelsToShow = labelsSorted.slice(0, maxLabelsToShow);
const labelsToShowAsOther = labelsSorted.slice(maxLabelsToShow);
// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const dataKey in data) {
if (labelsToShowAsOther.includes(dataKey)) {
data.others = (data.others || 0) + (data[dataKey] || 0);
delete data[dataKey];
}
}
}
labelsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
color: `hsl(var(--chart-${(i % 10) + 1}))`,
label: labelsToShow[i],
};
});
// Add Other
if (labelsToShow.length >= maxLabelsToShow) {
labelsToShow.push("others");
_chartConfig.others = {
color: "hsl(var(--muted-foreground))",
label: "Others",
};
}
return {
chartConfig: _chartConfig,
chartData: Array.from(_chartDataMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
),
};
}, [stats, maxLabelsToShow]);
const uniqueLabels = Object.keys(chartConfig);
const total = useMemo(() => {
return props.stats.reduce((acc, stat) => acc + stat.count, 0);
}, [props.stats]);
return (
<ThirdwebBarChart
chartClassName="h-[275px] w-full"
config={chartConfig}
customHeader={
props.viewMoreLink ? (
<TotalValueChartHeader
total={total}
title={props.title}
isPending={props.isPending}
viewMoreLink={props.viewMoreLink}
/>
) : (
<div className="relative px-6 pt-6">
<div className="space-y-1">
<h3 className="font-semibold text-lg tracking-tight leading-none">
{props.title}
</h3>
{props.description && (
<p className="text-muted-foreground text-sm">
{props.description}
</p>
)}
</div>
{props.exportButton && props.stats.length > 0 && (
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
fileName={props.exportButton.fileName}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Time", ...uniqueLabels];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
new Date(time).toISOString(),
...uniqueLabels.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
)}
</div>
)
}
data={chartData}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -44,6 +44,10 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
hideLabel?: boolean;
emptyChartState?: React.ReactElement;
className?: string;
xAxis?: {
showHour?: boolean;
};
};
export function ThirdwebBarChart<TConfig extends ChartConfig>(
@@ -55,7 +59,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
return (
<Card>
<Card className={cn("overflow-hidden", props.className)}>
{props.header && (
<CardHeader>
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
@@ -83,7 +87,12 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
<XAxis
axisLine={false}
dataKey="time"
tickFormatter={(value) => format(new Date(value), "MMM d")}
tickFormatter={(value) =>
format(
new Date(value),
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
)
}
tickLine={false}
tickMargin={10}
/>
@@ -0,0 +1,50 @@
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;
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">
{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,
});
@@ -22,6 +22,7 @@ interface UpsellWrapperProps {
isLocked?: boolean;
requiredPlan: Team["billingPlan"];
currentPlan?: Team["billingPlan"];
isLegacyPlan?: boolean;
featureName: string;
featureDescription: string;
benefits?: {
@@ -41,6 +42,7 @@ export function UpsellWrapper({
featureDescription,
benefits = [],
className,
isLegacyPlan = false,
}: UpsellWrapperProps) {
if (!isLocked) {
return <>{children}</>;
@@ -66,6 +68,7 @@ export function UpsellWrapper({
featureDescription={featureDescription}
featureName={featureName}
requiredPlan={requiredPlan}
isLegacyPlan={isLegacyPlan}
teamSlug={teamSlug}
/>
</div>
@@ -79,6 +82,7 @@ export function UpsellContent(props: {
featureDescription: string;
requiredPlan: Team["billingPlan"];
currentPlan: Team["billingPlan"];
isLegacyPlan: boolean;
benefits?: {
description: string;
status: "available" | "soon";
@@ -96,6 +100,7 @@ export function UpsellContent(props: {
plan={props.requiredPlan}
postfix=" Feature"
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
<div className="space-y-1">
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
@@ -69,7 +69,7 @@ export default function Component() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}},
);
@@ -124,7 +124,7 @@ export default function Component() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}}
);
@@ -214,7 +214,7 @@ function App() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}}
);`,
dotnet: `using Thirdweb;
@@ -272,7 +272,7 @@ export default function Component() {
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
address: "{{contract_address}}",
chainId: {{chainId}}
);
@@ -1103,7 +1103,7 @@ function AccountFactorySection(props: {
<AlertDescription>
The recommended way to use account factories is to integrate the{" "}
<UnderlineLink
href="https://portal.thirdweb.com/transactions/sponsor"
href="https://portal.thirdweb.com/wallets/sponsor-gas"
rel="noopener noreferrer"
target="_blank"
>
@@ -64,7 +64,7 @@ const truncateIdentifier = (value: string) => {
return `${value.slice(0, 8)}...${value.slice(-4)}`;
};
export function InAppWalletUsersPageContent(
export function UserWalletsTable(
props: {
authToken: string;
client: ThirdwebClient;
@@ -288,83 +288,81 @@ export function InAppWalletUsersPageContent(
]);
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<UserIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
User Wallets
</h2>
<p className="text-muted-foreground text-sm">
View and manage your project's users
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="w-full lg:w-auto lg:min-w-[320px]">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2 bg-background rounded-full"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="User wallets"
/>
<div className="flex justify-center gap-3 border-t bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</>
)}
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<UserIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
User Wallets
</h2>
<p className="text-muted-foreground text-sm">
View and manage your project's users
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="w-full lg:w-auto lg:min-w-[320px]">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2 bg-background rounded-full"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
</div>
<div>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="User wallets"
/>
<div className="flex justify-center gap-3 border-t bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
);
+1
View File
@@ -30,6 +30,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
billingPlan: billingPlan,
billingStatus: "validPayment",
canCreatePublicChains: null,
isLegacyPlan: false,
capabilities: {
bundler: {
enabled: true,
+4 -7
View File
@@ -7,6 +7,10 @@ export interface WalletStats {
walletType: string;
}
export type WalletStatsWithName = WalletStats & {
walletName: string;
};
export interface InAppWalletStats {
date: string;
authenticationMethod: string;
@@ -35,13 +39,6 @@ export interface TransactionStats {
count: number;
}
export interface EngineCloudStats {
date: string;
chainId: string;
pathname: string;
totalRequests: number;
}
export interface UniversalBridgeStats {
date: string;
chainId: number;
@@ -22,7 +22,7 @@ export function AccountsPage(props: {
View list of smart accounts that have been created for this
contract.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/transactions/sponsor"
href="https://portal.thirdweb.com/wallets/sponsor-gas"
target="_blank"
rel="noopener noreferrer"
>
@@ -18,7 +18,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { SearchInput } from "../components/SearchInput";
export function AccountTeamsUI(props: {
@@ -117,8 +116,6 @@ function TeamRow(props: {
role: TeamAccountRole;
client: ThirdwebClient;
}) {
const plan = getValidTeamPlan(props.team);
return (
<div className="flex items-center justify-between gap-2">
{/* start */}
@@ -133,7 +130,11 @@ function TeamRow(props: {
<div>
<div className="flex items-center gap-3">
<p className="font-semibold text-sm">{props.team.name}</p>
<TeamPlanBadge plan={plan} teamSlug={props.team.slug} />
<TeamPlanBadge
plan={props.team.billingPlan}
teamSlug={props.team.slug}
isLegacyPlan={props.team.isLegacyPlan}
/>
</div>
<p className="text-muted-foreground text-sm capitalize">
{props.role.toLowerCase()}
@@ -6,13 +6,12 @@ import {
} from "responsive-rsc";
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import {
getClientTransactions,
getEOAWalletConnections,
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
} from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team/get-team";
@@ -26,7 +25,7 @@ import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UserOpStats,
WalletStats,
WalletStatsWithName,
} from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
@@ -240,7 +239,7 @@ async function AsyncWalletDistributionCard(props: {
range: Range;
authToken: string;
}) {
const walletConnections = await getWalletConnections(
const walletConnections = await getEOAWalletConnections(
{
from: props.range.from,
period: "all",
@@ -373,29 +372,19 @@ async function AsyncTotalSponsoredCard(props: {
) : (
<EmptyStateCard
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
metric="Account Abstraction"
metric="Gas Sponsorship"
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data.map(async (w) => {
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
() => ({ name: w.walletType }),
);
return {
totalConnections: w.totalConnections,
uniqueWalletsConnected: w.uniqueWalletsConnected,
walletName: wallet.name,
walletType: w.walletType,
};
}),
);
async function WalletDistributionCard({
data,
}: {
data: WalletStatsWithName[];
}) {
return (
<PieChartCard
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
data={data.map(({ walletName, uniqueWalletsConnected }) => {
return {
label: walletName,
value: uniqueWalletsConnected,
@@ -73,21 +73,24 @@ export function PlanInfoCardUI(props: {
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{validPlan === "growth_legacy" ? "Growth" : validPlan} Plan
</h3>
{validPlan.includes("legacy") && (
{props.team.isLegacyPlan && (
<Badge variant="warning">Legacy</Badge>
)}
{trialEndsInFuture && <Badge variant="default">Trial</Badge>}
</div>
{validPlan.includes("legacy") && (
{props.team.isLegacyPlan && (
<p className="text-sm text-yellow-600">
You are on the legacy plan. You may save by upgrading to new
plan.{" "}
Legacy plans will be upgraded to new plans automatically on
January 1st, 2026.
<br />
<UnderlineLink
className="decoration-yellow-600/50"
href="/pricing"
href="https://blog.thirdweb.com/retiring-legacy-pricing-plans-on-january-1st-2026"
rel="noopener noreferrer"
target="_blank"
>
Learn More
Learn more about changes to legacy plans
</UnderlineLink>
</p>
)}
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/team/ecosystems";
import { getTeamBySlug } from "@/api/team/get-team";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
@@ -30,7 +30,7 @@ export default async function EcosystemUsersPage(props: {
});
return (
<InAppWalletUsersPageContent
<UserWalletsTable
authToken={authToken}
client={client}
ecosystemSlug={ecosystem.slug}
@@ -28,6 +28,7 @@ export default async function Page(props: {
<div className="flex flex-col grow justify-center items-center">
<UpsellContent
currentPlan={team.billingPlan}
isLegacyPlan={team.isLegacyPlan}
featureDescription="Create support cases for your projects"
featureName="Support Center"
requiredPlan="starter"
@@ -1,20 +1,29 @@
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getUserOpUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProjects } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { getTeamSubscriptions } from "@/api/team/team-subscription";
import { SponsoredTransactionsTable } from "@/components/sponsored-transactions-table/SponsoredTransactionsTable";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { AccountAbstractionSummary } from "../../../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { AccountAbstractionAnalytics } from "../../../../[project_slug]/(sidebar)/account-abstraction/aa-analytics";
import { searchParamLoader } from "../../../../[project_slug]/(sidebar)/account-abstraction/search-params";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<SearchParams>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
const [params, parsedSearchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
@@ -22,6 +31,8 @@ export default async function Page(props: {
loginRedirect(`/team/${params.team_slug}/~/usage/account-abstraction`);
}
const team = await getTeamBySlug(params.team_slug);
if (!team) {
redirect("/team");
}
@@ -44,20 +55,51 @@ export default async function Page(props: {
);
}
const interval = parsedSearchParams.interval ?? "week";
const rangeType = parsedSearchParams.range || "last-120";
const range: Range = {
from:
rangeType === "custom"
? parsedSearchParams.from
: getLastNDaysRange(rangeType).from,
to:
rangeType === "custom"
? parsedSearchParams.to
: getLastNDaysRange(rangeType).to,
type: rangeType,
};
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
teamId: team.id,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<div className="flex flex-col gap-14">
<SponsoredTransactionsTable
<div className="flex flex-col gap-10">
<AccountAbstractionSummary
authToken={authToken}
teamId={team.id}
from={range.from}
to={range.to}
/>
<AccountAbstractionAnalytics
client={client}
from={usageSubscription.currentPeriodStart}
projects={projects}
teamId={team.id}
teamSlug={team.slug}
to={usageSubscription.currentPeriodEnd}
userOpStats={userOpStats}
variant="team"
/>
</div>
@@ -13,7 +13,7 @@ export default async function Layout(props: {
return (
<div className="flex grow flex-col">
<div className="border-border pt-10 pb-4">
<div className="border-border pt-10 pb-6">
<div className="container max-w-7xl flex flex-col gap-4 lg:flex-row lg:justify-between">
<h1 className="font-semibold text-3xl tracking-tight">Usage</h1>
<div className="items-center gap-3 hidden lg:flex">
@@ -47,7 +47,7 @@ export default async function Layout(props: {
path: `${usagePath}/storage`,
},
{
name: "Account Abstraction",
name: "Gas Sponsorship",
path: `${usagePath}/account-abstraction`,
},
]}
@@ -43,7 +43,8 @@ export default async function Page(props: {
<div className="grow flex flex-col justify-center items-center">
<UpsellContent
currentPlan={team.billingPlan}
featureDescription="View RPC, Wallet, Storage, Account Abstraction, Engine Cloud, Webhooks usage and more"
isLegacyPlan={team.isLegacyPlan}
featureDescription="View RPC, Wallet, Storage, Gas Sponsorship, Engine Cloud, Webhooks usage and more"
featureName="Usage"
requiredPlan="starter"
teamSlug={params.team_slug}
@@ -57,7 +57,7 @@ export function CountGraph(props: {
return format(label, "MMM dd, HH:mm");
}}
xAxis={{
sameDay: true,
showHour: true,
}}
yAxis
/>
@@ -57,7 +57,7 @@ export function RateGraph(props: {
return format(label, "MMM dd, HH:mm");
}}
xAxis={{
sameDay: true,
showHour: true,
}}
yAxis
/>
@@ -33,7 +33,6 @@ export default async function RPCUsage(props: {
redirect("/team");
}
const currentPlan = team.billingPlan;
const currentRateLimit = team.capabilities.rpc.rateLimit;
const apiData = await getLast24HoursRPCUsage({
@@ -102,7 +101,11 @@ export default async function RPCUsage(props: {
<div className="font-bold text-2xl capitalize">
{currentRateLimit.toLocaleString()} RPS
</div>
<TeamPlanBadge plan={currentPlan} teamSlug={team.slug} />
<TeamPlanBadge
plan={team.billingPlan}
teamSlug={team.slug}
isLegacyPlan={team.isLegacyPlan}
/>
</div>
</CardContent>
</Card>
@@ -34,13 +34,17 @@ function AccountAbstractionSummaryInner(props: {
async function AsyncAccountAbstractionSummary(props: {
teamId: string;
projectId: string;
projectId?: string;
authToken: string;
from?: Date;
to?: Date;
}) {
const aggregateUserOpStats = await getAggregateUserOpUsage(
{
projectId: props.projectId,
teamId: props.teamId,
from: props.from,
to: props.to,
},
props.authToken,
);
@@ -55,8 +59,10 @@ async function AsyncAccountAbstractionSummary(props: {
export function AccountAbstractionSummary(props: {
teamId: string;
projectId: string;
projectId?: string;
authToken: string;
from?: Date;
to?: Date;
}) {
return (
<Suspense
@@ -71,6 +77,8 @@ export function AccountAbstractionSummary(props: {
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
from={props.from}
to={props.to}
/>
</Suspense>
);
@@ -15,13 +15,30 @@ import { GasMetricsChartCard } from "./AccountAbstractionAnalytics/GasMetricsCha
import { TotalSponsoredChartCard } from "./AccountAbstractionAnalytics/TotalSponsoredChartCard";
import { searchParams } from "./search-params";
export function AccountAbstractionAnalytics(props: {
type AccountAbstractionAnalyticsProps = {
userOpStats: UserOpStats[];
teamId: string;
teamSlug: string;
client: ThirdwebClient;
projectId: string;
}) {
} & (
| {
variant: "project";
projectId: string;
}
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
}
);
export function AccountAbstractionAnalytics(
props: AccountAbstractionAnalyticsProps,
) {
const [isLoading, startTransition] = useTransition();
const [rangeType, setRangeType] = useQueryState(
@@ -62,6 +79,8 @@ export function AccountAbstractionAnalytics(props: {
to: rangeType === "custom" ? to : getLastNDaysRange(rangeType).to,
type: rangeType,
};
const resolvedFrom = range.from;
const resolvedTo = range.to;
return (
<div>
@@ -105,15 +124,18 @@ export function AccountAbstractionAnalytics(props: {
userOpStats={props.userOpStats}
/>
<SponsoredTransactionsTable
client={props.client}
from={from.toISOString()}
projectId={props.projectId}
teamId={props.teamId}
teamSlug={props.teamSlug}
to={to.toISOString()}
variant="project"
/>
{resolvedFrom && resolvedTo && (
<SponsoredTransactionsTable
client={props.client}
from={resolvedFrom.toISOString()}
teamId={props.teamId}
teamSlug={props.teamSlug}
to={resolvedTo.toISOString()}
{...(props.variant === "project"
? { projectId: props.projectId, variant: "project" as const }
: { projects: props.projects, variant: "team" as const })}
/>
)}
</div>
</div>
);
@@ -44,6 +44,7 @@ type AccountAbstractionSettingsPageProps = {
teamId: string;
teamSlug: string;
validTeamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
client: ThirdwebClient;
};
@@ -565,6 +566,7 @@ export function AccountAbstractionSettingsPage(
<GatedSwitch
currentPlan={props.validTeamPlan}
requiredPlan="starter"
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: field.value.enabled,
id: "server-verifier-switch",
@@ -1,10 +1,12 @@
"use client";
import { format } from "date-fns";
import { BotIcon } from "lucide-react";
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { DocLink } from "@/components/blocks/DocLink";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import { Button } from "@/components/ui/button";
import type { ChartConfig } from "@/components/ui/chart";
import type { AIUsageStats } from "@/types/analytics";
@@ -17,7 +19,7 @@ export function AiTokenUsageChartCardUI(props: {
aiUsageStats: AIUsageStats[];
isPending: boolean;
title: string;
description: string;
viewMoreLink: string | undefined;
}) {
const { aiUsageStats } = props;
@@ -52,33 +54,43 @@ export function AiTokenUsageChartCardUI(props: {
chartData.length === 0 ||
chartData.every((data) => data.tokens === 0);
const total = useMemo(() => {
return chartData.reduce((acc, curr) => acc + curr.tokens, 0);
}, [chartData]);
return (
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
chartClassName="h-[275px] w-full aspect-auto"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
disabled={disableActions}
fileName="AI Token Usage"
getData={async () => {
const header = ["Date", "Tokens"];
const rows = chartData.map((data) => [
data.time,
data.tokens.toString(),
]);
return { header, rows };
}}
props.viewMoreLink ? (
<TotalValueChartHeader
isPending={props.isPending}
total={total}
title={props.title}
viewMoreLink={props.viewMoreLink}
/>
</div>
) : (
<div className="relative px-6 pt-6">
<h3 className="font-semibold text-xl tracking-tight">
{props.title}
</h3>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
disabled={disableActions}
fileName="AI Token Usage"
getData={async () => {
const header = ["Date", "Tokens"];
const rows = chartData.map((data) => [
data.time,
data.tokens.toString(),
]);
return { header, rows };
}}
/>
</div>
)
}
data={chartData}
emptyChartState={<AiTokenUsageEmptyChartState />}
@@ -100,17 +112,24 @@ export function AiTokenUsageChartCardUI(props: {
function AiTokenUsageEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
No data available
</h3>
<p className="mb-6 text-center text-sm">
Integrate thirdweb AI to interact with any EVM chain using natural
language
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
icon={BotIcon}
label="Get Started"
link="https://portal.thirdweb.com/ai/chat"
/>
</div>
</p>
<Button
asChild
size="sm"
variant="default"
className="rounded-full gap-2"
>
<Link href="https://portal.thirdweb.com/ai/chat" target="_blank">
Get Started
<ArrowUpRightIcon className="size-4" />
</Link>
</Button>
</div>
);
}
@@ -18,9 +18,9 @@ function AiAnalyticsUI({ stats, isPending }: AiAnalyticsProps) {
return (
<AiTokenUsageChartCardUI
title="Token Usage"
description="The total number of tokens used for AI interactions on your project."
aiUsageStats={stats || []}
isPending={isPending}
viewMoreLink={undefined}
/>
);
}
@@ -1,4 +1,4 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getSessionById, getSessions } from "../../api/session";
@@ -39,7 +39,7 @@ export default async function Page(props: {
]);
if (!session) {
notFound();
redirect(`/team/${params.team_slug}/${params.project_slug}/ai`);
}
return (
@@ -410,7 +410,7 @@ export function ChatBar(props: {
if (message.trim() === "" && images.length === 0) return;
handleSubmit(message);
}}
variant="primary"
variant="default"
>
<ArrowUpIcon className="size-4" />
</Button>
@@ -1,7 +1,6 @@
"use client";
import {
BotIcon,
ChartLineIcon,
ChevronDownIcon,
ChevronRightIcon,
FileCode2Icon,
@@ -83,16 +82,6 @@ export function ChatSidebar(props: {
<div className="h-5" />
<div className="flex flex-col gap-3 border-t border-dashed px-4 py-4">
<Link
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
href={`/team/${props.team_slug}/${props.project.slug}/ai/analytics`}
>
<div className="flex items-center gap-2">
<ChartLineIcon className="size-3.5 text-muted-foreground" />
Analytics
</div>
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
</Link>
<Link
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
href={"https://portal.thirdweb.com/ai/chat"}
@@ -1,56 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "@/storybook/utils";
import type { EngineCloudStats } from "@/types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
const meta = {
component: Component,
title: "Analytics/EngineCloudBarChartCard",
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
parameters: {
viewport: { defaultViewport: "desktop" },
},
};
const generateTimeSeriesData = (days: number, pathnames: string[]) => {
const data: EngineCloudStats[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0];
for (const pathname of pathnames) {
data.push({
chainId: "84532",
// biome-ignore lint/style/noNonNullAssertion: we know this is not null
date: dateStr!,
pathname,
totalRequests: Math.floor(Math.random() * 1000) + 100,
});
}
}
return data;
};
function Component() {
return (
<div className="container max-w-6xl space-y-10 py-10">
<BadgeContainer label="Multiple Pathnames">
<EngineCloudBarChartCardUI
rawData={generateTimeSeriesData(30, [
"/v1/write/transaction",
"/v1/sign/transaction",
"/v1/sign/message",
])}
/>
</BadgeContainer>
</div>
);
}
@@ -1,146 +0,0 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
BarChart as RechartsBarChart,
XAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { EngineCloudStats } from "@/types/analytics";
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
export function EngineCloudBarChartCardUI({
rawData,
}: {
rawData: EngineCloudStats[];
}) {
const { data, pathnames, chartConfig, isAllEmpty } = useMemo(() => {
// Dynamically collect all unique pathnames
const pathnameSet = new Set<string>();
for (const item of rawData) {
// Ignore empty pathname ''.
if (item.pathname) {
pathnameSet.add(item.pathname);
}
}
const pathnames = Array.from(pathnameSet);
// Group by date, then by pathname
const dateMap = new Map<string, Record<string, number>>();
for (const { date, pathname, totalRequests } of rawData) {
const map = dateMap.get(date) ?? {};
map[pathname] = Number(totalRequests) || 0;
dateMap.set(date, map);
}
// Build data array for recharts
const data = Array.from(dateMap.entries()).map(([date, value]) => {
let total = 0;
for (const pathname of pathnames) {
if (!value[pathname]) value[pathname] = 0;
total += value[pathname];
}
return { date, ...value, total };
});
// Chart config
const chartConfig: ChartConfig = {};
for (const pathname of pathnames) {
chartConfig[pathname] = { label: pathname };
}
return {
chartConfig,
data,
isAllEmpty: data.every((d) => d.total === 0),
pathnames,
};
}, [rawData]);
if (data.length === 0 || isAllEmpty) {
return (
<EmptyStateCard
link="https://portal.thirdweb.com/transactions"
metric="Transactions"
/>
);
}
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
<CardTitle className="font-semibold text-lg">
Engine Cloud Requests
</CardTitle>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<ChartContainer
className="aspect-auto h-[250px] w-full pt-6"
config={chartConfig}
>
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
});
}}
tickLine={false}
tickMargin={8}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(d) => format(new Date(d), "MMM d")}
valueFormatter={(_value) => {
const value = typeof _value === "number" ? _value : 0;
return <span className="inline-flex gap-1.5">{value}</span>;
}}
/>
}
/>
{pathnames.map((pathname, idx) => (
<Bar
className="stroke-background"
dataKey={pathname}
fill={`hsl(var(--chart-${idx + 1}))`}
key={pathname}
radius={[
idx === pathnames.length - 1 ? 4 : 0,
idx === pathnames.length - 1 ? 4 : 0,
idx === 0 ? 4 : 0,
idx === 0 ? 4 : 0,
]}
stackId="a"
strokeWidth={1}
/>
))}
</RechartsBarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -1,14 +0,0 @@
import { getEngineCloudMethodUsage } from "@/api/analytics";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
export async function EngineCloudChartCardAsync(props: {
params: AnalyticsQueryParams;
authToken: string;
}) {
const rawData = await getEngineCloudMethodUsage(
props.params,
props.authToken,
);
return <EngineCloudBarChartCardUI rawData={rawData} />;
}
@@ -83,9 +83,26 @@ export function ProjectSidebarLayout(props: {
label: "Tokens",
},
{
href: `${props.layoutPath}/ai`,
icon: BotIcon,
label: "AI",
subMenu: {
icon: BotIcon,
label: "AI",
},
links: [
{
href: `${props.layoutPath}/ai`,
label: "Chat",
isActive: (pathname) => {
return (
pathname === `${props.layoutPath}/ai` ||
pathname.startsWith(`${props.layoutPath}/ai/chat`)
);
},
},
{
href: `${props.layoutPath}/ai/analytics`,
label: "Analytics",
},
],
},
{
subMenu: {
@@ -109,14 +109,14 @@ export function RpcMethodBarChartCardUI({
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
<CardHeader className="flex flex-col items-stretch space-y-0 p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6 pb-0">
<CardTitle className="font-semibold text-lg">RPC Methods</CardTitle>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<ChartContainer
className="aspect-auto h-[250px] w-full pt-6"
className="aspect-auto h-[275px] w-full pt-6"
config={chartConfig}
>
<RechartsBarChart
@@ -20,7 +20,7 @@ export function WaitingForIntegrationCard(props: {
const [selectedTab, setSelectedTab] = useState(props.codeTabs[0]?.label);
return (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<div className="border-b px-4 py-4 lg:px-6 lg:py-5 border-dashed">
<h2 className="font-semibold text-xl tracking-tight">{props.title}</h2>
</div>
@@ -28,14 +28,19 @@ export function WaitingForIntegrationCard(props: {
{props.children}
{/* Code */}
<div>
<TabButtons
tabs={props.codeTabs.map((tab) => ({
isActive: tab.label === selectedTab,
name: tab.label,
onClick: () => setSelectedTab(tab.label),
}))}
/>
<div className="h-2" />
{props.codeTabs.length > 1 && (
<>
<TabButtons
tabs={props.codeTabs.map((tab) => ({
isActive: tab.label === selectedTab,
name: tab.label,
onClick: () => setSelectedTab(tab.label),
}))}
/>
<div className="h-2" />
</>
)}
{props.codeTabs.find((tab) => tab.label === selectedTab)?.code}
</div>
</div>
@@ -10,17 +10,24 @@ import {
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { type ThirdwebClient, toWei } from "thirdweb";
import {
getContract,
readContract,
type ThirdwebClient,
toUnits,
} from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import { isAddress } from "thirdweb/utils";
import { isAddress, shortenAddress } from "thirdweb/utils";
import { z } from "zod";
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
import type { Project } from "@/api/project/projects";
import type { TokenMetadata } from "@/api/universal-bridge/types";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { TokenSelector } from "@/components/blocks/TokenSelector";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -78,15 +85,96 @@ type ProjectWalletControlsProps = {
client: ThirdwebClient;
};
const STORAGE_KEY_PREFIX = "project-wallet-selection";
function getStorageKey(projectId: string): string {
return `${STORAGE_KEY_PREFIX}-${projectId}`;
}
type StoredSelection = {
chainId: number;
tokenAddress: string | undefined;
};
function readStoredSelection(projectId: string): StoredSelection | null {
if (typeof window === "undefined") {
return null;
}
try {
const stored = localStorage.getItem(getStorageKey(projectId));
if (stored) {
return JSON.parse(stored) as StoredSelection;
}
} catch {
// Ignore parse errors
}
return null;
}
function saveStoredSelection(projectId: string, selection: StoredSelection) {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(getStorageKey(projectId), JSON.stringify(selection));
} catch {
// Ignore storage errors
}
}
export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
const { projectWallet, project, defaultChainId } = props;
const [isSendOpen, setIsSendOpen] = useState(false);
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
// Initialize chain and token from localStorage or defaults
const [selectedChainId, setSelectedChainId] = useState(() => {
const stored = readStoredSelection(project.id);
return stored?.chainId ?? defaultChainId ?? 1;
});
const [selectedTokenAddress, setSelectedTokenAddress] = useState<
string | undefined
>(() => {
const stored = readStoredSelection(project.id);
if (stored) {
return stored.tokenAddress;
}
return undefined;
});
const chain = useV5DashboardChain(selectedChainId);
// Handle chain change - reset token to native when chain changes
const handleChainChange = useCallback(
(newChainId: number) => {
setSelectedChainId((prevChainId) => {
if (prevChainId !== newChainId) {
// Reset token to native (undefined) when chain changes
setSelectedTokenAddress(undefined);
saveStoredSelection(project.id, {
chainId: newChainId,
tokenAddress: undefined,
});
}
return newChainId;
});
},
[project.id],
);
// Handle token change
const handleTokenChange = useCallback(
(token: TokenMetadata) => {
setSelectedTokenAddress(token.address);
saveStoredSelection(project.id, {
chainId: selectedChainId,
tokenAddress: token.address,
});
},
[project.id, selectedChainId],
);
const engineCloudService = useMemo(
() => project.services?.find((service) => service.name === "engineCloud"),
[project.services],
@@ -117,6 +205,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
address: projectWallet.address,
chain,
client: props.client,
tokenAddress: selectedTokenAddress,
});
const canChangeWallet =
@@ -126,62 +215,12 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
return (
<div>
<div className="rounded-xl border border-border bg-card relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Open wallet actions"
className="p-2 h-auto w-auto absolute right-4 top-4 z-10"
variant="ghost"
>
<EllipsisVerticalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 rounded-xl">
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsSendOpen(true)}
>
<SendIcon className="size-4 text-muted-foreground" />
Send funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<WalletIcon className="size-4 text-muted-foreground" />
Receive funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
View transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<ShuffleIcon className="size-4 text-muted-foreground" />
Change project wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="p-5 relative flex flex-col gap-5">
<div>
<div className="rounded-xl border border-border bg-card">
<div className="p-5 flex flex-col lg:flex-row gap-5">
<div className="flex-1">
<p className="text-sm text-foreground mb-1">Wallet Address</p>
<CopyTextButton
textToShow={projectWallet.address}
textToShow={shortenAddress(projectWallet.address, 6)}
textToCopy={projectWallet.address}
tooltip="Copy wallet address"
copyIconPosition="right"
@@ -190,7 +229,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</div>
<div>
<div className="flex-1">
<p className="text-sm text-foreground mb-1">Wallet Label</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
@@ -200,7 +239,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
</div>
</div>
<div className="p-5 border-t border-dashed flex justify-between items-center">
<div className="p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
<div>
<p className="text-sm text-foreground mb-1">Balance</p>
<div className="flex items-center gap-1">
@@ -229,17 +268,88 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
</div>
</div>
<SingleNetworkSelector
chainId={selectedChainId}
className="w-fit rounded-full bg-background hover:bg-accent/50"
client={props.client}
disableDeprecated
disableChainId
onChange={setSelectedChainId}
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<SingleNetworkSelector
chainId={selectedChainId}
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
client={props.client}
disableDeprecated
disableChainId
onChange={handleChainChange}
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
<TokenSelector
selectedToken={
selectedTokenAddress
? { chainId: selectedChainId, address: selectedTokenAddress }
: undefined
}
onChange={handleTokenChange}
chainId={selectedChainId}
client={props.client}
enabled={true}
showCheck={true}
addNativeTokenIfMissing={true}
placeholder="Native token"
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
align="end"
/>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSendOpen(true)}
>
<SendIcon className="size-4" />
Withdraw
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="px-2 bg-background hover:bg-accent/50"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<WalletIcon className="size-4 text-muted-foreground" />
Deposit
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() =>
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
)
}
>
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
Transactions
</DropdownMenuItem>
{canChangeWallet && (
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsChangeWalletOpen(true)}
>
<ShuffleIcon className="size-4 text-muted-foreground" />
Change Wallet
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -253,6 +363,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
open={isSendOpen}
publishableKey={project.publishableKey}
teamId={project.teamId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
/>
@@ -467,6 +578,7 @@ const createSendFormSchema = (secretKeyLabel: string) =>
chainId: z.number({
required_error: "Select a network",
}),
tokenAddress: z.string().optional(),
toAddress: z
.string()
.trim()
@@ -491,6 +603,7 @@ type SendProjectWalletModalProps = {
publishableKey: string;
teamId: string;
chainId: number;
tokenAddress?: string;
label: string;
client: ReturnType<typeof getClientThirdwebClient>;
isManagedVault: boolean;
@@ -528,6 +641,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
publishableKey,
teamId,
chainId,
tokenAddress,
label,
client,
isManagedVault,
@@ -539,6 +653,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
defaultValues: {
amount: "0",
chainId,
tokenAddress,
secretKey: "",
vaultAccessToken: "",
toAddress: "",
@@ -548,10 +663,24 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
});
const selectedChain = useV5DashboardChain(form.watch("chainId"));
const selectedFormChainId = form.watch("chainId");
const sendMutation = useMutation({
mutationFn: async (values: SendFormValues) => {
const quantityWei = toWei(values.amount).toString();
let decimals = 18;
if (values.tokenAddress) {
const decimalsRpc = await readContract({
contract: getContract({
address: values.tokenAddress,
chain: selectedChain,
client,
}),
method: "function decimals() view returns (uint8)",
params: [],
});
decimals = Number(decimalsRpc);
}
const quantityWei = toUnits(values.amount, decimals).toString();
const secretKeyValue = values.secretKey.trim();
const vaultAccessTokenValue = values.vaultAccessToken.trim();
@@ -563,16 +692,16 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
teamId,
walletAddress,
secretKey: secretKeyValue,
...(values.tokenAddress ? { tokenAddress: values.tokenAddress } : {}),
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
});
if (!result.ok) {
const errorMessage =
typeof result.error === "string"
? result.error
: "Failed to send funds";
const errorMessage = result.error
? JSON.stringify(result.error, null, 2)
: "Failed to send funds";
throw new Error(errorMessage);
}
@@ -623,6 +752,8 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
disableDeprecated
onChange={(nextChainId) => {
field.onChange(nextChainId);
// Reset token to native when chain changes
form.setValue("tokenAddress", undefined);
}}
placeholder="Select network"
/>
@@ -632,6 +763,39 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
)}
/>
<FormField
control={form.control}
name="tokenAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Token</FormLabel>
<FormControl>
<TokenSelector
selectedToken={
field.value
? {
chainId: selectedFormChainId,
address: field.value,
}
: undefined
}
onChange={(token) => {
field.onChange(token.address);
}}
chainId={selectedFormChainId}
client={client}
enabled={true}
showCheck={true}
addNativeTokenIfMissing={true}
placeholder="Native token"
className="w-full bg-card"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="toAddress"
@@ -655,12 +819,6 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
<FormControl>
<Input inputMode="decimal" min="0" step="any" {...field} />
</FormControl>
<FormDescription>
Sending native token
{selectedChain?.nativeCurrency?.symbol
? ` (${selectedChain.nativeCurrency.symbol})`
: ""}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -682,7 +840,9 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
{...field}
/>
</FormControl>
<FormDescription>{secretKeyHelper}</FormDescription>
<FormDescription className="text-muted-foreground text-xs">
{secretKeyHelper}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -694,21 +854,20 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
name="vaultAccessToken"
render={({ field }) => (
<FormItem>
<FormLabel>
Vault access token
<span className="text-muted-foreground"> (optional)</span>
</FormLabel>
<FormLabel>Vault access token</FormLabel>
<FormControl>
<Input
autoComplete="off"
autoCorrect="off"
placeholder="Enter a vault access token (optional)"
placeholder="Enter a vault access token"
spellCheck={false}
type="password"
{...field}
/>
</FormControl>
<FormDescription>{vaultAccessTokenHelper}</FormDescription>
<FormDescription className="text-muted-foreground text-xs">
{vaultAccessTokenHelper}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -716,7 +875,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
<div className="flex justify-end gap-3 border-t bg-card p-4">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
@@ -183,9 +183,8 @@ export async function InsightAnalytics(props: {
) : (
<RequestsByStatusGraph
data={"data" in statusCodesData ? statusCodesData.data : []}
description="The number of requests by status code over time."
isPending={false}
title="Requests by Status Code"
viewMoreLink={undefined}
/>
)}
@@ -5,6 +5,7 @@ import { shortenLargeNumber } from "thirdweb/utils";
import type { InsightStatusCodeStats } from "@/api/analytics";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { ChartConfig } from "@/components/ui/chart";
type ChartData = Record<string, number> & {
@@ -15,11 +16,14 @@ const defaultLabel = 200;
export function RequestsByStatusGraph(props: {
data: InsightStatusCodeStats[];
isPending: boolean;
title: string;
description: string;
viewMoreLink: string | undefined;
}) {
const topStatusCodesToShow = 10;
const total = useMemo(() => {
return props.data.reduce((acc, curr) => acc + curr.totalRequests, 0);
}, [props.data]);
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
@@ -90,17 +94,15 @@ export function RequestsByStatusGraph(props: {
return (
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
chartClassName="aspect-auto h-[275px]"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
</div>
<TotalValueChartHeader
isPending={props.isPending}
total={total}
title="Indexer Requests"
viewMoreLink={props.viewMoreLink}
/>
}
data={chartData}
emptyChartState={<EmptyChartState type="bar" />}
@@ -4,7 +4,6 @@ import type { ThirdwebClient } from "thirdweb";
import { shortenLargeNumber } from "thirdweb/utils";
import type { RpcMethodStats } from "@/api/analytics";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Card } from "@/components/ui/card";
import { SkeletonContainer } from "@/components/ui/skeleton";
import {
Table,
@@ -15,7 +14,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CardHeading } from "../../../payments/components/common";
export function TopRPCMethodsTable(props: {
data: RpcMethodStats[];
@@ -41,18 +39,22 @@ export function TopRPCMethodsTable(props: {
const isEmpty = useMemo(() => sortedData.length === 0, [sortedData]);
return (
<Card className="relative flex flex-col rounded-xl border border-border bg-card p-4">
<div className="rounded-lg border bg-card">
{/* header */}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<CardHeading>Top EVM Methods Called </CardHeading>
<div className="p-6 border-b">
<h2 className="tracking-tight font-semibold text-lg">
Top EVM Methods Called
</h2>
</div>
<div className="h-5" />
<TableContainer scrollableContainerClassName="h-[280px]">
<TableContainer
scrollableContainerClassName="max-h-[380px] min-h-[200px]"
className="border-none"
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead>Method</TableHead>
<TableHead className="lg:w-[320px]">Method</TableHead>
<TableHead>Requests</TableHead>
</TableRow>
</TableHeader>
@@ -85,7 +87,7 @@ export function TopRPCMethodsTable(props: {
/>
</div>
)}
</Card>
</div>
);
}
@@ -1,14 +1,33 @@
"use client";
import { format } from "date-fns";
import { shortenLargeNumber } from "thirdweb/utils";
import { differenceInCalendarDays, format } from "date-fns";
import { useMemo } from "react";
import type { RpcUsageTypeStats } from "@/api/analytics";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
export function RPCRequestsChartUI(props: {
data: RpcUsageTypeStats[];
viewMoreLink: string | undefined;
isPending: boolean;
}) {
const total = useMemo(() => {
return props.data.reduce((acc, curr) => acc + curr.count, 0);
}, [props.data]);
const showBreakdownByHour = useMemo(() => {
if (props.data.length === 0) return true;
const firstData = props.data[0];
const lastData = props.data[props.data.length - 1];
if (!firstData || !lastData) return true;
const firstDate = new Date(firstData.date);
const lastDate = new Date(lastData.date);
return Math.abs(differenceInCalendarDays(lastDate, firstDate)) < 2;
}, [props.data]);
export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
return (
<ThirdwebAreaChart
chartClassName="aspect-[1.5] lg:aspect-[4]"
<ThirdwebBarChart
chartClassName="aspect-auto h-[275px]"
config={{
requests: {
color: "hsl(var(--chart-1))",
@@ -32,23 +51,33 @@ export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
},
[] as { requests: number; time: string }[],
)}
header={{
description: "Requests over time.",
title: "RPC Requests",
}}
customHeader={
<TotalValueChartHeader
total={total}
isPending={props.isPending}
title="RPC Requests"
viewMoreLink={props.viewMoreLink}
/>
}
hideLabel={false}
isPending={false}
showLegend
isPending={props.isPending}
toolTipLabelFormatter={(label) => {
return format(label, "MMM dd, HH:mm");
if (showBreakdownByHour) {
return format(label, "MMM dd, HH:mm");
}
return format(label, "MMM dd");
}}
toolTipValueFormatter={(value) => {
return shortenLargeNumber(value as number);
return compactNumberFormatter.format(value as number);
}}
xAxis={{
sameDay: true,
showHour: showBreakdownByHour,
}}
yAxis
/>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
@@ -3,10 +3,12 @@ import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import { getRpcMethodUsage, getRpcUsageByType } from "@/api/analytics";
import type { Range } from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { StatCard } from "@/components/analytics/stat";
import { Skeleton } from "@/components/ui/skeleton";
import { RpcMethodBarChartCardAsync } from "../../../components/RpcMethodBarChartCard";
import { TopRPCMethodsTable } from "./MethodsTable";
import { RequestsGraph } from "./RequestsGraph";
import { RPCRequestsChartUI } from "./RequestsGraph";
import { RpcAnalyticsFilter } from "./RpcAnalyticsFilter";
import { RpcFTUX } from "./RpcFtux";
@@ -21,7 +23,7 @@ export async function RPCAnalytics(props: {
}) {
const { projectId, teamId, range, interval, authToken } = props;
// TODO: add requests by status code, but currently not performant enough
// TODO: add requests by status code filter, but currently not performant enough
const allRequestsByUsageTypePromise = getRpcUsageByType(
{
from: range.from,
@@ -96,13 +98,35 @@ export async function RPCAnalytics(props: {
value={totalRequests}
/>
</div>
<RequestsGraph data={usageData} />
<RPCRequestsChartUI
data={usageData}
viewMoreLink={undefined}
isPending={false}
/>
<TopRPCMethodsTable
client={props.client}
data={evmMethodsData || []}
/>
</div>
</ResponsiveSuspense>
<div className="h-6" />
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[377px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<RpcMethodBarChartCardAsync
params={{
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
}}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
);
}
@@ -1,71 +0,0 @@
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
type TimeSeriesMetrics = {
date: string;
mainnet: number;
testnet: number;
total: number;
};
export function TotalSponsoredCardUI(props: {
selectedChart: string | undefined;
selectedChartQueryParam: string;
timeSeriesData: TimeSeriesMetrics[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
const {
selectedChart,
selectedChartQueryParam,
processedAggregatedData,
timeSeriesData,
} = props;
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
return (
<CombinedBarChartCard
activeChart={(selectedChart as keyof typeof chartConfig) ?? "mainnet"}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
data={timeSeriesData}
isCurrency
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
title="Gas Sponsored"
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
@@ -6,16 +6,12 @@ import {
} from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import {
type ChainMetadata,
defineChain,
getChainMetadata,
} from "thirdweb/chains";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import {
getAiUsage,
getInAppWalletUsage,
getInsightStatusCodeUsage,
getRpcUsageByType,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
getX402Settlements,
isProjectActive,
} from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
@@ -31,20 +27,16 @@ 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 { AiTokenUsageChartCardUI } from "./ai/analytics/chart/AiTokenUsageChartCard";
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 { RequestsByStatusGraph } from "./gateway/indexer/components/RequestsByStatusGraph";
import { RPCRequestsChartUI } from "./gateway/rpc/components/RequestsGraph";
import { ProjectHighlightsCard } from "./overview/highlights-card";
import { TotalSponsoredCardUI } from "./overview/total-sponsored";
import { AllWalletConnectionsChart } from "./wallets/analytics/chart/all-wallet-connections-chart";
import { X402RequestsChartCardUI } from "./x402/analytics/x402-requests-chart";
type PageParams = {
team_slug: string;
@@ -188,6 +180,7 @@ async function ProjectAnalytics(props: {
return (
<div className="flex grow flex-col gap-6">
{/* project highlights */}
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
@@ -208,30 +201,116 @@ async function ProjectAnalytics(props: {
/>
</ResponsiveSuspense>
{/* wallet connections */}
<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}
/>
{/* indexer and RPC requests */}
<div className="grid gap-6 md:grid-cols-2">
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
fallback={
<RequestsByStatusGraph
data={[]}
isPending={true}
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/indexer`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncWalletDistributionCard
project={project}
range={range}
<AsyncIndexerRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
fallback={
<RPCRequestsChartUI
isPending={true}
data={[]}
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/rpc`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAuthMethodDistributionCard
project={project}
range={range}
<AsyncRPCRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* x402 */}
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<X402RequestsChartCardUI
stats={[]}
isPending={true}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
/>
}
>
<AsyncX402RequestsChart
from={range.from}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
{/* AI tokens */}
<ResponsiveSuspense
fallback={
<AiTokenUsageChartCardUI
isPending={true}
title="AI token volume"
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/ai/analytics`}
aiUsageStats={[]}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAiAnalytics
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
{/* transactions */}
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
@@ -254,130 +333,66 @@ async function ProjectAnalytics(props: {
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;
async function AsyncIndexerRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
selectedChart: string | undefined;
selectedChartQueryParam: string;
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: 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(
const requestsData = await getInsightStatusCodeUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return inAppWalletUsage && inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
) : (
<EmptyStateCard
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
metric="Wallets"
return (
<RequestsByStatusGraph
data={requestsData && "data" in requestsData ? requestsData.data : []}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
/>
);
}
async function AsyncRPCRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getRpcUsageByType(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return (
<RPCRequestsChartUI
isPending={false}
data={requestsData || []}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
/>
);
}
@@ -463,158 +478,79 @@ async function AsyncAppHighlightsCard(props: {
);
}
async function AsyncWalletDistributionCard(props: {
project: Project;
range: Range;
async function AsyncAiAnalytics(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const walletConnections = await getWalletConnections(
const stats = await getAiUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.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,
};
}),
);
).catch((error) => {
console.error(error);
return [];
});
return (
<PieChartCard
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
return {
label: walletName,
value: uniqueWalletsConnected,
};
})}
title="External Wallets Connected"
<AiTokenUsageChartCardUI
title="AI token volume"
isPending={false}
aiUsageStats={stats}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
/>
);
}
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;
async function AsyncX402RequestsChart(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: 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[]);
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 [];
});
// 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),
};
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
return (
<TotalSponsoredCardUI
processedAggregatedData={processedAggregatedData}
selectedChart={selectedChart}
selectedChartQueryParam={selectedChartQueryParam}
timeSeriesData={timeSeriesData}
<X402RequestsChartCardUI
stats={
isAllEmpty
? []
: stats.map((stat) => ({
requests: stat.totalRequests,
time: stat.date,
}))
}
isPending={false}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
);
}
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>
);
}
@@ -52,6 +52,7 @@ function Variants(props: { currentPlan: Team["billingPlan"] }) {
<div className="mx-auto w-full max-w-[1140px] px-4 py-6">
<div className="flex flex-col gap-10">
<InAppWalletSettingsUI
isLegacyPlan={false}
client={storybookThirdwebClient}
embeddedWalletService={{
actions: [],
@@ -45,6 +45,7 @@ type InAppWalletSettingsPageProps = {
teamId: string;
teamSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
smsCountryTiers: SMSCountryTiers;
client: ThirdwebClient;
};
@@ -170,6 +171,7 @@ export const InAppWalletSettingsUI: React.FC<
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
isLegacyPlan={props.isLegacyPlan}
/>
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
@@ -196,6 +198,7 @@ export const InAppWalletSettingsUI: React.FC<
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
<div className="my-5 border-t border-dashed" />
@@ -205,6 +208,7 @@ export const InAppWalletSettingsUI: React.FC<
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
<div className="my-5 border-t border-dashed" />
@@ -215,6 +219,7 @@ export const InAppWalletSettingsUI: React.FC<
smsCountryTiers={props.smsCountryTiers}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isLegacyPlan={props.isLegacyPlan}
/>
</Fieldset>
</form>
@@ -227,6 +232,7 @@ function BrandingFieldset(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
client: ThirdwebClient;
isUpdating: boolean;
}) {
@@ -247,6 +253,7 @@ function BrandingFieldset(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("branding"),
id: "branding-switch",
@@ -391,6 +398,7 @@ function SMSCountryFields(props: {
teamPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
teamSlug: string;
isLegacyPlan: boolean;
}) {
return (
<div>
@@ -402,6 +410,7 @@ function SMSCountryFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("smsEnabledCountryISOs").length,
id: "sms-switch",
@@ -446,6 +455,7 @@ function JSONWebTokenFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
return (
<div>
@@ -468,6 +478,7 @@ function JSONWebTokenFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("customAuthentication"),
id: "authentication-switch",
@@ -533,6 +544,7 @@ function AuthEndpointFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const expandCustomAuthEndpointField =
props.form.watch("customAuthEndpoint") !== undefined;
@@ -559,6 +571,7 @@ function AuthEndpointFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: expandCustomAuthEndpointField,
id: "auth-endpoint-switch",
@@ -32,6 +32,7 @@ export function CreateNFTPageUI(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const [step, setStep] =
useState<keyof typeof nftCreationPages>("collection-info");
@@ -135,6 +136,7 @@ export function CreateNFTPageUI(props: {
{step === nftCreationPages["launch-nft"] && (
<LaunchNFT
isLegacyPlan={props.isLegacyPlan}
client={props.client}
createNFTFunctions={props.createNFTFunctions}
onLaunchSuccess={props.onLaunchSuccess}
@@ -42,6 +42,7 @@ export function CreateNFTPage(props: {
teamId: string;
projectId: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
@@ -69,6 +69,7 @@ export function LaunchNFT(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const formValues = props.values;
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
@@ -716,6 +717,7 @@ export function LaunchNFT(props: {
</div>
<GatedSwitch
currentPlan={props.teamPlan}
isLegacyPlan={props.isLegacyPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
@@ -50,6 +50,7 @@ export default async function Page(props: {
<div className="container max-w-5xl pt-8 pb-32">
<CreateNFTPage
accountAddress={accountAddress}
isLegacyPlan={team.isLegacyPlan}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
@@ -49,6 +49,7 @@ export function CreateTokenAssetPage(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
@@ -443,6 +444,7 @@ export function CreateTokenAssetPage(props: {
return (
<CreateTokenAssetPageUI
accountAddress={props.accountAddress}
isLegacyPlan={props.isLegacyPlan}
client={props.client}
createTokenFunctions={{
ERC20Asset: {
@@ -66,6 +66,7 @@ export function CreateTokenAssetPageUI(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
"token-info",
@@ -228,6 +229,7 @@ export function CreateTokenAssetPageUI(props: {
{step === "launch" && (
<LaunchTokenStatus
isLegacyPlan={props.isLegacyPlan}
client={props.client}
createTokenFunctions={props.createTokenFunctions}
onLaunchSuccess={props.onLaunchSuccess}
@@ -57,6 +57,7 @@ const mockCreateTokenFunctions: CreateTokenFunctions = {
export const Default: Story = {
args: {
isLegacyPlan: false,
accountAddress: "0x1234567890123456789012345678901234567890",
client: storybookThirdwebClient,
createTokenFunctions: mockCreateTokenFunctions,
@@ -69,6 +70,7 @@ export const Default: Story = {
export const ErrorOnDeploy: Story = {
args: {
isLegacyPlan: false,
accountAddress: "0x1234567890123456789012345678901234567890",
client: storybookThirdwebClient,
createTokenFunctions: {
@@ -90,6 +92,7 @@ export const ErrorOnDeploy: Story = {
export const StorageErrorOnDeploy: Story = {
args: {
isLegacyPlan: false,
accountAddress: "0x1234567890123456789012345678901234567890",
client: storybookThirdwebClient,
createTokenFunctions: {
@@ -63,6 +63,7 @@ export function LaunchTokenStatus(props: {
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const formValues = props.values;
const { createTokenFunctions } = props;
@@ -358,6 +359,7 @@ export function LaunchTokenStatus(props: {
</div>
<GatedSwitch
currentPlan={props.teamPlan}
isLegacyPlan={props.isLegacyPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
@@ -50,6 +50,7 @@ export default async function Page(props: {
<div className="container max-w-5xl pt-8 pb-32">
<CreateTokenAssetPage
accountAddress={accountAddress}
isLegacyPlan={team.isLegacyPlan}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
@@ -1,188 +0,0 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { DocLink } from "@/components/blocks/DocLink";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import type { ChartConfig } from "@/components/ui/chart";
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
import type { InAppWalletStats } from "@/types/analytics";
type ChartData = Record<string, number> & {
time: string; // human readable date
};
const defaultLabel = "Unknown Auth";
export function InAppWalletUsersChartCardUI(props: {
inAppWalletStats: InAppWalletStats[];
isPending: boolean;
title: string;
description: string;
}) {
const { inAppWalletStats } = props;
const topChainsToShow = 10;
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const authMethodToVolumeMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of inAppWalletStats) {
const chartData = _chartDataMap.get(stat.date);
const { authenticationMethod } = stat;
// if no data for current day - create new entry
if (!chartData && stat.uniqueWalletsConnected > 0) {
_chartDataMap.set(stat.date, {
time: stat.date,
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
} as ChartData);
} else if (chartData) {
chartData[authenticationMethod || defaultLabel] =
(chartData[authenticationMethod || defaultLabel] || 0) +
stat.uniqueWalletsConnected;
}
authMethodToVolumeMap.set(
authenticationMethod || defaultLabel,
stat.uniqueWalletsConnected +
(authMethodToVolumeMap.get(authenticationMethod || defaultLabel) ||
0),
);
}
const authMethodsSorted = Array.from(authMethodToVolumeMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const authMethodsToShow = authMethodsSorted.slice(0, topChainsToShow);
const authMethodsAsOther = authMethodsSorted.slice(topChainsToShow);
// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const authMethod in data) {
if (authMethodsAsOther.includes(authMethod)) {
data.others = (data.others || 0) + (data[authMethod] || 0);
delete data[authMethod];
}
}
}
authMethodsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
color: `hsl(var(--chart-${(i % 10) + 1}))`,
label: authMethodsToShow[i],
};
});
if (authMethodsToShow.length > topChainsToShow) {
// Add Other
authMethodsToShow.push("others");
_chartConfig.others = {
color: "hsl(var(--muted-foreground))",
label: "Others",
};
}
return {
chartConfig: _chartConfig,
chartData: Array.from(_chartDataMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
),
};
}, [inAppWalletStats]);
const uniqueAuthMethods = Object.keys(chartConfig);
const disableActions =
props.isPending ||
chartData.length === 0 ||
chartData.every((data) => data.sponsoredUsd === 0);
return (
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
disabled={disableActions}
fileName="Connect Wallets"
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
}
data={chartData}
emptyChartState={<InAppWalletUsersEmptyChartState />}
hideLabel={false}
isPending={props.isPending}
showLegend
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
function InAppWalletUsersEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
icon={TypeScriptIcon}
label="TypeScript"
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
/>
<DocLink
icon={ReactIcon}
label="React"
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
/>
<DocLink
icon={ReactIcon}
label="React Native"
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
/>
<DocLink
icon={UnityIcon}
label="Unity"
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
/>
<DocLink
icon={UnrealIcon}
label="Unreal Engine"
link="https://portal.thirdweb.com/unreal-engine/getting-started"
/>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
import {
AutoMergeBarChart,
type StatData,
} from "@/components/blocks/charts/automerge-barchart";
type AllWalletConnectionsChartProps = {
teamId: string;
projectId: string;
authToken: string;
from: Date;
to: Date;
interval: "day" | "week";
teamSlug: string;
projectSlug: string;
};
export function AllWalletConnectionsChart(
props: AllWalletConnectionsChartProps,
) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<AllWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
}
>
<AsyncAllWalletConnectionsChart {...props} />
</ResponsiveSuspense>
);
}
async function AsyncAllWalletConnectionsChart(
props: AllWalletConnectionsChartProps,
) {
const stats = await getEOAAndInAppWalletConnections(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<AllWalletConnectionsChartUI
{...props}
isPending={false}
stats={stats.map((stat) => ({
date: stat.date,
count: stat.uniqueWalletsConnected,
label: stat.walletName,
}))}
/>
);
}
function AllWalletConnectionsChartUI(props: {
stats: StatData[];
teamSlug: string;
projectSlug: string;
isPending: boolean;
}) {
return (
<AutoMergeBarChart
title="Wallet connections"
description={undefined}
stats={props.stats || []}
isPending={props.isPending}
exportButton={undefined}
maxLabelsToShow={5}
emptyChartState={undefined}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
/>
);
}
@@ -0,0 +1,79 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAWalletConnections } from "@/api/analytics";
import {
AutoMergeBarChart,
type StatData,
} from "@/components/blocks/charts/automerge-barchart";
type EOAConnectionsChartProps = {
teamId: string;
projectId: string;
authToken: string;
from: Date;
to: Date;
interval: "day" | "week";
};
export function EOAConnectionsChart(props: EOAConnectionsChartProps) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<EOAConnectionsChartUI {...props} isPending={true} stats={[]} />
}
>
<AsyncEOAConnectionsChart {...props} />
</ResponsiveSuspense>
);
}
async function AsyncEOAConnectionsChart(props: EOAConnectionsChartProps) {
const stats = await getEOAWalletConnections(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<EOAConnectionsChartUI
{...props}
isPending={false}
stats={stats.map((stat) => ({
date: stat.date,
count: stat.uniqueWalletsConnected,
label: stat.walletName,
}))}
/>
);
}
function EOAConnectionsChartUI({
stats,
isPending,
}: {
stats: StatData[];
isPending: boolean;
}) {
return (
<AutoMergeBarChart
viewMoreLink={undefined}
title="EOA wallet connections"
description="Total number of unique EOA wallets connected"
stats={stats || []}
isPending={isPending}
exportButton={{
fileName: "eoa-connections",
}}
maxLabelsToShow={5}
emptyChartState={undefined}
/>
);
}
@@ -1,80 +0,0 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInAppWalletUsage } from "@/api/analytics";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import type { InAppWalletStats } from "@/types/analytics";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
type InAppWalletAnalyticsProps = {
interval: "day" | "week";
range: Range;
stats: InAppWalletStats[];
isPending: boolean;
};
function InAppWalletAnalyticsUI({
stats,
isPending,
}: InAppWalletAnalyticsProps) {
return (
<InAppWalletUsersChartCardUI
title="Unique Users"
description="The total number of active user wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
/>
);
}
type AsyncInAppWalletAnalyticsProps = Omit<
InAppWalletAnalyticsProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncInAppWalletAnalytics(
props: AsyncInAppWalletAnalyticsProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getInAppWalletUsage(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<InAppWalletAnalyticsUI
{...props}
isPending={false}
range={range}
stats={stats}
/>
);
}
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<InAppWalletAnalyticsUI {...props} isPending={true} stats={[]} />
}
>
<AsyncInAppWalletAnalytics {...props} />
</ResponsiveSuspense>
);
}
@@ -0,0 +1,83 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInAppWalletUsage } from "@/api/analytics";
import {
AutoMergeBarChart,
type StatData,
} from "@/components/blocks/charts/automerge-barchart";
type UserWalletConnectionsChartProps = {
teamId: string;
projectId: string;
authToken: string;
from: Date;
to: Date;
interval: "day" | "week";
};
export function UserWalletConnectionsChart(
props: UserWalletConnectionsChartProps,
) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<UserWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
}
>
<AsyncUserWalletConnectionsChart {...props} />
</ResponsiveSuspense>
);
}
async function AsyncUserWalletConnectionsChart(
props: UserWalletConnectionsChartProps,
) {
const stats = await getInAppWalletUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<UserWalletConnectionsChartUI
{...props}
isPending={false}
stats={stats.map((stat) => ({
date: stat.date,
count: stat.uniqueWalletsConnected,
label: stat.authenticationMethod,
}))}
/>
);
}
function UserWalletConnectionsChartUI({
stats,
isPending,
}: {
stats: StatData[];
isPending: boolean;
}) {
return (
<AutoMergeBarChart
viewMoreLink={undefined}
title="User wallet connections"
description="Total number of unique user wallets connected"
stats={stats || []}
isPending={isPending}
exportButton={{
fileName: "user-wallet-connections",
}}
maxLabelsToShow={5}
emptyChartState={undefined}
/>
);
}
@@ -57,6 +57,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `${basePath}/configuration`,
},
{
name: "Wallets",
path: `${basePath}/wallets`,
},
]}
>
{props.children}
@@ -4,9 +4,10 @@ import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { loginRedirect } from "@/utils/redirects";
import { ProjectWalletSection } from "../../components/project-wallet/project-wallet";
import { TransactionsAnalyticsPageContent } from "../../transactions/analytics/analytics-page";
import { ServerWalletsTable } from "../../transactions/components/server-wallets-table.client";
import type { Wallet } from "../../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../../transactions/solana-wallets/wallet-table/types";
@@ -16,9 +17,6 @@ export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | string[];
to?: string | string[];
interval?: string | string[];
testTxWithWallet?: string | string[];
testSolanaTxWithWallet?: string | string[];
page?: string;
@@ -33,7 +31,7 @@ export default async function Page(props: {
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/wallets`,
);
}
@@ -103,9 +101,6 @@ export default async function Page(props: {
};
}
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const testTxWithWallet =
typeof searchParams.testTxWithWallet === "string"
? searchParams.testTxWithWallet
@@ -122,8 +117,17 @@ export default async function Page(props: {
teamId: project.teamId,
});
const projectWallet = await getProjectWallet(project);
return (
<div className="flex flex-col gap-10">
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
layout="column"
/>
<TransactionsAnalyticsPageContent
authToken={authToken}
client={client}
@@ -139,34 +143,6 @@ export default async function Page(props: {
testTxWithWallet={testTxWithWallet}
wallets={wallets}
/>
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={wallets}
pageSize={pageSize}
project={project}
solanaCurrentPage={solanaCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError}
authToken={authToken}
/>
)}
</div>
);
}
@@ -0,0 +1,138 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { ServerWalletsTable } from "../../../transactions/components/server-wallets-table.client";
import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
props.searchParams,
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!vaultClient) {
return <div>Error: Failed to connect to Vault</div>;
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
const wallets = (eoas.data?.items as Wallet[] | undefined) ?? [];
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanaCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<div className="flex flex-col gap-10">
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={wallets}
pageSize={pageSize}
project={project}
solanaCurrentPage={solanaCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError}
authToken={authToken}
/>
)}
</div>
);
}
@@ -49,9 +49,9 @@ export default async function Page(props: {
{!bundlerService ? (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
<AlertTitle>Gas Sponsorship is disabled</AlertTitle>
<AlertDescription>
Enable Account Abstraction service in{" "}
Enable Gas Sponsorship in{" "}
<UnderlineLink href={`/team/${team_slug}/${project_slug}/settings`}>
project settings
</UnderlineLink>{" "}
@@ -61,6 +61,7 @@ export default async function Page(props: {
) : (
<>
<AccountAbstractionSettingsPage
isLegacyPlan={team.isLegacyPlan}
bundlerService={bundlerService}
client={client}
project={project}
@@ -43,7 +43,7 @@ export default async function Layout(props: {
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions/sponsor",
href: "https://portal.thirdweb.com/wallets/sponsor-gas",
},
],
}}
@@ -99,10 +99,13 @@ export default async function Page(props: {
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
from={range.from}
to={range.to}
/>
<AccountAbstractionAnalytics
client={client}
variant="project"
projectId={project.id}
teamId={project.teamId}
teamSlug={params.team_slug}
@@ -47,6 +47,7 @@ type InAppWalletSettingsPageProps = {
teamId: string;
teamSlug: string;
teamPlan: Team["billingPlan"];
isLegacyPlan: boolean;
smsCountryTiers: SMSCountryTiers;
client: ThirdwebClient;
};
@@ -248,6 +249,7 @@ const InAppWalletSettingsUI: React.FC<
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
isLegacyPlan={props.isLegacyPlan}
/>
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
@@ -271,6 +273,7 @@ const InAppWalletSettingsUI: React.FC<
>
<JSONWebTokenFields
form={form}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
@@ -280,6 +283,7 @@ const InAppWalletSettingsUI: React.FC<
<AuthEndpointFields
form={form}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
@@ -289,6 +293,7 @@ const InAppWalletSettingsUI: React.FC<
<SMSCountryFields
form={form}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={authRequiredPlan}
smsCountryTiers={props.smsCountryTiers}
teamPlan={props.teamPlan}
@@ -305,6 +310,7 @@ function BrandingFieldset(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
client: ThirdwebClient;
isUpdating: boolean;
}) {
@@ -324,6 +330,7 @@ function BrandingFieldset(props: {
<div className="absolute top-8 right-6">
<GatedSwitch
currentPlan={props.teamPlan}
isLegacyPlan={props.isLegacyPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("branding"),
@@ -468,6 +475,7 @@ function SMSCountryFields(props: {
smsCountryTiers: SMSCountryTiers;
teamPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
teamSlug: string;
}) {
return (
@@ -480,6 +488,7 @@ function SMSCountryFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("smsEnabledCountryISOs").length,
id: "sms-switch",
@@ -521,6 +530,7 @@ function JSONWebTokenFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
return (
<div>
@@ -543,6 +553,7 @@ function JSONWebTokenFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: !!props.form.watch("customAuthentication"),
id: "authentication-switch",
@@ -608,6 +619,7 @@ function AuthEndpointFields(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
isLegacyPlan: boolean;
}) {
const expandCustomAuthEndpointField =
props.form.watch("customAuthEndpoint") !== undefined;
@@ -634,6 +646,7 @@ function AuthEndpointFields(props: {
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
isLegacyPlan={props.isLegacyPlan}
switchProps={{
checked: expandCustomAuthEndpointField,
id: "auth-endpoint-switch",
@@ -42,6 +42,7 @@ export default async function Page(props: {
return (
<div className="flex flex-col gap-6">
<InAppWalletSettingsPage
isLegacyPlan={team.isLegacyPlan}
client={client}
project={project}
smsCountryTiers={smsCountryTiers}
@@ -57,6 +57,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `${basePath}/configuration`,
},
{
name: "Users",
path: `${basePath}/users`,
},
]}
>
{props.children}
@@ -4,12 +4,11 @@ import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { InAppWalletAnalytics } from "../analytics/chart";
import { EOAConnectionsChart } from "../analytics/chart/eoa-connections-chart";
import { InAppWalletsSummary } from "../analytics/chart/Summary";
import { UserWalletConnectionsChart } from "../analytics/chart/user-wallet-connections-chart";
export const dynamic = "force-dynamic";
@@ -47,11 +46,6 @@ export default async function Page(props: {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
@@ -62,17 +56,21 @@ export default async function Page(props: {
range={range}
teamId={project.teamId}
/>
<InAppWalletAnalytics
<UserWalletConnectionsChart
authToken={authToken}
interval={interval}
projectId={project.id}
range={range}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
<InAppWalletUsersPageContent
<EOAConnectionsChart
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
projectId={project.id}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
</div>
@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets/users`,
);
}
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<UserWalletsTable
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
);
}
@@ -0,0 +1,51 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
export function X402RequestsChartCardUI(props: {
stats: Array<{ requests: number; time: string }>;
isPending: boolean;
teamSlug: string;
projectSlug: string;
}) {
const total = useMemo(() => {
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
}, [props.stats]);
return (
<ThirdwebBarChart
chartClassName="w-full h-[275px]"
data={props.stats}
config={{
requests: {
color: "hsl(var(--chart-1))",
label: "Requests",
},
}}
isPending={props.isPending}
hideLabel={false}
toolTipLabelFormatter={(label) => {
return format(label, "MMM dd");
}}
toolTipValueFormatter={(value) => {
return compactNumberFormatter.format(value as number);
}}
customHeader={
<TotalValueChartHeader
total={total}
isPending={props.isPending}
title="X402 Requests"
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
/>
}
/>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
@@ -80,11 +80,11 @@ export default async function Page(props: {
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
{totalPayments === 0 ? (
<X402EmptyState walletAddress={projectWallet?.address} />
) : (
<>
<ResponsiveTimeFilters defaultRange={defaultRange} />
<X402Summary
authToken={authToken}
projectId={project.id}
@@ -72,7 +72,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
<TeamVerifiedIcon domain={currentTeam.verifiedDomain} />
</Link>
{/* may render its own link so has to be outside of the link */}
<TeamPlanBadge plan={teamPlan} teamSlug={currentTeam.slug} />
<TeamPlanBadge
plan={teamPlan}
teamSlug={currentTeam.slug}
isLegacyPlan={currentTeam.isLegacyPlan}
/>
</span>
<TeamAndProjectSelectorPopoverButton
@@ -117,6 +117,7 @@ export function TeamSelectionUI(props: {
<TeamPlanBadge
plan={team.billingPlan}
isLegacyPlan={team.isLegacyPlan}
teamSlug={team.slug}
/>
</Link>
@@ -66,6 +66,7 @@ export function ProjectAndTeamSelectorCard(props: {
<span className="font-medium text-sm">{team.name}</span>
<TeamPlanBadge
plan={team.billingPlan}
isLegacyPlan={team.isLegacyPlan}
teamSlug={team.slug}
/>
</div>
@@ -62,7 +62,11 @@ export function TeamSelectorCard(props: {
>
{team.name}
</Link>
<TeamPlanBadge plan={team.billingPlan} teamSlug={team.slug} />
<TeamPlanBadge
plan={team.billingPlan}
teamSlug={team.slug}
isLegacyPlan={team.isLegacyPlan}
/>
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground group-hover:text-foreground" />
</div>
);
+2 -2
View File
@@ -1,11 +1,11 @@
export const bridgeStats = {
supportedChains: "95+",
supportedTokens: "10,000+",
supportedTokens: "14,000+",
supportedRoutes: "15M+",
};
export const bridgeStatsNumbers = {
supportedChains: 95,
supportedTokens: 10000,
supportedTokens: 14000,
supportedRoutes: 15000000,
};
@@ -3,7 +3,7 @@
import type React from "react";
import { useId, useState } from "react";
import { defineChain } from "thirdweb/chains";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -106,7 +106,7 @@ export function X402LeftSection(props: {
{/* Chain selection */}
<div className="flex flex-col gap-2">
<Label htmlFor={chainId}>Chain</Label>
<BridgeNetworkSelector
<SingleNetworkSelector
chainId={selectedChain}
onChange={handleChainChange}
placeholder="Select a chain"
@@ -119,6 +119,7 @@ export function X402LeftSection(props: {
<div className="flex flex-col gap-2">
<Label htmlFor={tokenId}>Token</Label>
<TokenSelector
includeNativeToken={false}
chainId={selectedChain}
client={THIRDWEB_CLIENT}
enabled={true}
@@ -1,17 +1,11 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { CodeClient } from "@workspace/ui/components/code/code.client";
import { CodeIcon, LockIcon } from "lucide-react";
import { CircleDollarSignIcon, CodeIcon, LockIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
ConnectButton,
useActiveAccount,
useActiveWallet,
} from "thirdweb/react";
import { wrapFetchWithPayment } from "thirdweb/x402";
import { ConnectButton, useFetchWithPayment } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { THIRDWEB_CLIENT } from "@/lib/client";
@@ -31,55 +25,47 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) {
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
}
const activeWallet = useActiveWallet();
const activeAccount = useActiveAccount();
const { fetchWithPayment, isPending, data, error, isError } =
useFetchWithPayment(THIRDWEB_CLIENT);
const paidApiCall = useMutation({
mutationFn: async () => {
if (!activeWallet) {
throw new Error("No active wallet");
}
const fetchWithPay = wrapFetchWithPayment(
fetch,
THIRDWEB_CLIENT,
activeWallet,
);
const searchParams = new URLSearchParams();
searchParams.set("chainId", props.options.chain.id.toString());
searchParams.set("payTo", props.options.payTo);
searchParams.set("amount", props.options.amount);
searchParams.set("tokenAddress", props.options.tokenAddress);
searchParams.set("decimals", props.options.tokenDecimals.toString());
searchParams.set("waitUntil", props.options.waitUntil);
const url =
"/api/paywall" +
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
const response = await fetchWithPay(url.toString());
return response.json();
},
});
const isTokenSelected =
props.options.tokenAddress !==
"0x0000000000000000000000000000000000000000" &&
props.options.tokenSymbol !== "";
const handlePayClick = async () => {
paidApiCall.mutate();
const searchParams = new URLSearchParams();
searchParams.set("chainId", props.options.chain.id.toString());
searchParams.set("payTo", props.options.payTo);
searchParams.set("amount", props.options.amount);
searchParams.set("tokenAddress", props.options.tokenAddress);
searchParams.set("decimals", props.options.tokenDecimals.toString());
searchParams.set("waitUntil", props.options.waitUntil);
const url =
"/api/paywall" +
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
await fetchWithPayment(url);
};
const clientCode = `import { createThirdwebClient } from "thirdweb";
import { wrapFetchWithPayment } from "thirdweb/x402";
import { useActiveWallet } from "thirdweb/react";
import { useFetchWithPayment } from "thirdweb/react";
const client = createThirdwebClient({ clientId: "your-client-id" });
export default function Page() {
const wallet = useActiveWallet();
const { fetchWithPayment, isPending } = useFetchWithPayment(client);
const onClick = async () => {
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
const response = await fetchWithPay('/api/paid-endpoint');
const data = await fetchWithPayment('/api/paid-endpoint');
console.log(data);
}
return (
<Button onClick={onClick}>Pay Now</Button>
<Button onClick={onClick} disabled={isPending}>
{isPending ? "Processing..." : "Pay Now"}
</Button>
);
}`;
@@ -159,34 +145,36 @@ export async function POST(request: Request) {
>
<BackgroundPattern />
{previewTab === "ui" && (
{previewTab === "ui" && !isTokenSelected && (
<Card className="p-8 text-center max-w-md mx-auto my-auto">
<CircleDollarSignIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Select Payment Token</h3>
<p className="text-sm text-muted-foreground">
Please select a chain and payment token from the configuration
panel to continue.
</p>
</Card>
)}
{previewTab === "ui" && isTokenSelected && (
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={props.options.chain}
detailsButton={{
displayBalanceToken:
props.options.tokenAddress !==
"0x0000000000000000000000000000000000000000"
? {
[props.options.chain.id]: props.options.tokenAddress,
}
: undefined,
displayBalanceToken: {
[props.options.chain.id]: props.options.tokenAddress,
},
}}
supportedTokens={{
[props.options.chain.id]: [
{
address: props.options.tokenAddress,
symbol: props.options.tokenSymbol,
name: props.options.tokenSymbol,
},
],
}}
supportedTokens={
props.options.tokenAddress !==
"0x0000000000000000000000000000000000000000"
? {
[props.options.chain.id]: [
{
address: props.options.tokenAddress,
symbol: props.options.tokenSymbol,
name: props.options.tokenSymbol,
},
],
}
: undefined
}
/>
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
@@ -194,8 +182,7 @@ export async function POST(request: Request) {
<span className="text-lg font-medium">Paid API Call</span>
<Badge variant="success">
<span className="text-xl font-bold">
{props.options.amount}{" "}
{props.options.tokenSymbol || "tokens"}
{props.options.amount} {props.options.tokenSymbol}
</span>
</Badge>
</div>
@@ -204,12 +191,12 @@ export async function POST(request: Request) {
onClick={handlePayClick}
className="w-full mb-4"
size="lg"
disabled={paidApiCall.isPending || !activeAccount}
disabled={isPending}
>
Access Premium Content
</Button>
<p className="text-sm text-muted-foreground">
Pay for access with {props.options.tokenSymbol || "tokens"} on{" "}
Pay for access with {props.options.tokenSymbol} on{" "}
{props.options.chain.name || `chain ${props.options.chain.id}`}
</p>
</Card>
@@ -218,19 +205,12 @@ export async function POST(request: Request) {
<CodeIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">API Call Response</span>
</div>
{paidApiCall.isPending && (
<div className="text-center">Loading...</div>
{isPending && <div className="text-center">Loading...</div>}
{isError && (
<div className="text-center">Error: {error?.message}</div>
)}
{paidApiCall.isError && (
<div className="text-center">
Error: {paidApiCall.error.message}
</div>
)}
{paidApiCall.data && (
<CodeClient
code={JSON.stringify(paidApiCall.data, null, 2)}
lang="json"
/>
{!!data && (
<CodeClient code={JSON.stringify(data, null, 2)} lang="json" />
)}
</Card>
</div>
@@ -1,9 +1,11 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { useCallback, useMemo } from "react";
import { ChainIcon } from "@/components/blocks/ChainIcon";
import { SelectWithSearch } from "@/components/ui/select-with-search";
import { useBridgeSupportedChains } from "@/hooks/chains";
import { useAllChainsData } from "../../hooks/allChains";
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
@@ -11,6 +13,132 @@ function cleanChainName(chainName: string) {
type Option = { label: string; value: string };
export function SingleNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
disableTestnets?: boolean;
disableDeprecated?: boolean;
placeholder?: string;
}) {
const allChains = useAllChainsData();
const chainsToShow = useMemo(() => {
let chains = allChains || [];
if (props.disableTestnets) {
chains = chains.filter((chain) => !chain.testnet);
}
if (props.chainIds) {
const chainIdSet = new Set(props.chainIds);
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
}
if (props.disableDeprecated) {
chains = chains.filter((chain) => chain.status !== "deprecated");
}
return chains;
}, [
allChains,
props.chainIds,
props.disableTestnets,
props.disableDeprecated,
]);
const options = useMemo(() => {
return chainsToShow.map((chain) => {
return {
label: chain.name,
value: String(chain.chainId),
};
});
}, [chainsToShow]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = chainsToShow.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return false;
}
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[chainsToShow],
);
const renderOption = useCallback(
(option: Option) => {
const chain = chainsToShow.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return option.label;
}
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge className="gap-2 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[chainsToShow, props.disableChainId],
);
const isLoadingChains = !allChains || allChains.length === 0;
return (
<SelectWithSearch
align={props.align}
className={props.className}
closeOnSelect={true}
disabled={isLoadingChains}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={
isLoadingChains
? "Loading Chains..."
: props.placeholder || "Select Chain"
}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain ID"
showCheck={false}
side={props.side}
value={props.chainId ? String(props.chainId) : undefined}
/>
);
}
export function BridgeNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
@@ -2,7 +2,7 @@
import { CoinsIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { Badge } from "@/components/ui/badge";
import { Img } from "@/components/ui/Img";
@@ -21,13 +21,24 @@ export function TokenSelector(props: {
client: ThirdwebClient;
disabled?: boolean;
enabled?: boolean;
includeNativeToken?: boolean;
}) {
const tokensQuery = useTokensData({
chainId: props.chainId,
enabled: props.enabled,
});
const tokens = tokensQuery.data || [];
const tokens = useMemo(() => {
if (props.includeNativeToken === false) {
return (
tokensQuery.data?.filter(
(token) =>
token.address.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase(),
) || []
);
}
return tokensQuery.data || [];
}, [tokensQuery.data, props.includeNativeToken]);
const addressChainToToken = useMemo(() => {
const value = new Map<string, TokenMetadata>();
for (const token of tokens) {
@@ -0,0 +1,30 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ChainMetadata } from "thirdweb/chains";
async function fetchChainsFromApi() {
// always fetch from prod for chains for now
// TODO: re-visit this
const res = await fetch("https://api.thirdweb.com/v1/chains");
const json = await res.json();
if (json.error || !res.ok) {
throw new Error(
json.error?.message || `Failed to fetch chains: ${res.status}`,
);
}
return json.data as ChainMetadata[];
}
export function useAllChainsData() {
// trigger fetching all chains if this hook is used instead of putting this on root
// so we can avoid fetching all chains if it's not required
const allChainsQuery = useQuery({
queryFn: () => fetchChainsFromApi(),
queryKey: ["all-chains"],
});
return allChainsQuery.data;
}
+75 -8
View File
@@ -4,18 +4,24 @@ import { useQuery } from "@tanstack/react-query";
import type { TokenMetadata } from "@/lib/types";
async function fetchTokensFromApi(chainId?: number) {
const domain = process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb.com";
const url = new URL(
`${domain.includes("localhost") ? "http" : "https"}://${domain}/v1/tokens`,
const bridgeDomain =
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb.com";
const apiDomain = process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com";
const bridgeUrl = new URL(
`${bridgeDomain.includes("localhost") ? "http" : "https"}://${bridgeDomain}/v1/tokens`,
);
const apiUrl = new URL(
`${apiDomain.includes("localhost") ? "http" : "https"}://${apiDomain}/v1/payments/x402/supported`,
);
if (chainId) {
url.searchParams.append("chainId", String(chainId));
bridgeUrl.searchParams.append("chainId", String(chainId));
apiUrl.searchParams.append("chainId", String(chainId));
}
url.searchParams.append("limit", "1000");
url.searchParams.append("includePrices", "false");
bridgeUrl.searchParams.append("limit", "1000");
bridgeUrl.searchParams.append("includePrices", "false");
const res = await fetch(url.toString(), {
const res = await fetch(bridgeUrl.toString(), {
headers: {
"Content-Type": "application/json",
"x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "",
@@ -31,7 +37,68 @@ async function fetchTokensFromApi(chainId?: number) {
throw new Error(json.error.message);
}
return json.data as Array<TokenMetadata>;
const bridgeTokens = json.data as Array<TokenMetadata>;
if (bridgeTokens.length === 0 && chainId) {
// try the x402 supported api, which supports testnets
const apiRes = await fetch(apiUrl.toString(), {
headers: {
"Content-Type": "application/json",
"x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "",
},
});
if (!apiRes.ok) {
throw new Error("Failed to fetch supported tokens");
}
const apiJson = await apiRes.json();
if (apiJson.error) {
throw new Error(apiJson.error.message);
}
const apiTokens = apiJson as {
kinds: Array<{
x402Version: 1;
scheme: "exact";
network: string;
extra: {
defaultAsset: {
address: string;
decimals: number;
eip712: {
name: string;
version: string;
primaryType: "TransferWithAuthorization" | "Permit";
};
};
supportedAssets?: Array<{
address: string;
decimals: number;
eip712: {
name: string;
version: string;
primaryType: "TransferWithAuthorization" | "Permit";
};
}>;
};
}>;
};
const result = apiTokens.kinds
.flatMap((token) => {
const assets = token.extra?.defaultAsset
? [token.extra?.defaultAsset]
: (token.extra?.supportedAssets ?? []);
return assets.map((asset) => ({
chainId: chainId,
address: asset.address,
decimals: asset.decimals,
symbol: asset.eip712.name,
name: asset.eip712.name,
})) as TokenMetadata[];
})
.filter((token) => token !== undefined)
.flat();
return result;
}
return bridgeTokens;
}
export function useTokensData({
@@ -77,3 +77,5 @@ export default async function ExtensionPage() {
</>
);
}
export const revalidate = 86400; // revalidate every day
+2
View File
@@ -19,3 +19,5 @@ export const metadata = createMetadata({
},
title: "Contracts",
});
export const revalidate = 86400; // revalidate every day
@@ -12,15 +12,15 @@ Use `ThirdwebContract.Read` to fetch data from a smart contract without making a
## Usage
```csharp
var result = await contract.Read<T>(contract, "methodName", parameters);
// Static
var result = await ThirdwebContract.Read<T>(contract, "methodName", parameters);
// Extension
var result = await contract.Read<T>("methodName", parameters);
```
<Details summary="Parameters">
### contract (required)
An instance of `ThirdwebContract`. Represents the smart contract you want to interact with.
### methodName (required)
The name of the contract method you wish to call. Must be a `string`.
@@ -12,7 +12,11 @@ The `ThirdwebContract.Write` method allows you to execute transactions that alte
## Usage
```csharp
var transactionReceipt = await contract.Write(wallet, contract, "methodName", weiValue, parameters);
// Static
var receipt = await ThirdwebContract.Write(wallet, contract, "transfer", weiValue, toAddress, amount);
// Extension
var transactionReceipt = await contract.Write(wallet, "methodName", weiValue, parameters);
```
<Details summary="Parameters">
@@ -21,10 +25,6 @@ var transactionReceipt = await contract.Write(wallet, contract, "methodName", we
An instance of `IThirdwebWallet`. This represents the signer of the transaction, which can be any type of wallet provider.
### contract (required)
An instance of `ThirdwebContract`. Represents the smart contract you wish to interact with.
### methodName (required)
The name of the smart contract method you intend to call. Must be a `string`.
@@ -57,9 +57,6 @@ string contractAddress = "0x..."; // Your contract address
var client = ThirdwebClient.Create(secretKey: "yourSecretKey");
var contract = await ThirdwebContract.Create(client, contractAddress, chainId);
// The wallet that signs and sends the transaction
var wallet = await PrivateKeyWallet.Create(client, "yourPrivateKeyHex");
// Assuming transfer takes an address and an amount as parameters
string toAddress = "0x...";
BigInteger amount = new BigInteger(1000); // The amount to transfer
@@ -68,7 +65,7 @@ BigInteger amount = new BigInteger(1000); // The amount to transfer
BigInteger weiValue = BigInteger.Zero;
// Executing the transfer
var receipt = await contract.Write(wallet, contract, "transfer", weiValue, toAddress, amount);
var receipt = await contract.Write(wallet, "transfer", weiValue, toAddress, amount);
Console.WriteLine($"Transaction receipt: {receipt}");
```
@@ -23,13 +23,22 @@ Looking for the contract APIs themselves? Check out the [Contracts guides](/dotn
## Usage
```csharp
ThirdwebTransaction transaction = await contract.Prepare(
// Static
ThirdwebTransaction transaction = await ThirdwebContract.Prepare(
wallet,
contract,
"methodName",
weiValue,
parameters
);
// Extension
ThirdwebTransaction transaction = await contract.Prepare(
wallet,
"methodName",
weiValue,
parameters
);
```
Once prepared, you can tweak or send the transaction:
@@ -27,3 +27,5 @@ export default async function Page(props: PageProps) {
notFound();
}
export const revalidate = 86400; // revalidate every day
+2
View File
@@ -20,3 +20,5 @@ export const metadata = createMetadata({
"A type-safe library to interact with any EVM-compatible blockchain in React applications.",
title: "thirdweb React SDK",
});
export const revalidate = 86400; // revalidate every day
@@ -13,6 +13,7 @@ const tagsToGroup = {
"@appURI": "App URI",
"@auth": "Auth",
"@bridge": "Payments",
"@x402": "x402",
"@chain": "Chain",
"@claimConditions": "Claim Conditions",
"@client": "Client",
@@ -64,6 +65,7 @@ const sidebarGroupOrder: TagKey[] = [
"@transaction",
"@insight",
"@engine",
"@x402",
"@bridge",
"@nebula",
"@social",
@@ -14,3 +14,5 @@ const config = getTDocPage({
export default config.default;
export const generateStaticParams = config.generateStaticParams;
export const generateMetadata = config.generateMetadata;
export const revalidate = 86400; // revalidate every day

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