Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8b6a953933 Version Packages (#7594)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-13 10:27:06 +12:00
Joaquim VergesandGitHub 33b0b6dcb4 [Dashboard] Add server-wallet signAuthorization capability to wallet access token (#7530) 2025-07-12 02:53:53 -07:00
MananTank cdc7d95db2 [TOOL-4959] Dashboard: Asset page UI improvements (#7599)
<!--

## 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 refining the UI components and enhancing the functionality of the `ERC20` public page. It includes improvements in layout, styling, and the introduction of new features related to token price data and analytics.

### Detailed summary
- Deleted unused files: `useTokenPriceData.ts` and `ContractCreatorBadge.tsx`.
- Updated `PageHeader` to change border style.
- Added `tooltip` prop in `CopyAddressButton`.
- Changed API key usage in `fetchTokenInfoFromBridge`.
- Enhanced `ThirdwebAreaChart` with better props handling.
- Introduced `GridPattern` component for background.
- Improved `RecentTransfers` UI and pagination buttons.
- Updated `PriceChart` and `TokenStats` to directly use passed props for token price data.
- Refactored `ContractHeaderUI` to improve layout and social link handling.

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

<!-- end pr-codex -->

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

* **New Features**
  * Introduced a customizable grid background pattern component for enhanced visual design.
  * Added support for displaying token price data and analytics on ERC20 public pages, including conditional rendering of token statistics.
  * Enabled tooltip customization for the copy address button.

* **Bug Fixes**
  * The contract creator badge icon is now visible, and spacing between elements has been improved.

* **Refactor**
  * Simplified and unified chart headers and analytics section layouts.
  * Streamlined fetching and handling of token price data with a new asynchronous function and removed the previous hook.
  * Improved layout and container styling across ERC20 and NFT public pages, including card backgrounds, borders, and spacing.
  * Adjusted component structures for better visual separation and consistency.
  * Removed the contract creator badge component and replaced it with a simpler contract owner link.

* **Style**
  * Updated card and header styles for better visual hierarchy and clarity.
  * Enhanced recent transfers and contract header sections with improved padding, borders, and rounded corners.
  * Removed small shadow effects from cards and refined header background and border styles.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-12 05:50:29 +05:30
Manan TankandGitHub 592bf6f1fd [TOOL-5021] SDK: Fix theme in ConnectButton Details Modal Buy screen (#7589) 2025-07-12 09:21:50 +12:00
MananTank 8973174ad5 Dashboard: Fix erc20 asset page crash when number of tokens claimed is a fraction (#7597)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `SupplyClaimedProgress` component to use `claimedSupplyTokens` and `totalSupplyTokens` as `number` types instead of `bigint`, along with minor adjustments in related components and UI elements.

### Detailed summary
- Changed `enable-background` to `enableBackground` in `DiscordIcon.tsx`.
- Updated `SupplyClaimedProgress` props from `claimedSupply` and `totalSupply` (bigint) to `claimedSupplyTokens` and `totalSupplyTokens` (number).
- Adjusted calculations and conditions in `SupplyClaimedProgress` for new props.
- Modified test cases in stories to reflect new prop types.
- Updated UI elements to display supply values correctly based on the new number type.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Refactor**
  * Updated supply progress components and related usage to use number types instead of bigints, with new prop names for clarity.
  * Improved handling and display of "unlimited" supply scenarios in supply progress components.
  * Adjusted percentage calculation and formatting for claimed supply progress.

* **Style**
  * Corrected SVG attribute naming in the Discord icon to ensure proper rendering.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 20:38:47 +00:00
Phillip HoandGitHub c160e91c08 chore: redirect /universal-bridge -> /payments (#7593) 2025-07-12 04:20:58 +08:00
gregandGitHub f33e31ad6e [SDK] Fix: only require missing token amount for transaction widget (#7591) 2025-07-11 02:01:57 -07:00
Joaquim VergesandGitHub 4111d0e638 [Dashboard] Add error handling and refactor vault account rotation (#7592) 2025-07-10 19:59:42 -07:00
joaquim-verges ffb7b6487b [Dashboard] use BuyWidget on /bridge (#7590)
Fixes TOOL-5023

<!--

## 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 `UniversalBridgeEmbed` component to replace the `PayEmbed` with `BuyWidget` from the `thirdweb` library, modifying how payment options are handled and improving the integration with the `thirdweb` SDK.

### Detailed summary
- Replaced `PayEmbed` component with `BuyWidget`.
- Removed `payOptions` prop and its nested properties.
- Added `amount`, `chain`, and `tokenAddress` props to `BuyWidget`.
- Updated the theme handling to use `getSDKTheme`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * Updated the embedded widget in the dashboard to use a new buy widget, offering a simplified and more direct interface for purchasing tokens.

* **Refactor**
  * Streamlined the widget’s input options for a more straightforward user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 23:09:34 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
77063e5c85 Version Packages (#7588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 15:51:49 -07:00
Joaquim VergesandGitHub 585eac2a6f [Dashboard] Replace vault access token with project secret key for authentication (#7570) 2025-07-10 15:32:36 -07:00
MananTank c54db522b3 Dashboard: Fix webhooks posthog feature flag conditional rendering (#7585)
<!--

## 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 centralized webhooks feature, enhancing the sidebar layout and analytics for webhooks. It adds loading states, integrates feature flags, and modifies the handling of webhooks in various components.

### Detailed summary
- Added `Loading` components using `GenericLoadingPage` in multiple webhook pages.
- Updated `ProjectSidebarLayout` to include `isCentralizedWebhooksFeatureFlagEnabled`.
- Enhanced `ProjectLayout` to fetch and utilize the centralized webhooks feature flag.
- Modified `WebhooksAnalyticsPage` to check for the centralized webhooks feature flag before redirecting.
- Adjusted `WebhooksLayout` to conditionally render links based on the centralized webhooks feature flag.
- Refactored `isFeatureFlagEnabled` function for improved feature flag handling.
- Updated `WebhooksPage` to include centralized webhooks feature flag checks and redirection logic.
- Cleaned up imports and ensured consistent use of `params` across various components.

> ✨ 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 multiple loading indicators across webhooks analytics, contracts, universal bridge, and sidebar routes for smoother user experience.
  * Sidebar and tab navigation dynamically update based on the centralized webhooks feature flag.

* **Bug Fixes**
  * Enhanced error handling with 404 responses for missing accounts, projects, or authentication on webhooks pages.
  * Redirects implemented when the centralized webhooks feature flag is disabled.

* **Refactor**
  * Updated feature flag checks to a new cached API accepting structured parameters.
  * Streamlined parameter handling and navigation logic for webhooks pages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 20:15:27 +00:00
Joaquim VergesandGitHub d62e9971ee [SDK] Fix inapp wallets showing up in all wallets list (#7586) 2025-07-10 13:14:57 -07:00
MananTank 7d796f5aa5 Dashboard: Fix theme for BuyWidget in chain page (#7583)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `BuyFundsSection` component by integrating theme support for the `BuyWidget` using the `useTheme` hook from `next-themes`. It adjusts the widget's theme based on the current application theme.

### Detailed summary
- Added `useTheme` hook to access the current theme.
- Imported `getSDKTheme` utility for theme handling.
- Passed the correct theme to the `BuyWidget` based on whether the theme is "dark" or "light".

> ✨ 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**
  * The Buy Funds section now adapts its appearance based on your selected theme (light or dark), providing a more consistent and personalized user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 19:57:29 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a5bb36141d Version Packages (#7584)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 12:50:16 -07:00
Prithvish BaidyaandGitHub f3234c3c41 [thirdweb/server-wallet] fix address used for simulation for ERC4337 (#7581) 2025-07-10 11:55:04 -07:00
91e3781e83 [Dashboard] Improve user filtering functionality (#7572)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-10 11:53:41 -07:00
arcoraven 86e3aa8c04 feat: webhooks list in dashboard (#7505)
# [Dashboard] Feature: Add Webhook Management UI

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/38dbe90b-2bd9-47b6-b190-3c257e99d2fe.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/d0a40295-c1c7-45c5-9b23-224a2a631b83.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/42d5337d-fcfe-4ef7-a349-1f596013e003.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/b7b89fe6-a5f7-4961-b927-bfc754c6665f.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/88916c15-a501-4ceb-9267-a23290f683b9.png)

## Notes for the reviewer
This PR adds a new webhook management UI to the dashboard, allowing users to create, edit, delete, and monitor webhooks. The implementation includes:

- A new overview page for webhooks with a table to display all configured webhooks
- Create/edit webhook modals with form validation
- Topic selector for webhook event subscriptions
- Delete confirmation with activity warning
- Webhook metrics display showing request counts and error rates
- API integration for webhook configuration management

## How to test
Navigate to the webhooks section in a project to view the new UI. Create a new webhook, edit its configuration, and test the delete functionality. Verify that metrics are displayed correctly for existing webhooks.

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

* **New Features**
  * Introduced a comprehensive webhooks management interface, including creation, editing, deletion, and topic selection with optional JSON filters.
  * Added analytics dashboards for webhooks, featuring charts for request status codes and latency percentiles, with filtering by webhook and date range.
  * Implemented feature flag-based tab navigation for "Overview" and "Analytics" in the webhooks section.
  * Provided detailed webhook metrics, including recent activity and error rates.

* **Bug Fixes**
  * Improved error handling and user feedback for webhook configuration and analytics operations.

* **Chores**
  * Added PostHog server-side analytics integration.
  * Updated environment variable naming for analytics configuration.
  * Added new dependency for analytics support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces webhook analytics features and enhances the webhook management system, including new types for webhook statistics, UI components for creating and editing webhooks, and improvements in data fetching and handling.

### Detailed summary
- Added `posthog-node` dependency for server-side analytics.
- Updated environment variable from `NEXT_PUBLIC_POSTHOG_API_KEY` to `NEXT_PUBLIC_POSTHOG_KEY`.
- Introduced interfaces for `WebhookRequestStats`, `WebhookLatencyStats`, and `WebhookSummaryStats`.
- Created `CreateWebhookConfigModal` and `EditWebhookConfigModal` components for managing webhooks.
- Implemented `WebhooksAnalytics` and `WebhookAnalyticsServer` components for displaying analytics data.
- Enhanced `WebhookMetrics` to show metrics and error rates.
- Added `isFeatureFlagEnabled` function for feature flag checks.
- Implemented data fetching functions for webhook metrics in `analytics.ts`.
- Updated the webhook configuration management with new API endpoints and improved error handling.
- Created `TopicSelectorModal` for selecting topics with optional filters.
- Improved UI components for better user experience in managing webhooks.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-07-10 17:21:02 +00:00
MananTank aa1035d16c [TOOL-4988] Dashboard, SDK: Add gas sponsorship option for asset creation for in-app wallets (#7566)
<!--

## 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 handling of gasless transactions across various components in the `thirdweb` dashboard, specifically for NFT and token creation processes. It introduces new parameters and logic to support gas sponsorship features.

### Detailed summary
- Added `useAuthToken` option in `bundler.ts` fetch requests.
- Enhanced `fetch.ts` to include authentication headers for bundler requests.
- Updated `CreateTokenFunctions` and `CreateNFTCollectionFunctions` to accept `gasless` parameter.
- Modified multiple functions in the NFT and token creation pages to support gasless transactions.
- Implemented UI elements for enabling gas sponsorship in the `LaunchNFT` and `LaunchTokenStatus` components.
- Adjusted error handling for unsupported gas sponsorship on certain chains.

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

Here are the updated release notes based on the provided summary:

* **New Features**
  * Added support for gasless transactions when launching NFT collections and tokens, available for eligible team plans and in-app wallets.
  * Introduced a toggle in the launch UI to enable or disable gasless mode for NFT and token creation.
  * Updated UI labels and dialogs for launching NFT collections.

* **Refactor**
  * Standardized function signatures for NFT and token creation to accept a unified parameter object, including a new gasless flag.
  * Centralized account and contract retrieval logic for improved maintainability and consistency.
  * Improved error logging in the token creation workflow.

* **Bug Fixes**
  * Enhanced error handling during token launch to detect unsupported gas sponsorship and provide user-friendly retry options.

* **Chores**
  * Updated request headers logic to properly handle authorization tokens and additional headers for bundler URLs.
  * Enabled authentication token usage in bundler request configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 15:18:10 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
bd395a3cff Version Packages (#7579)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 00:51:14 -07:00
Joaquim VergesandGitHub 446a69b1d1 [SDK] update wallet definitions (#7580) 2025-07-09 21:51:41 -07:00
Manan TankandGitHub 826d9c80a8 Dashboard: Remove OP superchain banner, CTAs, credits (#7569) 2025-07-09 20:10:50 -07:00
Joaquim VergesandGitHub 772eb0cc53 [SDK] Handle different fiat currencies in payment widgets (#7578) 2025-07-09 19:59:35 -07:00
585 changed files with 12818 additions and 9294 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
# posthog API key
# - not required for prod/staging
NEXT_PUBLIC_POSTHOG_API_KEY="ignored"
NEXT_PUBLIC_POSTHOG_KEY=""
# Stripe Customer portal
NEXT_PUBLIC_STRIPE_KEY=
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = [
// -- build category
"/wallets",
"/account-abstraction",
"/universal-bridge",
"/payments",
"/auth",
"/in-app-wallets",
"/transactions",
+1
View File
@@ -55,6 +55,7 @@
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"posthog-node": "^5.4.0",
"prettier": "3.6.2",
"qrcode": "^1.5.3",
"react": "19.1.0",
+6 -1
View File
@@ -416,7 +416,7 @@ async function redirects() {
source: "/connect/account-abstraction",
},
{
destination: "/universal-bridge",
destination: "/payments",
permanent: false,
source: "/connect/universal-bridge",
},
@@ -440,6 +440,11 @@ async function redirects() {
permanent: false,
source: "/rpc-edge",
},
{
destination: "/payments",
permanent: false,
source: "/universal-bridge",
},
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
@@ -0,0 +1,57 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { PostHog } from "posthog-node";
let _posthogClient: PostHog | null = null;
function getPostHogServer(): PostHog | null {
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
host: "https://us.i.posthog.com",
});
}
return _posthogClient;
}
/**
* Check if a feature flag is enabled for a specific user
* @param flagKey - The feature flag key
* @param userEmail - The user's email address for filtering
*/
export const isFeatureFlagEnabled = unstable_cache(
async (params: {
flagKey: string;
accountId: string;
email: string | undefined;
}): Promise<boolean> => {
const posthogClient = getPostHogServer();
if (!posthogClient) {
console.warn("Posthog client not set");
return true;
}
const { flagKey, accountId, email } = params;
try {
if (posthogClient && accountId) {
const isEnabled = await posthogClient.isFeatureEnabled(
flagKey,
accountId,
{
personProperties: email ? { email } : undefined,
},
);
if (isEnabled !== undefined) {
return isEnabled;
}
}
} catch (error) {
console.error(`Error checking feature flag ${flagKey}:`, error);
}
return false;
},
["is-feature-flag-enabled"],
{
revalidate: 3600, // 1 hour
},
);
+57
View File
@@ -12,6 +12,9 @@ import type {
UserOpStats,
WalletStats,
WalletUserStats,
WebhookLatencyStats,
WebhookRequestStats,
WebhookSummaryStats,
} from "@/types/analytics";
import { getAuthToken } from "./auth-token";
import { getChains } from "./chain";
@@ -482,6 +485,60 @@ export async function getEngineCloudMethodUsage(
return json.data as EngineCloudStats[];
}
export async function getWebhookSummary(
params: AnalyticsQueryParams & { webhookId: string },
): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
searchParams.append("webhookId", params.webhookId);
const res = await fetchAnalytics(
`v2/webhook/summary?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookSummaryStats[] };
}
export async function getWebhookRequests(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/requests?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookRequestStats[] };
}
export async function getWebhookLatency(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/latency?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookLatencyStats[] };
}
export async function getInsightChainUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
-36
View File
@@ -1,44 +1,8 @@
import "server-only";
import type { ChainMetadata } from "thirdweb/chains";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import type { ChainService } from "@/types/chain";
export async function getGasSponsoredChains() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/gas-sponsored`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch gas sponsored chains",
res.status,
res.statusText,
);
res.body?.cancel();
return [];
}
try {
return (await res.json()).data as number[];
} catch (e) {
console.error("Failed to parse gas sponsored chains", e);
return [];
}
}
export function getChains() {
return fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`,
+312
View File
@@ -0,0 +1,312 @@
"use server";
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
export interface WebhookConfig {
id: string;
teamId: string;
projectId: string;
destinationUrl: string;
description: string;
pausedAt: string | null;
webhookSecret: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
topics: {
id: string;
serviceName: string;
description: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
filters: object | null;
}[];
}
type WebhookConfigsResponse =
| {
data: WebhookConfig[];
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
interface CreateWebhookConfigRequest {
topics: { id: string; filters: object | null }[];
destinationUrl: string;
description: string;
isPaused?: boolean;
}
type CreateWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export interface Topic {
id: string;
serviceName: string;
description: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type TopicsResponse =
| {
data: Topic[];
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
interface UpdateWebhookConfigRequest {
destinationUrl?: string;
topics?: { id: string; filters: object | null }[];
description?: string;
isPaused?: boolean;
}
type UpdateWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
type DeleteWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export async function getWebhookConfigs(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
}): Promise<WebhookConfigsResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function createWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
config: CreateWebhookConfigRequest;
}): Promise<CreateWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
{
body: JSON.stringify(props.config),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function getAvailableTopics(): Promise<TopicsResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function updateWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
webhookConfigId: string;
config: UpdateWebhookConfigRequest;
}): Promise<UpdateWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
{
body: JSON.stringify(props.config),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function deleteWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
webhookConfigId: string;
}): Promise<DeleteWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "DELETE",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
@@ -1,45 +1,18 @@
import { formatDistance } from "date-fns";
import { CircleAlertIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import type { Account, BillingCredit } from "@/hooks/useApi";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ChainIconClient } from "@/icons/ChainIcon";
import type { BillingCredit } from "@/hooks/useApi";
import { formatToDollars } from "./formatToDollars";
interface CreditsItemProps {
credit?: BillingCredit;
isOpCreditDefault?: boolean;
onClickApply?: () => void;
twAccount: Account;
client: ThirdwebClient;
teamSlug: string;
}
export const CreditsItem: React.FC<CreditsItemProps> = ({
credit,
isOpCreditDefault,
onClickApply,
twAccount,
client,
teamSlug,
}) => {
const [hasAppliedForOpGrant] = useLocalStorage(
`appliedForOpGrant-${twAccount.id}`,
false,
);
const isOpCredit = credit?.name.startsWith("OP -") || isOpCreditDefault;
export const CreditsItem: React.FC<CreditsItemProps> = ({ credit }) => {
const isTwCredit = credit?.name.startsWith("TW -");
const isStartupCredit = credit?.name.startsWith("SU -");
let creditTitle = credit?.name ?? "thirdweb credits";
if (isOpCredit) {
creditTitle = "Optimism sponsorship credits";
} else if (isTwCredit) {
if (isTwCredit) {
creditTitle = "thirdweb credits";
} else if (isStartupCredit) {
creditTitle = "Startup grant credits";
@@ -50,13 +23,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
<div className="flex flex-col gap-3 p-4 lg:p-6">
<div className="relative">
<div className="absolute top-0 right-0">
{isOpCredit ? (
<ChainIconClient
className="size-6"
client={client}
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
/>
) : isTwCredit ? (
{isTwCredit ? (
<Image
alt="tw-credit"
className="size-6"
@@ -112,35 +79,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
</div>
)}
</div>
{hasAppliedForOpGrant && !credit && isOpCredit && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>Grant application pending approval</AlertTitle>
<AlertDescription>
You will receive an email once your application&apos;s status
changes.
</AlertDescription>
</Alert>
)}
</div>
{!hasAppliedForOpGrant && isOpCredit && (
<div className="mt-2 flex justify-end border-t px-4 py-4 lg:px-6">
<Button asChild size="sm" variant="outline">
<Link
href={`/team/${teamSlug}/~/settings/credits`}
onClick={() => {
if (onClickApply) {
onClickApply();
}
}}
>
Apply Now
</Link>
</Button>
</div>
)}
</div>
);
};
@@ -2,41 +2,26 @@
// TODO - convert to RSC
import type { ThirdwebClient } from "thirdweb";
import { type Account, useAccountCredits } from "@/hooks/useApi";
import { useAccountCredits } from "@/hooks/useApi";
import { CreditsItem } from "./CreditsItem";
export const CreditsInfoCard = (props: {
twAccount: Account;
client: ThirdwebClient;
teamSlug: string;
}) => {
export const CreditsInfoCard = () => {
const { data: credits } = useAccountCredits();
if (!credits) {
return null;
}
const opCredit = credits.find((crd) => crd.name.startsWith("OP -"));
const restCredits = credits.filter((crd) => !crd.name.startsWith("OP -"));
if (restCredits.length === 0) {
return null;
}
return (
<section className="flex flex-col gap-4">
<CreditsItem
client={props.client}
credit={opCredit}
isOpCreditDefault={true}
teamSlug={props.teamSlug}
twAccount={props.twAccount}
/>
{restCredits?.map((credit) => (
<CreditsItem
client={props.client}
credit={credit}
key={credit.couponId}
teamSlug={props.teamSlug}
twAccount={props.twAccount}
/>
{restCredits.map((credit) => (
<CreditsItem credit={credit} key={credit.couponId} />
))}
</section>
);
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
title: string;
description?: string;
titleClassName?: string;
headerClassName?: string;
};
customHeader?: React.ReactNode;
// chart config
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
emptyChartState?: React.ReactElement;
margin?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
};
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
return (
<Card className={props.className}>
{props.header && (
<CardHeader>
<CardHeader className={props.header.headerClassName}>
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
{props.header.title}
</CardTitle>
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
{props.emptyChartState}
</EmptyChartState>
) : (
<AreaChart accessibilityLayer data={props.data}>
<AreaChart
accessibilityLayer
data={props.data}
margin={{
right: props.margin?.right ?? 0,
left: props.margin?.left ?? 0,
bottom: props.margin?.bottom ?? 10,
top: props.margin?.top ?? 0,
}}
>
<CartesianGrid vertical={false} />
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
<XAxis
@@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
@@ -63,6 +64,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
<CreateProjectDialogUI
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
console.error(
"Failed to create vault account and access token",
error,
);
throw error;
});
return {
project: res.project,
secret: res.secret,
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
address: string;
className?: string;
iconClassName?: string;
tooltip?: string;
variant?:
| "primary"
| "default"
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
copyIconPosition={props.copyIconPosition}
textToCopy={props.address}
textToShow={shortenedAddress}
tooltip="Copy Address"
tooltip={props.tooltip || "Copy Address"}
variant={props.variant}
/>
);
@@ -1,3 +1,4 @@
import { useId } from "react";
import { cn } from "@/lib/utils";
export function DotsBackgroundPattern(props: { className?: string }) {
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
/>
);
}
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number;
height?: number;
x?: number;
y?: number;
squares?: Array<[x: number, y: number]>;
strokeDasharray?: string;
className?: string;
[key: string]: unknown;
}
export function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = "0",
squares,
className,
...props
}: GridPatternProps) {
const id = useId();
return (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
className,
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible" role="presentation">
{squares.map(([x, y]) => (
<rect
strokeWidth="0"
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
"rounded-lg border border-border bg-card text-card-foreground",
className,
)}
ref={ref}
+20 -5
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useActiveAccount } from "thirdweb/react";
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
// FIXME: We keep repeating types, API server should provide them
@@ -311,26 +312,40 @@ export type RotateSecretKeyAPIReturnType = {
data: {
secret: string;
secretMasked: string;
secretHash: string;
};
};
export async function rotateSecretKeyClient(params: {
teamId: string;
projectId: string;
}) {
export async function rotateSecretKeyClient(params: { project: Project }) {
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
});
if (!res.ok) {
throw new Error(res.error);
}
try {
// if the project has an encrypted vault admin key, rotate it as well
const service = params.project.services.find(
(service) => service.name === "engineCloud",
);
if (service?.encryptedAdminKey) {
await rotateVaultAccountAndAccessToken({
project: params.project,
projectSecretKey: res.data.data.secret,
projectSecretHash: res.data.data.secretHash,
});
}
} catch (error) {
console.error("Failed to rotate vault admin key", error);
}
return res.data;
}
@@ -7,22 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys";
const fetchAccountList = ({
jwt,
clientId,
ecosystemSlug,
teamId,
pageNumber,
}: {
jwt: string;
clientId: string;
clientId?: string;
ecosystemSlug?: string;
teamId: string;
pageNumber: number;
}) => {
return async () => {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
url.searchParams.append("clientId", clientId);
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
}
url.searchParams.append("page", pageNumber.toString());
const res = await fetch(url.href, {
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
@@ -38,23 +50,27 @@ const fetchAccountList = ({
};
export function useEmbeddedWallets(params: {
clientId: string;
clientId?: string;
ecosystemSlug?: string;
teamId: string;
page: number;
authToken: string;
}) {
const { clientId, page, authToken } = params;
const { clientId, ecosystemSlug, teamId, page, authToken } = params;
const address = useActiveAccount()?.address;
return useQuery({
enabled: !!address && !!clientId,
enabled: !!address && !!(clientId || ecosystemSlug),
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId,
clientId || ecosystemSlug || "",
page,
),
});
@@ -67,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
const address = useActiveAccount()?.address;
return useMutation({
mutationFn: async ({ clientId }: { clientId: string }) => {
mutationFn: async ({
clientId,
ecosystemSlug,
teamId,
}: {
clientId?: string;
ecosystemSlug?: string;
teamId: string;
}) => {
const responses: WalletUser[] = [];
let page = 1;
@@ -77,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
}>({
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId,
clientId || ecosystemSlug || "",
page,
),
});
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
enable-background="new 0 0 100 100"
enableBackground="new 0 0 100 100"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
{...props}
+25
View File
@@ -66,6 +66,31 @@ export interface UniversalBridgeWalletStats {
developerFeeUsdCents: number;
}
export interface WebhookRequestStats {
date: string;
webhookId: string;
httpStatusCode: number;
totalRequests: number;
}
export interface WebhookLatencyStats {
date: string;
webhookId: string;
p50LatencyMs: number;
p90LatencyMs: number;
p99LatencyMs: number;
}
export interface WebhookSummaryStats {
webhookId: string;
totalRequests: number;
successRequests: number;
errorRequests: number;
successRate: number;
avgLatencyMs: number;
errorBreakdown: Record<string, number>;
}
export interface AnalyticsQueryParams {
teamId: string;
projectId?: string;
@@ -1,12 +1,15 @@
"use client";
import { useTheme } from "next-themes";
import { defineChain, type ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyWidget } from "thirdweb/react";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyFundsSection(props: {
chain: ChainMetadata;
client: ThirdwebClient;
}) {
const { theme } = useTheme();
return (
<section className="flex flex-col gap-4 items-center justify-center">
<BuyWidget
@@ -14,6 +17,7 @@ export function BuyFundsSection(props: {
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.chain.chainId)}
client={props.client}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
</section>
);
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b border-dashed">
<div className="border-b bg-card">
<header
className={cn(
"container flex max-w-8xl justify-between py-3",
@@ -17,28 +17,57 @@ export const Variants: Story = {
function StoryVariants() {
return (
<div className="container max-w-md space-y-10 py-10">
<BadgeContainer label="5.5 / Unlimited Supply">
<SupplyClaimedProgress
claimedSupplyTokens={5.5}
totalSupplyTokens="unlimited"
/>
</BadgeContainer>
<BadgeContainer label="10 / Unlimited Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply="unlimited" />
<SupplyClaimedProgress
claimedSupplyTokens={10}
totalSupplyTokens="unlimited"
/>
</BadgeContainer>
<BadgeContainer label="500/1000 Supply">
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
<SupplyClaimedProgress
claimedSupplyTokens={500}
totalSupplyTokens={1000}
/>
</BadgeContainer>
<BadgeContainer label="123.45/1000 Supply">
<SupplyClaimedProgress
claimedSupplyTokens={123.45}
totalSupplyTokens={1000}
/>
</BadgeContainer>
<BadgeContainer label="10/1M Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
<SupplyClaimedProgress
claimedSupplyTokens={10}
totalSupplyTokens={1000000}
/>
</BadgeContainer>
<BadgeContainer label="0/1M Supply">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
<SupplyClaimedProgress
claimedSupplyTokens={0}
totalSupplyTokens={1000000}
/>
</BadgeContainer>
<BadgeContainer label="10/10 Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
<SupplyClaimedProgress
claimedSupplyTokens={10}
totalSupplyTokens={10}
/>
</BadgeContainer>
<BadgeContainer label="0 Supply - don't show anything">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
<SupplyClaimedProgress claimedSupplyTokens={0} totalSupplyTokens={0} />
</BadgeContainer>
</div>
);
@@ -3,16 +3,16 @@ import { Progress } from "@/components/ui/progress";
import { supplyFormatter } from "../nft/format";
export function SupplyClaimedProgress(props: {
claimedSupply: bigint;
totalSupply: bigint | "unlimited";
claimedSupplyTokens: number;
totalSupplyTokens: number | "unlimited";
}) {
// if total supply is unlimited
if (props.totalSupply === "unlimited") {
if (props.totalSupplyTokens === "unlimited") {
return (
<p className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">Claimed Supply </span>
<span className="flex items-center gap-1 font-bold text-sm">
{supplyFormatter.format(props.claimedSupply)} /{" "}
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
<InfinityIcon aria-label="Unlimited" className="size-4" />
</span>
</p>
@@ -20,29 +20,29 @@ export function SupplyClaimedProgress(props: {
}
// if total supply is 0 - don't show anything
if (props.totalSupply === 0n) {
if (props.totalSupplyTokens === 0) {
return null;
}
// multiply by 10k to have precision up to 2 decimal places in percentage value
const soldFractionTimes10KBigInt =
(props.claimedSupply * 10000n) / props.totalSupply;
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
const soldPercentage =
(props.claimedSupplyTokens / props.totalSupplyTokens) * 100;
const percentToShow = soldPercentage.toFixed(2);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Supply Claimed</span>
<span className="font-bold text-sm">
{supplyFormatter.format(props.claimedSupply)} /{" "}
{supplyFormatter.format(props.totalSupply)}
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
{supplyFormatter.format(props.totalSupplyTokens)}
</span>
</div>
<Progress className="h-2.5" value={soldPercentage} />
<p className="font-medium text-muted-foreground text-xs">
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
{soldPercentage.toFixed(2)}% Sold
{percentToShow === "0.00" ? "~0.00%" : `${percentToShow}%`} Sold
</p>
</div>
);
@@ -0,0 +1,51 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
export type TokenPriceData = {
price_usd: number;
price_usd_cents: number;
percent_change_24h: number;
market_cap_usd: number;
volume_24h_usd: number;
volume_change_24h: number;
holders: number;
historical_prices: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
};
export async function getTokenPriceData(params: {
chainId: number;
contractAddress: string;
}) {
try {
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
);
url.searchParams.set("include_historical_prices", "true");
url.searchParams.set("chain_id", params.chainId.toString());
url.searchParams.set("address", params.contractAddress);
url.searchParams.set("include_holders", "true");
const res = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
});
if (!res.ok) {
console.error("Failed to fetch token price data", await res.text());
return undefined;
}
const json = await res.json();
const priceData = json.data[0] as TokenPriceData | undefined;
return priceData;
} catch {
return undefined;
}
}
@@ -1,19 +0,0 @@
import type { ThirdwebContract } from "thirdweb";
import { WalletAddress } from "@/components/blocks/wallet-address";
export function ContractCreatorBadge(props: {
contractCreator: string;
clientContract: ThirdwebContract;
}) {
return (
<div className="flex items-center gap-1.5 bg-card rounded-full px-2.5 py-1.5 border hover:bg-accent">
<span className="text-xs text-foreground">By</span>
<WalletAddress
address={props.contractCreator}
className="py-0 text-xs h-auto !no-underline"
client={props.clientContract.client}
iconClassName="size-3.5 hidden"
/>
</div>
);
}
@@ -1,4 +1,9 @@
import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react";
import {
ExternalLinkIcon,
GlobeIcon,
Settings2Icon,
UserIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
@@ -19,7 +24,6 @@ import { YoutubeIcon } from "@/icons/brand-icons/YoutubeIcon";
import { ChainIconClient } from "@/icons/ChainIcon";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { ContractCreatorBadge } from "./ContractCreatorBadge";
const platformToIcons: Record<string, React.FC<{ className?: string }>> = {
discord: DiscordIcon,
@@ -44,6 +48,7 @@ export function ContractHeaderUI(props: {
socialUrls: object;
imageClassName?: string;
contractCreator: string | null;
className?: string;
}) {
const socialUrls = useMemo(() => {
const socialUrlsValue: { name: string; href: string }[] = [];
@@ -64,41 +69,45 @@ export function ContractHeaderUI(props: {
?.replace("Mainnet", "")
.trim();
const explorersToShow = getExplorersToShow(props.chainMetadata);
const validBlockExplorer = getExplorerToShow(props.chainMetadata);
return (
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
{props.image && (
<Img
className={cn(
"size-20 shrink-0 rounded-full border bg-muted",
props.imageClassName,
)}
fallback={
<div className="flex items-center justify-center font-bold text-3xl text-muted-foreground/80">
{props.name[0]}
</div>
}
src={
props.image
? resolveSchemeWithErrorHandler({
client: props.clientContract.client,
uri: props.image,
})
: ""
}
/>
<div
className={cn(
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
props.className,
)}
>
<div className="flex">
<div className="border p-1 rounded-full bg-card">
<Img
className={cn("size-28 shrink-0 rounded-full bg-muted")}
fallback={
<div className="flex items-center justify-center font-bold text-5xl text-muted-foreground/50 capitalize">
{props.name[0]}
</div>
}
src={
props.image
? resolveSchemeWithErrorHandler({
client: props.clientContract.client,
uri: props.image,
})
: ""
}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2.5 flex-1">
{/* top row */}
<div className="flex flex-col gap-3">
<div className="flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center">
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
<h1 className="font-semibold text-3xl tracking-tighter lg:text-5xl">
{props.name}
</h1>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 ">
<Link
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
href={`/${props.chainMetadata.slug}`}
@@ -113,12 +122,15 @@ export function ContractHeaderUI(props: {
)}
</Link>
<CopyAddressButton
address={props.clientContract.address}
className="rounded-full bg-card w-[30px] h-[30px] p-0 [&>span]:hidden [&>svg]:text-foreground"
copyIconPosition="left"
variant="outline"
/>
{props.contractCreator &&
validBlockExplorer &&
props.contractCreator !== ZERO_ADDRESS && (
<SocialLink
href={`${validBlockExplorer.url}/address/${props.contractCreator}`}
name="Contract Owner"
icon={UserIcon}
/>
)}
{socialUrls
.toSorted((a, b) => {
@@ -148,13 +160,14 @@ export function ContractHeaderUI(props: {
</div>
{/* bottom row */}
<div className="flex flex-row flex-wrap items-center gap-2">
{props.contractCreator && props.contractCreator !== ZERO_ADDRESS && (
<ContractCreatorBadge
clientContract={props.clientContract}
contractCreator={props.contractCreator}
/>
)}
<div className="flex flex-col items-end sm:flex-row flex-wrap sm:items-center gap-2 absolute top-6 right-0 sm:static w-1/2 sm:w-full">
<CopyAddressButton
address={props.clientContract.address}
className="rounded-full bg-card px-2.5 py-1.5 text-xs"
copyIconPosition="left"
tooltip="Copy contract address"
variant="outline"
/>
<ToolTipLabel
contentClassName="max-w-[300px]"
@@ -180,13 +193,13 @@ export function ContractHeaderUI(props: {
</Button>
</ToolTipLabel>
{explorersToShow?.map((validBlockExplorer) => (
{validBlockExplorer && (
<BadgeLink
href={`${validBlockExplorer.url.endsWith("/") ? validBlockExplorer.url : `${validBlockExplorer.url}/`}address/${props.clientContract.address}`}
key={validBlockExplorer.url}
name={validBlockExplorer.name}
/>
))}
)}
{/* TODO - render social links here */}
</div>
@@ -204,19 +217,22 @@ function isValidUrl(url: string) {
}
}
function getExplorersToShow(chainMetadata: ChainMetadata) {
const validBlockExplorers = chainMetadata.explorers
?.filter((e) => e.standard === "EIP3091")
?.slice(0, 2);
function getExplorerToShow(chainMetadata: ChainMetadata) {
const validBlockExplorers = chainMetadata.explorers?.filter(
(e) => e.standard === "EIP3091",
);
return validBlockExplorers?.slice(0, 1);
return validBlockExplorers?.[0];
}
function BadgeLink(props: { name: string; href: string }) {
function BadgeLink(props: { name: string; href: string; className?: string }) {
return (
<Button
asChild
className="!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize"
className={cn(
"!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize",
props.className,
)}
variant="outline"
>
<Link href={props.href} rel="noopener noreferrer" target="_blank">
@@ -6,13 +6,11 @@ import { useMemo, useState } from "react";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTokenPriceData } from "../_hooks/useTokenPriceData";
import type { TokenPriceData } from "../_apis/token-price-data";
function PriceChartUI(props: {
isPending: boolean;
showTimeOfDay: boolean;
data: Array<{
date: string;
@@ -29,7 +27,7 @@ function PriceChartUI(props: {
<ThirdwebAreaChart
cardContentClassName="p-0"
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="border-none bg-background p-0"
className="border-none bg-transparent p-0"
config={{
price: {
color: "hsl(var(--chart-1))",
@@ -38,7 +36,7 @@ function PriceChartUI(props: {
}}
data={data}
hideLabel={false}
isPending={props.isPending}
isPending={false}
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
toolTipValueFormatter={(value) => {
return tokenPriceUSDFormatter.format(value as number);
@@ -49,7 +47,7 @@ function PriceChartUI(props: {
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 10,
maximumFractionDigits: 8,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
@@ -92,20 +90,14 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
export function TokenStats(params: {
chainId: number;
contractAddress: string;
tokenPriceData: TokenPriceData;
}) {
const tokenPriceQuery = useTokenPriceData(params);
const [interval, setInterval] = useState<Interval>("max");
const tokenPriceData = tokenPriceQuery.data;
const filteredHistoricalPrices = useMemo(() => {
const currentDate = new Date();
if (tokenPriceData?.type === "no-data") {
return [];
}
return tokenPriceData?.data?.historical_prices.filter((item) => {
return params.tokenPriceData.historical_prices.filter((item) => {
const date = new Date(item.date);
const maxDiff =
interval === "24h"
@@ -120,106 +112,75 @@ export function TokenStats(params: {
return differenceInCalendarDays(currentDate, date) <= maxDiff;
});
}, [tokenPriceData, interval]);
}, [params.tokenPriceData, interval]);
const priceUsd =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.price_usd;
const priceUsd = params.tokenPriceData.price_usd;
const percentChange24h = params.tokenPriceData.percent_change_24h;
const marketCap = params.tokenPriceData.market_cap_usd;
const holders = params.tokenPriceData.holders;
const percentChange24h =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.percent_change_24h;
const formattedAbsChange = percentChangeFormatter.format(
Math.abs(percentChange24h),
);
const marketCap =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.market_cap_usd;
const isAlmostZeroChange =
formattedAbsChange === percentChangeFormatter.format(0);
const holders =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.holders;
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
return (
<div>
<div className="bg-card rounded-lg border">
{/* price and change */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
<div>
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
<div className="flex items-center gap-2">
<SkeletonContainer
loadedData={priceUsd}
render={(v) => {
return (
<p className="font-bold text-4xl tracking-tight">
{typeof v === "number"
? tokenPriceUSDFormatter.format(v)
: v}
</p>
);
}}
skeletonData={10.0001}
/>
<SkeletonContainer
loadedData={percentChange24h}
render={(data) => {
if (typeof data === "string") {
return null;
}
const formattedAbsChange = percentChangeFormatter.format(
Math.abs(data),
);
const isAlmostZero =
formattedAbsChange === percentChangeFormatter.format(0);
return (
<Badge
className="gap-2 text-sm"
variant={
isAlmostZero
? "default"
: data > 0
? "success"
: "destructive"
}
>
<div className="flex items-center gap-0.5">
{isAlmostZero ? null : data > 0 ? (
<ArrowUpIcon className="size-3" />
) : (
<ArrowDownIcon className="size-3" />
)}
<span>
{isAlmostZero ? "~0%" : `${formattedAbsChange}%`}
</span>
</div>
<span>(1d)</span>
</Badge>
);
}}
skeletonData={0.001}
/>
<div className="flex items-center gap-3">
<p
className={cn(
"font-bold text-3xl lg:text-4xl tracking-tight",
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
)}
>
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
</p>
<Badge
className="gap-2 lg:text-sm text-xs px-1.5"
variant={
isAlmostZeroChange
? "default"
: percentChange24h > 0
? "success"
: "destructive"
}
>
<div className="flex items-center gap-0.5">
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
<ArrowUpIcon className="size-3" />
) : (
<ArrowDownIcon className="size-3" />
)}
<span>
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
</span>
</div>
<span>(1d)</span>
</Badge>
</div>
</div>
<IntervalSelector interval={interval} setInterval={setInterval} />
</div>
<div className="h-4" />
<PriceChartUI
data={filteredHistoricalPrices || []}
isPending={tokenPriceQuery.isPending}
showTimeOfDay={interval === "24h"}
/>
<div className="px-0">
<PriceChartUI
data={filteredHistoricalPrices || []}
showTimeOfDay={interval === "24h"}
/>
</div>
<div className="mt-8 flex gap-6 border-y border-dashed py-4 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:border-dashed [&>*:not(:first-child)]:pl-5 [&>*]:grow">
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
<TokenStat
label="Market Cap"
skeletonData={"1000000000"}
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
value={
typeof marketCap === "number"
@@ -230,7 +191,6 @@ export function TokenStats(params: {
<TokenStat
label="Number of Holders"
skeletonData={"1000000000"}
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
value={
// show 0 value as N/A
@@ -246,9 +206,8 @@ export function TokenStats(params: {
);
}
function TokenStat<T extends string | number>(props: {
value: T | undefined;
skeletonData: T;
function TokenStat(props: {
value: string | number;
label: string;
tooltip: string;
}) {
@@ -261,17 +220,9 @@ function TokenStat<T extends string | number>(props: {
</ToolTipLabel>
</div>
<div className="flex">
<SkeletonContainer
loadedData={props.value}
render={(v) => {
return (
<p className="font-semibold text-foreground text-xl tracking-tight">
{v}
</p>
);
}}
skeletonData={props.skeletonData}
/>
<p className="font-semibold text-foreground text-xl tracking-tight">
{props.value}
</p>
</div>
</div>
);
@@ -296,7 +247,7 @@ function IntervalSelector(props: {
{Object.entries(intervals).map(([key, value]) => (
<Button
className={cn(
"rounded-full",
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
props.interval === key && "border-active-border bg-accent",
)}
key={key}
@@ -47,8 +47,8 @@ function RecentTransfersUI(props: {
}) {
return (
<div>
<div className="mb-4">
<h2 className="font-semibold text-xl tracking-tight">
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
Recent Transfers
</h2>
<p className="text-muted-foreground text-sm">
@@ -57,7 +57,7 @@ function RecentTransfersUI(props: {
</p>
</div>
<TableContainer className="rounded-b-none">
<TableContainer className="rounded-b-none rounded-t-none">
<Table>
<TableHeader>
<TableRow>
@@ -159,7 +159,7 @@ function RecentTransfersUI(props: {
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
<Button
className="gap-1.5 bg-background"
className="gap-1.5 bg-background rounded-full"
disabled={props.page === 0 || props.isPending}
onClick={() => props.setPage(props.page - 1)}
size="sm"
@@ -169,7 +169,7 @@ function RecentTransfersUI(props: {
Previous
</Button>
<Button
className="gap-1.5 bg-background"
className="gap-1.5 bg-background rounded-full"
disabled={props.isPending || props.data.length < props.rowsPerPage}
onClick={() => props.setPage(props.page + 1)}
size="sm"
@@ -222,23 +222,21 @@ export function RecentTransfers(props: {
});
return (
<div>
<RecentTransfersUI
client={props.clientContract.client}
data={tokenQuery.data ?? []}
explorerUrl={
props.chainMetadata.explorers?.[0]?.url ||
`https://thirdweb.com/${props.chainMetadata.slug}`
}
isPending={tokenQuery.isPending}
page={page}
rowsPerPage={rowsPerPage}
setPage={setPage}
tokenMetadata={{
decimals: props.decimals,
symbol: props.tokenSymbol,
}}
/>
</div>
<RecentTransfersUI
client={props.clientContract.client}
data={tokenQuery.data ?? []}
explorerUrl={
props.chainMetadata.explorers?.[0]?.url ||
`https://thirdweb.com/${props.chainMetadata.slug}`
}
isPending={tokenQuery.isPending}
page={page}
rowsPerPage={rowsPerPage}
setPage={setPage}
tokenMetadata={{
decimals: props.decimals,
symbol: props.tokenSymbol,
}}
/>
);
}
@@ -325,13 +325,13 @@ export function TokenDropClaim(props: {
<div className="h-4" />
<SupplyClaimedProgress
claimedSupply={BigInt(
claimedSupplyTokens={Number(
toTokens(props.claimCondition.supplyClaimed, props.decimals),
)}
totalSupply={
totalSupplyTokens={
props.claimCondition.maxClaimableSupply === maxUint256
? "unlimited"
: BigInt(
: Number(
toTokens(
props.claimCondition.maxClaimableSupply,
props.decimals,
@@ -29,38 +29,38 @@ export function ContractAnalyticsOverview(props: {
});
return (
<div>
<div className="flex flex-col">
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
<p className="mb-3 text-muted-foreground text-sm">
View trends in unique wallets, transactions, and events over time for
this contract
</p>
</div>
<ThirdwebAreaChart
cardContentClassName="p-0"
chartClassName="aspect-[1.2] lg:aspect-[2.6]"
className="border-none bg-background p-0"
config={{
events: {
color: "hsl(var(--chart-3))",
label: "Events",
},
transactions: {
color: "hsl(var(--chart-2))",
label: "Transactions",
},
wallets: {
color: "hsl(var(--chart-1))",
label: "Unique Wallets",
},
}}
data={data || []}
hideLabel={false}
isPending={isPending}
showLegend
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
/>
</div>
<ThirdwebAreaChart
chartClassName="aspect-[1.2] lg:aspect-[3]"
cardContentClassName="px-0 pb-0"
margin={{
bottom: 20,
}}
header={{
headerClassName: "p-4 lg:p-6",
title: "Analytics",
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
description:
"View trends of transactions, events and unique wallets interacting with this contract over time",
}}
config={{
events: {
color: "hsl(var(--chart-5))",
label: "Events",
},
transactions: {
color: "hsl(var(--chart-8))",
label: "Transactions",
},
wallets: {
color: "hsl(var(--chart-1))",
label: "Unique Wallets",
},
}}
data={data || []}
hideLabel={false}
isPending={isPending}
showLegend
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
/>
);
}
@@ -1,56 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { isProd } from "@/constants/env-utils";
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
type TokenPriceData = {
price_usd: number;
price_usd_cents: number;
percent_change_24h: number;
market_cap_usd: number;
volume_24h_usd: number;
volume_change_24h: number;
holders: number;
historical_prices: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
};
export function useTokenPriceData(params: {
chainId: number;
contractAddress: string;
}) {
return useQuery({
queryFn: async () => {
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
);
url.searchParams.set("include_historical_prices", "true");
url.searchParams.set("chain_id", params.chainId.toString());
url.searchParams.set("address", params.contractAddress);
url.searchParams.set("include_holders", "true");
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
const res = await fetch(url);
if (!res.ok) {
throw new Error(await res.text());
}
const json = await res.json();
const priceData = json.data[0] as TokenPriceData | undefined;
return priceData
? {
data: priceData,
type: "data-found" as const,
}
: { type: "no-data" as const };
},
queryKey: ["token-price-chart", params.chainId, params.contractAddress],
refetchInterval: 5000,
refetchOnWindowFocus: false,
retry: false,
retryOnMount: false,
});
}
@@ -1,4 +1,6 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
export async function fetchTokenInfoFromBridge(params: {
chainId: number;
@@ -10,7 +12,7 @@ export async function fetchTokenInfoFromBridge(params: {
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
{
headers: {
"x-client-id": params.clientId,
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
},
);
@@ -2,9 +2,11 @@ import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { getContractMetadata } from "thirdweb/extensions/common";
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
import { GridPattern } from "@/components/ui/background-patterns";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { getContractCreator } from "../_components/getContractCreator";
import { PageHeader } from "../_components/PageHeader";
import { getTokenPriceData } from "./_apis/token-price-data";
import { ContractHeaderUI } from "./_components/ContractHeader";
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
@@ -25,6 +27,7 @@ export async function ERC20PublicPage(props: {
tokenDecimals,
tokenInfo,
functionSelectors,
tokenPriceData,
] = await Promise.all([
getContractMetadata({
contract: props.serverContract,
@@ -39,6 +42,10 @@ export async function ERC20PublicPage(props: {
tokenAddress: props.serverContract.address,
}),
resolveFunctionSelectors(props.serverContract),
getTokenPriceData({
chainId: props.serverContract.chain.id,
contractAddress: props.serverContract.address,
}),
]);
if (!contractMetadata.image && tokenInfo) {
@@ -78,62 +85,73 @@ export async function ERC20PublicPage(props: {
return (
<div className="flex grow flex-col">
<PageHeader />
<div className="container flex max-w-8xl grow flex-col">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
name={contractMetadata.name}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
/>
<PageHeader containerClassName="max-w-5xl" />
<div className="h-6" />
<div className="border-b">
<div className="container max-w-5xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
name={contractMetadata.name}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
/>
</div>
</div>
<div className="flex flex-col gap-8 pb-20 xl:flex-row">
<div className="flex grow flex-col gap-8 overflow-hidden">
{activeClaimCondition ? (
<div className="border-b border-dashed pb-6">
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
</div>
) : (
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
<div className="flex grow flex-col gap-8">
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
/>
<div className="sm:w-[420px] z-10">{buyEmbed}</div>
</div>
{tokenPriceData ? (
<>
<TokenStats
chainId={props.chainMetadata.chainId}
contractAddress={props.clientContract.address}
tokenPriceData={tokenPriceData}
/>
)}
<div className="xl:hidden">{buyEmbed}</div>
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
{!activeClaimCondition && (
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
)}
</div>
<div className="hidden xl:block xl:w-96">
<div className="-mt-6 sticky top-0 pt-6">{buyEmbed}</div>
</div>
</>
) : (
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
)}
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
</div>
</div>
</div>
@@ -17,26 +17,29 @@ export function NFTPublicPageLayout(props: {
return (
<div className="flex grow flex-col">
<PageHeader containerClassName="max-w-8xl" />
<div className="border-b">
<div className="container max-w-8xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={props.contractCreator}
image={
typeof props.contractMetadata.image === "string"
? props.contractMetadata.image
: undefined
}
name={props.contractMetadata.name}
socialUrls={
typeof props.contractMetadata.social_urls === "object" &&
props.contractMetadata.social_urls !== null
? props.contractMetadata.social_urls
: {}
}
symbol={props.contractMetadata.symbol}
/>
</div>
</div>
<div className="container flex max-w-8xl grow flex-col">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={props.contractCreator}
image={
typeof props.contractMetadata.image === "string"
? props.contractMetadata.image
: undefined
}
imageClassName="rounded-lg"
name={props.contractMetadata.name}
socialUrls={
typeof props.contractMetadata.social_urls === "object" &&
props.contractMetadata.social_urls !== null
? props.contractMetadata.social_urls
: {}
}
symbol={props.contractMetadata.symbol}
/>
{props.children}
</div>
</div>
@@ -10,6 +10,7 @@ import type { ChainMetadata } from "thirdweb/chains";
import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
import { claimTo } from "thirdweb/extensions/erc1155";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import * as z from "zod";
import {
reportAssetBuyFailed,
@@ -225,8 +226,12 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
{claimCondition.data ? (
<SupplyClaimedProgress
claimedSupply={claimCondition.data.supplyClaimed}
totalSupply={claimCondition.data.maxClaimableSupply}
claimedSupplyTokens={Number(claimCondition.data.supplyClaimed)}
totalSupplyTokens={
claimCondition.data.maxClaimableSupply === maxUint256
? "unlimited"
: Number(claimCondition.data.maxClaimableSupply)
}
/>
) : (
<Skeleton className="h-[62px] w-full" />
@@ -231,8 +231,10 @@ export function BuyNFTDropUI(props: BuyNFTDropUIProps) {
)}
<SupplyClaimedProgress
claimedSupply={BigInt(props.totalNFTs) - props.totalUnclaimedSupply}
totalSupply={BigInt(props.totalNFTs)}
claimedSupplyTokens={Number(
BigInt(props.totalNFTs) - props.totalUnclaimedSupply,
)}
totalSupplyTokens={Number(props.totalNFTs)}
/>
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/50 p-3">
@@ -2,11 +2,7 @@ import "server-only";
import { notFound } from "next/navigation";
import type { ChainMetadata } from "thirdweb/chains";
import {
getChainServices,
getChains,
getGasSponsoredChains,
} from "@/api/chain";
import { getChainServices, getChains } from "@/api/chain";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { ChainMetadataWithServices, ChainServices } from "@/types/chain";
import type { ChainCTAProps } from "./[chain_id]/(chainPage)/components/server/cta-card";
@@ -25,7 +21,6 @@ import cotiBanner from "./temp-assets/COTI_Banner.jpg";
import cotiCTA from "./temp-assets/COTI_CTA.jpg";
import creatorBanner from "./temp-assets/creatorBanner.png";
import creatorCTA from "./temp-assets/creatorCTA.png";
import superchainCTABG from "./temp-assets/cta-bg-superchain.png";
import xaiCTABg from "./temp-assets/cta-bg-xai-connect.png";
import thirdwebCTA from "./temp-assets/cta-thirdweb.png";
import cyberChainBanner from "./temp-assets/cyberChainBanner.png";
@@ -135,17 +130,6 @@ type ExtraChainMetadata = Partial<{
cta: ChainCTAProps;
}>;
// TEMPORARY
const OP_CTA = {
backgroundImageUrl: superchainCTABG.src,
buttonLink: "/team/~/~/settings/credits",
buttonText: "Apply now",
description:
"Successful applicants receive gas grants for use across all supported Optimism Superchain networks. These grants can sponsor gas fees for any onchain activity using our Account Abstraction tools.",
title: "Optimism Superchain App Accelerator",
} satisfies ChainCTAProps;
const chainMetaRecord = {
// Flare
14: {
@@ -933,22 +917,12 @@ const chainMetaRecord = {
export async function getChainMetadata(
chainId: number,
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
const gasSponsoredChains = await getGasSponsoredChains();
const isGasSponsored = gasSponsoredChains.includes(chainId);
// TODO: fetch this from the API
if (chainId in chainMetaRecord) {
return {
...(isGasSponsored ? { cta: OP_CTA, gasSponsored: true } : {}),
// this will OVERRIDE the op CTA if there is a custom one configured
...chainMetaRecord[chainId as keyof typeof chainMetaRecord],
};
} else if (isGasSponsored) {
return {
cta: OP_CTA,
gasSponsored: true,
};
}
return null;
}
@@ -1,12 +1,10 @@
import { redirect } from "next/navigation";
import { getStripeBalance } from "@/actions/stripe-actions";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team";
import { getMemberByAccountId } from "@/api/team-members";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { CreditsInfoCard } from "@/components/billing/PlanCard";
import { Coupons } from "@/components/billing/SubscriptionCoupons/Coupons";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { CreditBalanceSection } from "./components/credit-balance-section.client";
import { PlanInfoCardClient } from "./components/PlanInfoCard.client";
@@ -28,9 +26,8 @@ export default async function Page(props: {
const account = await getValidAccount(pagePath);
const [team, authToken, teamMember] = await Promise.all([
const [team, teamMember] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
getMemberByAccountId(params.team_slug, account.id),
]);
@@ -50,11 +47,6 @@ export default async function Page(props: {
);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const highlightPlan =
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
@@ -84,11 +76,7 @@ export default async function Page(props: {
/>
)}
<CreditsInfoCard
client={client}
teamSlug={team.slug}
twAccount={account}
/>
<CreditsInfoCard />
<Coupons isPaymentSetup={validPayment} teamId={team.id} />
</div>
);
@@ -62,6 +62,10 @@ export async function EcosystemLayoutSlug({
name: "Analytics",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/analytics`,
},
{
name: "Users",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/users`,
},
{
name: "Design (coming soon)",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/#`,
@@ -0,0 +1,40 @@
import { loginRedirect } from "@app/login/loginRedirect";
import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
export default async function EcosystemUsersPage(props: {
params: Promise<{ team_slug: string; slug: string }>;
}) {
const params = await props.params;
const [authToken, ecosystem, team] = await Promise.all([
getAuthToken(),
fetchEcosystem(params.slug, params.team_slug),
getTeamBySlug(params.team_slug),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/ecosystem/${params.slug}/users`);
}
if (!ecosystem || !team) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
ecosystemSlug={ecosystem.slug}
teamId={team.id}
/>
);
}
@@ -1,10 +1,6 @@
"use client";
import { SmartWalletsBillingAlert } from "@app/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/Alerts";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { AAFooter } from "./AAFooterSection";
@@ -44,10 +40,6 @@ export function AccountAbstractionLayout(props: {
{props.hasSmartWalletsWithoutBilling && (
<SmartWalletsBillingAlert teamSlug={props.teamSlug} />
)}
<GasCreditAlert
projectId={props.projectId}
teamSlug={props.teamSlug}
/>
</div>
<div className="h-4" />
@@ -89,40 +81,3 @@ export function AccountAbstractionLayout(props: {
</div>
);
}
function GasCreditAlert(props: { teamSlug: string; projectId: string }) {
return (
<DismissibleAlert
description={
<>
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<UnderlineLink
href="https://thirdweb.com/superchain"
rel="noopener noreferrer"
target="_blank"
>
Learn More
</UnderlineLink>
<div className="mt-4 flex items-center gap-4">
<Button
asChild
className="bg-background"
size="sm"
variant="outline"
>
<Link
className="gap-2"
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
>
Claim your credits <ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
</>
}
localStorageId={`${props.projectId}-gas-credit-alert`}
title="OP Gas Credit Program"
/>
);
}
@@ -62,9 +62,8 @@ function IntegrateAPIKeySection({
<ClientIDSection clientId={clientId} />
{secretKeyMasked && (
<SecretKeySection
projectId={project.id}
project={project}
secretKeyMasked={secretKeyMasked}
teamId={project.teamId}
/>
)}
</div>
@@ -1,13 +1,13 @@
"use client";
import { useState } from "react";
import type { Project } from "@/api/projects";
import { rotateSecretKeyClient } from "@/hooks/useApi";
import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage";
export function SecretKeySection(props: {
secretKeyMasked: string;
teamId: string;
projectId: string;
project: Project;
}) {
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
@@ -31,8 +31,7 @@ export function SecretKeySection(props: {
}}
rotateSecretKey={async () => {
return rotateSecretKeyClient({
projectId: props.projectId,
teamId: props.teamId,
project: props.project,
});
}}
/>
@@ -22,8 +22,14 @@ export function ProjectSidebarLayout(props: {
layoutPath: string;
engineLinkType: "cloud" | "dedicated";
children: React.ReactNode;
isCentralizedWebhooksFeatureFlagEnabled: boolean;
}) {
const { layoutPath, engineLinkType, children } = props;
const {
layoutPath,
engineLinkType,
children,
isCentralizedWebhooksFeatureFlagEnabled,
} = props;
return (
<FullWidthSidebarLayout
@@ -119,8 +125,13 @@ export function ProjectSidebarLayout(props: {
]}
footerSidebarLinks={[
{
href: `${layoutPath}/webhooks`,
href: isCentralizedWebhooksFeatureFlagEnabled
? `${layoutPath}/webhooks`
: `${layoutPath}/webhooks/contracts`,
icon: BellIcon,
isActive: (pathname) => {
return pathname.startsWith(`${layoutPath}/webhooks`);
},
label: (
<span className="flex items-center gap-2">
Webhooks <Badge>New</Badge>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProject, getProjects, type Project } from "@/api/projects";
import { getTeamBySlug, getTeams } from "@/api/team";
@@ -56,10 +57,18 @@ export default async function ProjectLayout(props: {
teamId: team.id,
});
const engineLinkType = await getEngineLinkType({
authToken,
project,
});
const [engineLinkType, isCentralizedWebhooksFeatureFlagEnabled] =
await Promise.all([
getEngineLinkType({
authToken,
project,
}),
isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
}),
]);
const isStaffMode = !teams.some((t) => t.slug === team.slug);
@@ -81,6 +90,9 @@ export default async function ProjectLayout(props: {
<ProjectSidebarLayout
engineLinkType={engineLinkType}
layoutPath={layoutPath}
isCentralizedWebhooksFeatureFlagEnabled={
isCentralizedWebhooksFeatureFlagEnabled
}
>
{props.children}
</ProjectSidebarLayout>
@@ -47,6 +47,7 @@ function Story(props: { isOwnerAccount: boolean }) {
data: {
secret: `sk_${new Array(86).fill("x").join("")}`,
secretMasked: "sk_123...4567",
secretHash: "sk_123...4567",
},
};
}}
@@ -135,8 +135,7 @@ export function ProjectGeneralSettingsPage(props: {
project={props.project}
rotateSecretKey={async () => {
return rotateSecretKeyClient({
projectId: props.project.id,
teamId: props.project.teamId,
project: props.project,
});
}}
showNebulaSettings={props.showNebulaSettings}
@@ -42,16 +42,29 @@ export type CreateNFTCollectionFunctions = {
address: string;
}[];
chain: string;
gasless: boolean;
}) => Promise<void>;
erc721: {
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
deployContract: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (values: CreateNFTCollectionAllValues) => Promise<void>;
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
setClaimConditions: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
};
erc1155: {
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
deployContract: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: {
@@ -60,8 +73,12 @@ export type CreateNFTCollectionFunctions = {
startIndex: number;
count: number;
};
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
};
};
@@ -21,6 +21,7 @@ import {
import { grantRole } from "thirdweb/extensions/permissions";
import { useActiveAccount } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
@@ -42,15 +43,26 @@ export function CreateNFTPage(props: {
teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
function getContractAndAccount(params: { chain: string }) {
function getAccount(params: { gasless: boolean }) {
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
if (params.gasless) {
return create7702MinimalAccount({
adminAccount: activeAccount,
client: props.client,
sponsorGas: true,
});
}
return activeAccount;
}
function getDeployedContract(params: { chain: string }) {
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(params.chain));
@@ -60,30 +72,26 @@ export function CreateNFTPage(props: {
throw new Error("Contract not found");
}
const contract = getContract({
return getContract({
address: contractAddress,
chain,
client: props.client,
});
return {
activeAccount,
contract,
};
}
async function handleContractDeploy(params: {
values: CreateNFTCollectionAllValues;
ercType: "erc721" | "erc1155";
gasless: boolean;
}) {
const { values: formValues, ercType } = params;
const { collectionInfo, sales } = formValues;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
const account = getAccount({
gasless: params.gasless,
});
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(collectionInfo.chain));
@@ -93,7 +101,7 @@ export function CreateNFTPage(props: {
if (ercType === "erc721") {
contractAddress = await deployERC721Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -110,7 +118,7 @@ export function CreateNFTPage(props: {
});
} else {
contractAddress = await deployERC1155Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -166,27 +174,32 @@ export function CreateNFTPage(props: {
}
async function handleLazyMintNFTs(params: {
formValues: CreateNFTCollectionAllValues;
values: CreateNFTCollectionAllValues;
ercType: "erc721" | "erc1155";
gasless: boolean;
}) {
const { formValues, ercType } = params;
const { values, ercType } = params;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
const { contract, activeAccount } = getContractAndAccount({
chain: formValues.collectionInfo.chain,
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const account = getAccount({
gasless: params.gasless,
});
const lazyMintFn = ercType === "erc721" ? lazyMint721 : lazyMint1155;
const transaction = lazyMintFn({
contract,
nfts: formValues.nfts,
nfts: values.nfts,
});
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -205,14 +218,19 @@ export function CreateNFTPage(props: {
}
async function handleSetClaimConditionsERC721(params: {
formValues: CreateNFTCollectionAllValues;
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) {
const { formValues } = params;
const { contract, activeAccount } = getContractAndAccount({
chain: formValues.collectionInfo.chain,
const { values } = params;
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const firstNFT = formValues.nfts[0];
const account = getAccount({
gasless: params.gasless,
});
const firstNFT = values.nfts[0];
if (!firstNFT) {
throw new Error("No NFTs found");
}
@@ -235,7 +253,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -259,12 +277,17 @@ export function CreateNFTPage(props: {
startIndex: number;
count: number;
};
gasless: boolean;
}) {
const { values, batch } = params;
const { contract, activeAccount } = getContractAndAccount({
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const account = getAccount({
gasless: params.gasless,
});
const endIndexExclusive = batch.startIndex + batch.count;
const nfts = values.nfts.slice(batch.startIndex, endIndexExclusive);
@@ -315,7 +338,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (error) {
@@ -339,15 +362,20 @@ export function CreateNFTPage(props: {
admins: {
address: string;
}[];
gasless: boolean;
chain: string;
}) {
const { contract, activeAccount } = getContractAndAccount({
const contract = getDeployedContract({
chain: params.chain,
});
const account = getAccount({
gasless: params.gasless,
});
// remove the current account from the list - its already an admin, don't have to add it again
const adminsToAdd = params.admins.filter(
(admin) => admin.address !== activeAccount.address,
(admin) => admin.address !== account.address,
);
const encodedTxs = await Promise.all(
@@ -369,7 +397,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (e) {
@@ -392,35 +420,35 @@ export function CreateNFTPage(props: {
{...props}
createNFTFunctions={{
erc721: {
deployContract: (formValues) => {
deployContract: (params) => {
return handleContractDeploy({
ercType: "erc721",
values: formValues,
...params,
});
},
lazyMintNFTs: (formValues) => {
lazyMintNFTs: (params) => {
return handleLazyMintNFTs({
ercType: "erc721",
formValues,
...params,
});
},
setClaimConditions: async (formValues) => {
setClaimConditions: async (params) => {
return handleSetClaimConditionsERC721({
formValues,
...params,
});
},
},
erc1155: {
deployContract: (formValues) => {
deployContract: (params) => {
return handleContractDeploy({
ercType: "erc1155",
values: formValues,
...params,
});
},
lazyMintNFTs: (formValues) => {
lazyMintNFTs: (params) => {
return handleLazyMintNFTs({
ercType: "erc1155",
formValues,
...params,
});
},
setClaimConditions: async (params) => {
@@ -19,6 +19,7 @@ import {
reportAssetCreationSuccessful,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -154,18 +155,28 @@ export function LaunchNFT(props: {
return shouldDeployERC721 ? "erc721" : "erc1155";
}, [formValues.nfts]);
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const contractLink = contractAddressRef.current
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
: null;
async function executeStep(steps: MultiStepState<StepId>[], stepId: StepId) {
if (stepId === "deploy-contract") {
const result =
await props.createNFTFunctions[ercType].deployContract(formValues);
const result = await props.createNFTFunctions[ercType].deployContract({
gasless: isGasless,
values: formValues,
});
contractAddressRef.current = result.contractAddress;
} else if (stepId === "set-claim-conditions") {
if (ercType === "erc721") {
await props.createNFTFunctions.erc721.setClaimConditions(formValues);
await props.createNFTFunctions.erc721.setClaimConditions({
gasless: isGasless,
values: formValues,
});
} else {
if (batchCount > 1) {
const batchStartIndex = batchesProcessedRef.current;
@@ -190,6 +201,7 @@ export function LaunchNFT(props: {
count: batchSize,
startIndex: batchIndex * batchSize,
},
gasless: isGasless,
values: formValues,
});
@@ -201,12 +213,16 @@ export function LaunchNFT(props: {
count: formValues.nfts.length,
startIndex: 0,
},
gasless: isGasless,
values: formValues,
});
}
}
} else if (stepId === "mint-nfts") {
await props.createNFTFunctions[ercType].lazyMintNFTs(formValues);
await props.createNFTFunctions[ercType].lazyMintNFTs({
gasless: isGasless,
values: formValues,
});
} else if (stepId === "set-admins") {
// this is type guard, this can never happen
if (!contractAddressRef.current) {
@@ -218,6 +234,7 @@ export function LaunchNFT(props: {
chain: formValues.collectionInfo.chain,
contractAddress: contractAddressRef.current,
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
gasless: isGasless,
});
}
}
@@ -311,6 +328,7 @@ export function LaunchNFT(props: {
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -319,7 +337,7 @@ export function LaunchNFT(props: {
variant="default"
>
<ArrowUpFromLineIcon className="size-4" />
Launch NFT
Launch NFT Collection
</TransactionButton>
),
type: "custom",
@@ -327,7 +345,7 @@ export function LaunchNFT(props: {
prevButton={{
onClick: props.onPrevious,
}}
title="Launch NFT"
title="Launch NFT Collection"
>
<Dialog
open={isModalOpen}
@@ -468,6 +486,7 @@ export function LaunchNFT(props: {
</div>
</div>
{/* NFTs */}
<div className="border-b border-dashed px-4 py-6 pb-6 md:px-6">
<h2 className="font-semibold text-base">NFTs</h2>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
@@ -509,6 +528,7 @@ export function LaunchNFT(props: {
</div>
</div>
{/* sales and fees */}
<div className="px-4 py-6 pb-6 md:px-6">
<h2 className="font-semibold text-base">Sales and Fees</h2>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
@@ -537,6 +557,31 @@ export function LaunchNFT(props: {
</OverviewField>
</div>
</div>
{/* gasless */}
{showGaslessSection && (
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Sponsor Gas</h2>
<p className="text-muted-foreground text-sm">
Sponsor gas fees for launching your NFT collection. <br /> This
allows you to launch the NFT collection without requiring any
balance in your wallet
</p>
</div>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
onCheckedChange: setIsGasless,
}}
teamSlug={props.teamSlug}
/>
</div>
</div>
)}
</StepCard>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useRef } from "react";
import { toast } from "sonner";
import {
defineChain,
getAddress,
getContract,
NATIVE_TOKEN_ADDRESS,
@@ -18,6 +18,7 @@ import {
transferBatch,
} from "thirdweb/extensions/erc20";
import { useActiveAccount } from "thirdweb/react";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
@@ -45,18 +46,52 @@ export function CreateTokenAssetPage(props: {
projectSlug: string;
teamPlan: Team["billingPlan"];
}) {
const account = useActiveAccount();
const activeAccount = useActiveAccount();
const { idToChain } = useAllChainsData();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
async function deployContract(formValues: CreateAssetFormValues) {
if (!account) {
toast.error("No Connected Wallet");
function getAccount(gasless: boolean) {
if (!activeAccount) {
throw new Error("No Connected Wallet");
}
const socialUrls = formValues.socialUrls.reduce(
if (gasless) {
return create7702MinimalAccount({
adminAccount: activeAccount,
client: props.client,
sponsorGas: true,
});
}
return activeAccount;
}
function getDeployedContract(params: { chain: string }) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("Contract address not set");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(params.chain));
return getContract({
address: contractAddress,
chain,
client: props.client,
});
}
async function deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const socialUrls = values.socialUrls.reduce(
(acc, url) => {
if (url.url && url.platform) {
acc[url.platform] = url.url;
@@ -71,29 +106,31 @@ export function CreateTokenAssetPage(props: {
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
Number(values.chain),
idToChain.get(Number(values.chain)),
),
client: props.client,
params: {
description: formValues.description,
image: formValues.image,
description: values.description,
image: values.image,
// metadata
name: formValues.name,
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: formValues.symbol,
symbol: values.symbol,
},
type: "DropERC20",
});
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: formValues.chain,
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
@@ -103,18 +140,17 @@ export function CreateTokenAssetPage(props: {
reportContractDeployed({
address: contractAddress,
chainId: Number(formValues.chain),
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
contractAddressRef.current = contractAddress;
return {
contractAddress: contractAddress,
};
} catch (e) {
console.error(e);
const parsedError = parseError(e);
const errorMessage =
typeof parsedError === "string" ? parsedError : "Unknown error";
@@ -131,32 +167,25 @@ export function CreateTokenAssetPage(props: {
}
}
async function airdropTokens(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
async function airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
if (!contractAddress) {
throw new Error("No contract address");
}
const contract = getDeployedContract({
chain: values.chain,
});
const account = getAccount(gasless);
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
});
try {
const airdropTx = transferBatch({
batch: formValues.airdropAddresses.map((recipient) => ({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
@@ -181,28 +210,18 @@ export function CreateTokenAssetPage(props: {
}
}
async function mintTokens(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("No contract address");
}
async function mintTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
const contract = getDeployedContract({
chain: values.chain,
});
const account = getAccount(gasless);
// poll until claim conditions are set before moving on to minting
await pollWithTimeout({
shouldStop: async () => {
@@ -214,9 +233,9 @@ export function CreateTokenAssetPage(props: {
timeoutMs: 30000,
});
const totalSupply = Number(formValues.supply);
const salePercent = formValues.saleEnabled
? Number(formValues.saleAllocationPercentage)
const totalSupply = Number(values.supply);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
: 0;
const ownerAndAirdropPercent = 100 - salePercent;
@@ -248,48 +267,36 @@ export function CreateTokenAssetPage(props: {
}
}
async function setClaimConditions(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("No contract address");
}
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
async function setClaimConditions(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const contract = getDeployedContract({
chain: values.chain,
});
const salePercent = formValues.saleEnabled
? Number(formValues.saleAllocationPercentage)
const account = getAccount(gasless);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
: 0;
const totalSupply = Number(formValues.supply);
const totalSupply = Number(values.supply);
const totalSupplyWei = toUnits(totalSupply.toString(), 18);
const phases: ClaimConditionsInput[] = [
{
currencyAddress:
getAddress(formValues.saleTokenAddress) ===
getAddress(values.saleTokenAddress) ===
getAddress(NATIVE_TOKEN_ADDRESS)
? undefined
: formValues.saleTokenAddress,
maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n,
: values.saleTokenAddress,
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
maxClaimableSupply: totalSupplyWei,
metadata: {
name:
formValues.saleEnabled && salePercent > 0
values.saleEnabled && salePercent > 0
? "Coin Sale phase"
: "Only Owner phase",
},
@@ -300,10 +307,7 @@ export function CreateTokenAssetPage(props: {
price: "0",
},
],
price:
formValues.saleEnabled && salePercent > 0
? formValues.salePrice
: "0",
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
startTime: new Date(),
},
];
@@ -22,13 +22,18 @@ import { TokenDistributionFieldset } from "./distribution/token-distribution";
import { LaunchTokenStatus } from "./launch/launch-token";
import { TokenInfoFieldset } from "./token-info/token-info-fieldset";
type CreateTokenFunctionsParams = {
values: CreateAssetFormValues;
gasless: boolean;
};
export type CreateTokenFunctions = {
deployContract: (values: CreateAssetFormValues) => Promise<{
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (values: CreateAssetFormValues) => Promise<void>;
mintTokens: (values: CreateAssetFormValues) => Promise<void>;
airdropTokens: (values: CreateAssetFormValues) => Promise<void>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
};
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
@@ -13,6 +13,7 @@ import {
reportAssetCreationSuccessful,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import {
type MultiStepState,
MultiStepStatus,
@@ -26,6 +27,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { parseError } from "@/utils/errorParser";
import { ChainOverview } from "../../_common/chain-overview";
import { FilePreview } from "../../_common/file-preview";
@@ -62,6 +64,13 @@ export function LaunchTokenStatus(props: {
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const { idToChain } = useAllChainsData();
const chainMetadata = idToChain.get(Number(formValues.chain));
function updateStatus(
index: number,
newStatus: MultiStepState<StepId>["status"],
@@ -104,30 +113,36 @@ export function LaunchTokenStatus(props: {
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0);
executeSteps(initialSteps, 0, isGasless);
}
const isComplete = steps.every((step) => step.status.type === "completed");
const isPending = steps.some((step) => step.status.type === "pending");
async function executeStep(stepId: StepId) {
async function executeStep(stepId: StepId, gasless: boolean) {
const params = {
gasless,
values: formValues,
};
if (stepId === "deploy-contract") {
const result = await createTokenFunctions.deployContract(formValues);
const result = await createTokenFunctions.deployContract(params);
setContractLink(
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
);
} else if (stepId === "set-claim-conditions") {
await createTokenFunctions.setClaimConditions(formValues);
await createTokenFunctions.setClaimConditions(params);
} else if (stepId === "mint-tokens") {
await createTokenFunctions.mintTokens(formValues);
await createTokenFunctions.mintTokens(params);
} else if (stepId === "airdrop-tokens") {
await createTokenFunctions.airdropTokens(formValues);
await createTokenFunctions.airdropTokens(params);
}
}
async function executeSteps(
steps: MultiStepState<StepId>[],
startIndex: number,
gasless: boolean,
) {
for (let i = startIndex; i < steps.length; i++) {
const currentStep = steps[i];
@@ -140,7 +155,7 @@ export function LaunchTokenStatus(props: {
type: "pending",
});
await executeStep(currentStep.id);
await executeStep(currentStep.id, gasless);
updateStatus(i, {
type: "completed",
@@ -172,20 +187,22 @@ export function LaunchTokenStatus(props: {
props.onLaunchSuccess();
}
async function handleRetry(step: MultiStepState<StepId>) {
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
const startIndex = steps.findIndex((s) => s.id === step.id);
if (startIndex === -1) {
return;
}
await executeSteps(steps, startIndex);
await executeSteps(steps, startIndex, gasless);
}
return (
<StepCard
nextButton={{
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -253,6 +270,30 @@ export function LaunchTokenStatus(props: {
<TokenDistributionBarChart distributionFormValues={formValues} />
</div>
{/* gasless */}
{showGaslessSection && (
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Sponsor Gas</h2>
<p className="text-muted-foreground text-sm">
Sponsor gas fees for launching your coin. <br /> This allows you
to launch the coin without requiring any balance in your wallet
</p>
</div>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
onCheckedChange: setIsGasless,
}}
teamSlug={props.teamSlug}
/>
</div>
</div>
)}
<Dialog
open={isModalOpen}
// do not set onOpenChange
@@ -274,7 +315,7 @@ export function LaunchTokenStatus(props: {
</DialogHeader>
<MultiStepStatus
onRetry={handleRetry}
onRetry={(step) => handleRetry(step, isGasless)}
renderError={(step, errorMessage) => {
if (
props.teamPlan === "free" &&
@@ -282,13 +323,40 @@ export function LaunchTokenStatus(props: {
) {
return (
<StorageErrorPlanUpsell
onRetry={() => handleRetry(step)}
onRetry={() => handleRetry(step, isGasless)}
teamSlug={props.teamSlug}
trackingCampaign="create-coin"
/>
);
}
if (
errorMessage
.toLowerCase()
.includes("does not support eip-7702")
) {
return (
<div>
<p className="text-red-500 text-sm mb-2">
Gas Sponsorship is not supported on{" "}
{chainMetadata?.name || "selected chain"}
</p>
<Button
size="sm"
className="gap-2"
onClick={() => {
setIsGasless(false);
handleRetry(step, false);
}}
>
Continue without gas sponsorship
<ArrowRightIcon className="size-4" />
</Button>
</div>
);
}
return null;
}}
steps={steps}
@@ -1,15 +1,11 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/projects";
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
import { Button } from "@/components/ui/button";
import { CreateVaultAccountButton } from "../../vault/components/create-vault-account.client";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SendTestTransaction } from "./send-test-tx.client";
import { deleteUserAccessToken } from "./utils";
interface Props {
managementAccessToken: string | undefined;
@@ -19,27 +15,12 @@ interface Props {
teamSlug: string;
testTxWithWallet?: string | undefined;
client: ThirdwebClient;
isManagedVault: boolean;
}
export const EngineChecklist: React.FC<Props> = (props) => {
const [userAccessToken, setUserAccessToken] = useState<string | undefined>();
const finalSteps = useMemo(() => {
const steps: Step[] = [];
steps.push({
children: (
<CreateVaultAccountStep
onUserAccessTokenCreated={(token) => setUserAccessToken(token)}
project={props.project}
teamSlug={props.teamSlug}
/>
),
completed: !!props.managementAccessToken,
description:
"Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.",
showCompletedChildren: false,
title: "Create a Vault Admin Account",
});
steps.push({
children: (
<CreateServerWalletStep
@@ -48,7 +29,7 @@ export const EngineChecklist: React.FC<Props> = (props) => {
teamSlug={props.teamSlug}
/>
),
completed: props.wallets.length > 0,
completed: props.wallets.length > 0 || props.hasTransactions,
description:
"Server wallets are smart wallets, they don't require any gas funds to send transactions.",
showCompletedChildren: false,
@@ -58,10 +39,10 @@ export const EngineChecklist: React.FC<Props> = (props) => {
steps.push({
children: (
<SendTestTransaction
isManagedVault={props.isManagedVault}
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
userAccessToken={userAccessToken}
wallets={props.wallets}
/>
),
@@ -78,9 +59,9 @@ export const EngineChecklist: React.FC<Props> = (props) => {
props.project,
props.wallets,
props.hasTransactions,
userAccessToken,
props.teamSlug,
props.client,
props.isManagedVault,
]);
const isComplete = useMemo(
@@ -91,10 +72,10 @@ export const EngineChecklist: React.FC<Props> = (props) => {
if (props.testTxWithWallet) {
return (
<SendTestTransaction
isManagedVault={props.isManagedVault}
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
userAccessToken={userAccessToken}
walletId={props.testTxWithWallet}
wallets={props.wallets}
/>
@@ -102,8 +83,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
}
if (finalSteps.length === 0 || isComplete) {
// clear token from local storage after FTUX is complete
deleteUserAccessToken(props.project.id);
return null;
}
return (
@@ -115,30 +94,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
);
};
function CreateVaultAccountStep(props: {
project: Project;
teamSlug: string;
onUserAccessTokenCreated: (userAccessToken: string) => void;
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateVaultAccountButton
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
project={props.project}
/>
<Button asChild variant="outline">
<Link
href="https://portal.thirdweb.com/vault"
rel="noopener noreferrer"
target="_blank"
>
Learn more about Vault
</Link>
</Button>
</div>
);
}
function CreateServerWalletStep(props: {
project: Project;
teamSlug: string;
@@ -11,7 +11,14 @@ import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -22,39 +29,35 @@ import {
} from "@/components/ui/select";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { TryItOut } from "../server-wallets/components/try-it-out";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
import { deleteUserAccessToken, getUserAccessToken } from "./utils";
const formSchema = z.object({
accessToken: z.string().min(1, "Access token is required"),
secretKey: z.string().min(1, "Secret key is required"),
chainId: z.number(),
walletIndex: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
export function SendTestTransaction(props: {
function SendTestTransactionModal(props: {
wallets?: Wallet[];
project: Project;
teamSlug: string;
userAccessToken?: string;
expanded?: boolean;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const [hasSentTx, setHasSentTx] = useState(false);
const router = useDashboardRouter();
const chainsQuery = useAllChainsData();
const userAccessToken =
props.userAccessToken ?? getUserAccessToken(props.project.id) ?? "";
const form = useForm<FormValues>({
defaultValues: {
accessToken: userAccessToken,
secretKey: "",
chainId: 84532,
walletIndex:
props.wallets && props.walletId
@@ -73,7 +76,7 @@ export function SendTestTransaction(props: {
const sendDummyTxMutation = useMutation({
mutationFn: async (args: {
walletAddress: string;
accessToken: string;
secretKey: string;
chainId: number;
}) => {
const response = await engineCloudProxy({
@@ -93,7 +96,9 @@ export function SendTestTransaction(props: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
"x-vault-access-token": args.accessToken,
...(props.isManagedVault
? { "x-secret-key": args.secretKey }
: { "x-vault-access-token": args.secretKey }),
},
method: "POST",
pathname: "/v1/write/transaction",
@@ -111,6 +116,10 @@ export function SendTestTransaction(props: {
},
onSuccess: () => {
toast.success("Test transaction sent successfully!");
// Close the modal after successful transaction
setTimeout(() => {
props.onOpenChange(false);
}, 1000);
},
});
@@ -123,161 +132,193 @@ export function SendTestTransaction(props: {
const onSubmit = async (data: FormValues) => {
await sendDummyTxMutation.mutateAsync({
accessToken: data.accessToken,
secretKey: data.secretKey,
chainId: data.chainId,
walletAddress: selectedWallet.address,
});
queryClient.invalidateQueries({
queryKey: ["transactions", props.project.id],
});
setHasSentTx(true);
};
return (
<div className="mt-3 w-full rounded-md border bg-background p-6">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Send a test transaction</DialogTitle>
<DialogDescription>
Test your server wallet configuration by sending a transaction
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)}>
{props.walletId && (
<h3 className="mb-4 font-medium text-lg">Send a test transaction</h3>
)}
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" />
{userAccessToken
? "Copy your Vault access token, you'll need it for every HTTP call to Engine."
: "Every wallet action requires your Vault access token."}
</p>
<div className="h-4" />
{/* Responsive container */}
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex-grow">
<div className="flex flex-col gap-2">
<p className="text-sm">Vault Access Token</p>
{userAccessToken ? (
<div className="flex flex-col gap-2 ">
<CopyTextButton
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
copyIconPosition="right"
textToCopy={userAccessToken}
textToShow={userAccessToken}
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This is a project-wide access token to access your server
wallets. You can create more access tokens using your admin
key, with granular scopes and permissions.
</p>
</div>
) : (
<Input
placeholder="vt_act_1234....ABCD"
type={userAccessToken ? "text" : "password"}
{...form.register("accessToken")}
className="text-xs"
disabled={isLoading}
/>
)}
</div>
<div className="space-y-6">
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" />
Every server wallet action requires your{" "}
{props.isManagedVault ? "project secret key" : "vault access token"}
.
</p>
{/* Secret Key Input */}
<div className="flex flex-col gap-2">
<p className="text-sm">
{props.isManagedVault
? "Project Secret Key"
: "Vault Access Token"}
</p>
<Input
placeholder={
props.isManagedVault
? "Enter your project secret key"
: "Enter your vault access token"
}
type={"password"}
{...form.register("secretKey")}
className="text-xs"
disabled={isLoading}
/>
<p className="text-muted-foreground text-xs">
{props.isManagedVault
? "Your project secret key was generated when you created your project. If you lost it, you can regenerate one in the project settings."
: "Your vault access token was generated when you created your vault. If you lost it, you can regenerate one in the vault settings."}
</p>
</div>
</div>
<div className="h-4" />
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex flex-1 flex-col gap-2">
<p className="text-sm">Server Wallet</p>
<Select
onValueChange={(value) => form.setValue("walletIndex", value)}
value={form.watch("walletIndex")}
>
<SelectTrigger className="w-full">
<SelectValue>
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<p className="text-sm">Server Wallet</p>
<Select
onValueChange={(value) => form.setValue("walletIndex", value)}
value={form.watch("walletIndex")}
>
<SelectTrigger className="w-full">
<SelectValue>
<div className="flex items-center gap-2">
<SmartAccountCell
client={props.client}
wallet={selectedWallet}
/>
<span className="text-muted-foreground text-sm">
{selectedWallet.metadata.label}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.wallets.map((wallet, index) => (
<SelectItem key={wallet.address} value={index.toString()}>
<div className="flex items-center gap-2">
<SmartAccountCell
client={props.client}
wallet={selectedWallet}
/>
<SmartAccountCell client={props.client} wallet={wallet} />
<span className="text-muted-foreground text-sm">
{selectedWallet.metadata.label}
{wallet.metadata.label}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.wallets.map((wallet, index) => (
<SelectItem key={wallet.address} value={index.toString()}>
<div className="flex items-center gap-2">
<SmartAccountCell
client={props.client}
wallet={wallet}
/>
<span className="text-muted-foreground text-sm">
{wallet.metadata.label}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-col gap-2">
<p className="text-sm">Select Testnet</p>
<SingleNetworkSelector
chainId={form.watch("chainId")}
chainIds={chainsQuery.allChains
.filter(
(chain) =>
chain.testnet === true &&
chain.stackType !== "zksync_stack",
)
.map((chain) => chain.chainId)}
className="bg-background"
client={props.client}
onChange={(chainId) => {
form.setValue("chainId", chainId);
}}
/>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="h-6" />
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Button
className="w-full min-w-[200px] md:w-auto"
disabled={isLoading || !form.formState.isValid}
type="submit"
variant={hasSentTx ? "secondary" : "primary"}
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : hasSentTx ? (
"Send More Transactions"
) : (
"Send Transaction"
)}
</Button>
{hasSentTx && (
{/* Testnet Selector */}
<div className="flex flex-col gap-2">
<p className="text-sm">Select Testnet</p>
<SingleNetworkSelector
chainId={form.watch("chainId")}
chainIds={chainsQuery.allChains
.filter(
(chain) =>
chain.testnet === true &&
chain.stackType !== "zksync_stack",
)
.map((chain) => chain.chainId)}
className="bg-background"
client={props.client}
onChange={(chainId) => {
form.setValue("chainId", chainId);
}}
/>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Button
className="w-full md:w-auto"
onClick={() => {
if (props.walletId) {
router.replace(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
);
} else {
router.refresh();
}
// clear token from local storage after FTUX is complete
deleteUserAccessToken(props.project.id);
}}
onClick={() => props.onOpenChange(false)}
variant="secondary"
type="button"
>
Close
</Button>
<Button
className="w-full min-w-[200px] md:w-auto"
disabled={isLoading || !form.formState.isValid}
type="submit"
variant="primary"
>
{props.walletId ? "Close" : "Complete Setup"}
{isLoading ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
"Send Transaction"
)}
</Button>
)}
</div>
</div>
</form>
</DialogContent>
);
}
export function SendTestTransaction(props: {
wallets?: Wallet[];
project: Project;
teamSlug: string;
expanded?: boolean;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useDashboardRouter();
// Early return in render phase
if (!props.wallets || props.wallets.length === 0) {
return null;
}
return (
<div className="mt-3 w-full rounded-md border bg-background p-6">
<TryItOut />
<div className="mt-6 flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button variant="secondary" className="w-full md:w-auto">
Test Request
</Button>
</DialogTrigger>
<SendTestTransactionModal
{...props}
open={isModalOpen}
onOpenChange={setIsModalOpen}
/>
</Dialog>
<Button
className="w-full md:w-auto"
onClick={() => {
if (props.walletId) {
router.replace(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
);
} else {
router.refresh();
}
}}
variant="primary"
>
{props.walletId ? "Close" : "Complete Setup"}
</Button>
</div>
</div>
);
}
@@ -64,7 +64,6 @@ export function TransactionsTableUI(props: {
const pageSize = 10;
const transactionsQuery = useQuery({
enabled: !!props.wallets && props.wallets.length > 0,
placeholderData: keepPreviousData,
queryFn: () => props.getData({ page, status }),
queryKey: ["transactions", props.project.id, page, status],
@@ -1,21 +0,0 @@
export function storeUserAccessToken(projectId: string, accessToken: string) {
localStorage.setItem(
`thirdweb:engine-cloud-user-access-token-${projectId}`,
accessToken,
);
}
export function getUserAccessToken(projectId: string) {
if (typeof localStorage === "undefined") {
return null;
}
return localStorage.getItem(
`thirdweb:engine-cloud-user-access-token-${projectId}`,
);
}
export function deleteUserAccessToken(projectId: string) {
localStorage.removeItem(
`thirdweb:engine-cloud-user-access-token-${projectId}`,
);
}
@@ -1,8 +1,11 @@
"use client";
import { encrypt } from "@thirdweb-dev/service-utils";
import {
createAccessToken,
createServiceAccount,
createVaultClient,
rotateServiceAccount,
type VaultClient,
} from "@thirdweb-dev/vault-sdk";
import type { Project } from "@/api/projects";
@@ -27,6 +30,201 @@ export async function initVaultClient() {
return vc;
}
export async function rotateVaultAccountAndAccessToken(props: {
project: Project;
projectSecretKey?: string;
projectSecretHash?: string;
}) {
const vaultClient = await initVaultClient();
const service = props.project.services.find(
(service) => service.name === "engineCloud",
);
const storedRotationCode = service?.rotationCode;
if (!storedRotationCode) {
throw new Error("No rotation code found");
}
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
auth: {
rotationCode: storedRotationCode,
},
},
});
if (rotateServiceAccountRes.error) {
throw new Error(rotateServiceAccountRes.error.message);
}
const adminKey = rotateServiceAccountRes.data.newAdminKey;
const rotationCode = rotateServiceAccountRes.data.newRotationCode;
const { managementToken, walletToken } =
await createAndEncryptVaultAccessTokens({
project: props.project,
projectSecretKey: props.projectSecretKey,
projectSecretHash: props.projectSecretHash,
vaultClient,
adminKey,
rotationCode,
});
return {
adminKey,
managementToken,
walletToken,
};
}
export async function createVaultAccountAndAccessToken(props: {
project: Project;
projectSecretKey?: string;
projectSecretHash?: string;
}) {
try {
const vaultClient = await initVaultClient();
const serviceAccountResult = await createServiceAccount({
client: vaultClient,
request: {
options: {
metadata: {
projectId: props.project.id,
purpose: "Thirdweb Project Server Wallet Service Account",
teamId: props.project.teamId,
},
},
},
});
if (serviceAccountResult.success === false) {
throw new Error(
`Failed to create service account: ${serviceAccountResult.error}`,
);
}
const serviceAccount = serviceAccountResult.data;
const adminKey = serviceAccount.adminKey;
const rotationCode = serviceAccount.rotationCode;
const { managementToken, walletToken } =
await createAndEncryptVaultAccessTokens({
project: props.project,
projectSecretKey: props.projectSecretKey,
projectSecretHash: props.projectSecretHash,
vaultClient,
adminKey,
rotationCode,
});
return {
adminKey,
managementToken,
walletToken,
};
} catch (error) {
throw new Error(
`Failed to create vault account and access token: ${error}`,
);
}
}
async function createAndEncryptVaultAccessTokens(props: {
project: Project;
vaultClient: VaultClient;
projectSecretKey?: string;
projectSecretHash?: string;
adminKey: string;
rotationCode: string;
}) {
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
props;
const [managementTokenResult, walletTokenResult] = await Promise.all([
createManagementAccessToken({ project, adminKey, vaultClient }),
createWalletAccessToken({ project, adminKey, vaultClient }),
]);
if (!managementTokenResult.success) {
throw new Error(
`Failed to create management token: ${managementTokenResult.error}`,
);
}
if (!walletTokenResult.success) {
throw new Error(
`Failed to create wallet token: ${walletTokenResult.error}`,
);
}
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
if (projectSecretKey) {
// verify that the project secret key is valid
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
const secretKeysHashed = [
...project.secretKeys,
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
];
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
throw new Error("Invalid project secret key");
}
// encrypt admin key and wallet token with project secret key
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
encrypt(adminKey, projectSecretKey),
encrypt(walletToken.accessToken, projectSecretKey),
]);
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey,
encryptedWalletAccessToken,
rotationCode: rotationCode,
},
],
},
);
} else {
// no secret key, only store the management token, remove any encrypted keys
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey: null,
encryptedWalletAccessToken: null,
rotationCode: rotationCode,
},
],
},
);
}
return {
managementToken,
walletToken,
};
}
export async function createWalletAccessToken(props: {
project: Project;
adminKey: string;
@@ -164,6 +362,29 @@ export async function createWalletAccessToken(props: {
],
type: "eoa:signTypedData",
},
{
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "eoa:signAuthorization",
},
{
metadataPatterns: [
{
@@ -243,13 +464,12 @@ export async function createWalletAccessToken(props: {
});
}
export async function createManagementAccessToken(props: {
async function createManagementAccessToken(props: {
project: Project;
adminKey: string;
rotationCode: string;
vaultClient: VaultClient;
}) {
const res = await createAccessToken({
return createAccessToken({
client: props.vaultClient,
request: {
auth: {
@@ -333,31 +553,26 @@ export async function createManagementAccessToken(props: {
},
},
});
if (res.success) {
const data = res.data;
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
actions: [],
managementAccessToken: data.accessToken,
maskedAdminKey: maskSecret(props.adminKey),
name: "engineCloud",
rotationCode: props.rotationCode,
},
],
},
);
return res;
}
throw new Error(`Failed to create management access token: ${res.error}`);
}
export function maskSecret(secret: string) {
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
}
async function hashSecretKey(secretKey: string) {
if (typeof window === "undefined" || !window.crypto?.subtle) {
throw new Error(
"This function can only be used in the browser environment with Web Crypto API support.",
);
}
const encoder = new TextEncoder();
const data = encoder.encode(secretKey);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
return bufferToHex(hashBuffer);
}
function bufferToHex(buffer: ArrayBuffer) {
return [...new Uint8Array(buffer)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
}
@@ -7,10 +7,7 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
import { EngineChecklist } from "./analytics/ftux.client";
import { TransactionAnalyticsSummary } from "./analytics/summary";
import {
getTransactionAnalyticsSummary,
type TransactionSummaryData,
} from "./lib/analytics";
import { getTransactionAnalyticsSummary } from "./lib/analytics";
import type { Wallet } from "./server-wallets/wallet-table/types";
export default async function TransactionsAnalyticsPage(props: {
@@ -53,6 +50,7 @@ export default async function TransactionsAnalyticsPage(props: {
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const eoas = managementAccessToken
? await listEoas({
@@ -68,14 +66,10 @@ export default async function TransactionsAnalyticsPage(props: {
const wallets = eoas.data?.items as Wallet[] | undefined;
let initialData: TransactionSummaryData | undefined;
if (wallets && wallets.length > 0) {
const summary = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
}).catch(() => undefined);
initialData = summary;
}
const initialData = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
}).catch(() => undefined);
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
const client = getClientThirdwebClient({
@@ -86,6 +80,7 @@ export default async function TransactionsAnalyticsPage(props: {
return (
<div className="flex grow flex-col">
<EngineChecklist
isManagedVault={isManagedVault}
client={client}
hasTransactions={hasTransactions}
managementAccessToken={managementAccessToken ?? undefined}
@@ -1,19 +1,17 @@
"use client";
import { CircleAlertIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CodeClient } from "@/components/ui/code/code.client";
import { TabButtons } from "@/components/ui/tabs";
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
export function TryItOut() {
const [activeTab, setActiveTab] = useState<string>("sdk");
const [activeTab, setActiveTab] = useState<string>("curl");
return (
<div className="flex flex-col gap-4 overflow-hidden rounded-lg border border-border bg-card p-4">
<>
<div className="flex flex-row items-center gap-4">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between pb-2">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Usage from your backend
@@ -30,16 +28,16 @@ export function TryItOut() {
<TabButtons
tabClassName="!text-sm"
tabs={[
{
isActive: activeTab === "sdk",
name: "thirdweb SDK",
onClick: () => setActiveTab("sdk"),
},
{
isActive: activeTab === "curl",
name: "Curl",
onClick: () => setActiveTab("curl"),
},
{
isActive: activeTab === "sdk",
name: "thirdweb SDK",
onClick: () => setActiveTab("sdk"),
},
{
isActive: activeTab === "js",
name: "JavaScript",
@@ -63,59 +61,10 @@ export function TryItOut() {
]}
/>
<div className="h-2" />
<div className="h-4" />
{activeTab === "sdk" && (
<div className="flex flex-col gap-6">
<Alert className="bg-background" variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>Using the thirdweb SDK on the backend</AlertTitle>
<AlertDescription className="leading-loose">
<p className="text-foreground text-sm">
You can use the full TypeScript thirdweb SDK in your backend,
allowing you to use:{" "}
<ul className="ml-2 list-disc py-2 pl-4">
<li>
The full catalog of{" "}
<Link
className="text-blue-500"
href="https://portal.thirdweb.com/references/typescript/v5/functions#extensions"
rel="noopener noreferrer"
target="_blank"
>
extension functions
</Link>
</li>
<li>
The{" "}
<Link
className="text-blue-500"
href="https://portal.thirdweb.com/references/typescript/v5/prepareContractCall"
rel="noopener noreferrer"
target="_blank"
>
prepareContractCall
</Link>{" "}
function to encode your transactions
</li>
<li>
The full{" "}
<Link
className="text-blue-500"
href="https://portal.thirdweb.com/references/typescript/v5/Account"
rel="noopener noreferrer"
target="_blank"
>
account
</Link>{" "}
interface, predefined chains, and more
</li>
</ul>
The SDK handles encoding your transactions, signing them to
Engine and polling for status.
</p>
</AlertDescription>
</Alert>
<div className="space-y-2">
<h3 className="font-semibold text-lg tracking-tight">
Installation
@@ -185,7 +134,7 @@ export function TryItOut() {
/>
)}
</div>
</div>
</>
);
}
@@ -203,7 +152,6 @@ const client = createThirdwebClient({
const serverWallet = Engine.serverWallet({
client,
address: "<your-server-wallet-address>",
vaultAccessToken: "<your-vault-access-token>",
});
// Prepare the transaction
@@ -241,7 +189,6 @@ const curlExample = () => `\
curl -X POST "${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/write/contract" \\
-H "Content-Type: application/json" \\
-H "x-secret-key: <your-project-secret-key>" \\
-H "x-vault-access-token: <your-vault-access-token>" \\
-d '{
"executionOptions": {
"from": "<your-server-wallet-address>",
@@ -264,7 +211,6 @@ const response = await fetch(
headers: {
"Content-Type": "application/json",
"x-secret-key": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
},
body: JSON.stringify({
"executionOptions": {
@@ -290,7 +236,6 @@ url = "${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/write/contract"
headers = {
"Content-Type": "application/json",
"x-secret-key": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
}
payload = {
"executionOptions": {
@@ -355,7 +300,6 @@ func main() {
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-secret-key", "<your-project-secret-key>")
req.Header.Set("x-vault-access-token", "<your-vault-access-token>")
// Send the request
client := &http.Client{}
@@ -408,7 +352,6 @@ class Program
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("x-secret-key", "<your-project-secret-key>");
httpClient.DefaultRequestHeaders.Add("x-vault-access-token", "<your-vault-access-token>");
var response = await httpClient.PostAsync(url, content);
var responseContent = await response.Content.ReadAsStringAsync();
@@ -1,7 +1,6 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { createServiceAccount } from "@thirdweb-dev/vault-sdk";
import {
CheckIcon,
DownloadIcon,
@@ -21,91 +20,74 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { storeUserAccessToken } from "../../transactions/analytics/utils";
import {
createManagementAccessToken,
createWalletAccessToken,
initVaultClient,
createVaultAccountAndAccessToken,
maskSecret,
} from "../../transactions/lib/vault.client";
export function CreateVaultAccountButton(props: {
project: Project;
onUserAccessTokenCreated?: (userAccessToken: string) => void;
}) {
export function CreateVaultAccountButton(props: { project: Project }) {
const [secretKeyModalOpen, setSecretKeyModalOpen] = useState(false);
const [secretKey, setSecretKey] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [manageSelfChecked, setManageSelfChecked] = useState(false);
const router = useDashboardRouter();
const initialiseProjectWithVaultMutation = useMutation({
mutationFn: async () => {
mutationFn: async (projectSecretKey: string | undefined) => {
setModalOpen(true);
setSecretKeyModalOpen(false);
const vaultClient = await initVaultClient();
const serviceAccount = await createServiceAccount({
client: vaultClient,
request: {
options: {
metadata: {
projectId: props.project.id,
purpose: "Thirdweb Project Server Wallet Service Account",
teamId: props.project.teamId,
},
},
},
});
if (!serviceAccount.success) {
throw new Error("Failed to create service account");
}
const managementAccessTokenPromise = createManagementAccessToken({
adminKey: serviceAccount.data.adminKey,
const result = await createVaultAccountAndAccessToken({
project: props.project,
rotationCode: serviceAccount.data.rotationCode,
vaultClient,
projectSecretKey,
});
const userAccesTokenPromise = createWalletAccessToken({
adminKey: serviceAccount.data.adminKey,
project: props.project,
vaultClient,
});
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
userAccesTokenPromise,
managementAccessTokenPromise,
]);
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
throw new Error("Failed to create access token");
}
// save in local storage in case the user refreshes the page during FTUX
storeUserAccessToken(
props.project.id,
userAccessTokenRes.data.accessToken,
);
props.onUserAccessTokenCreated?.(userAccessTokenRes.data.accessToken);
return {
serviceAccount: serviceAccount.data,
userAccessToken: userAccessTokenRes.data,
};
return result;
},
onError: (error) => {
toast.error(error.message);
setErrorMessage(error.message);
setModalOpen(false);
},
});
const handleCreateServerWallet = async () => {
await initialiseProjectWithVaultMutation.mutateAsync();
const handleInitializeVault = async () => {
setSecretKeyModalOpen(true);
setErrorMessage("");
setSecretKey("");
setManageSelfChecked(false);
};
const handleSecretKeySubmit = async () => {
if (!manageSelfChecked && !secretKey.trim()) {
setErrorMessage(
"Please enter your project secret key or check the option to manage keys yourself",
);
return;
}
try {
setErrorMessage("");
await initialiseProjectWithVaultMutation.mutateAsync(
manageSelfChecked ? undefined : secretKey,
);
} catch {
// Error will be handled by the mutation's onError
}
};
const handleCancelSecretKey = () => {
setSecretKeyModalOpen(false);
setSecretKey("");
setErrorMessage("");
setManageSelfChecked(false);
};
const handleDownloadKeys = () => {
@@ -113,7 +95,7 @@ export function CreateVaultAccountButton(props: {
return;
}
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.serviceAccount.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.userAccessToken.accessToken}\n`;
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.walletToken.accessToken}\n`;
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -147,11 +129,11 @@ export function CreateVaultAccountButton(props: {
const isLoading = initialiseProjectWithVaultMutation.isPending;
return (
<>
<div>
<Button
className="flex flex-row items-center gap-2"
disabled={isLoading}
onClick={handleCreateServerWallet}
onClick={handleInitializeVault}
variant={"primary"}
>
{isLoading ? (
@@ -162,6 +144,93 @@ export function CreateVaultAccountButton(props: {
{"Create Vault Admin Account"}
</Button>
{/* Secret Key Input Modal */}
<Dialog
modal={true}
onOpenChange={handleCancelSecretKey}
open={secretKeyModalOpen}
>
<DialogContent className="overflow-hidden p-0">
<DialogHeader className="pt-6 px-6">
<DialogTitle>Create Your Vault</DialogTitle>
</DialogHeader>
<div className="space-y-6 p-6 pt-0">
<div className="space-y-3">
<Label htmlFor="secretKey">
Let thirdweb manage your Vault keys for you
</Label>
<p className="text-muted-foreground text-sm">
Lets you use your project secret key to access your vault like
any other thirdweb service (recommended).
</p>
<Input
id="secretKey"
type="password"
placeholder="Enter your project secret key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
disabled={
initialiseProjectWithVaultMutation.isPending ||
manageSelfChecked
}
/>
<p className="text-muted-foreground text-xs">
Your project secret key was generated when you created your
project. If you lost it, you can regenerate one in your project
settings.
</p>
</div>
<div className="flex flex-col gap-2">
<hr className="mb-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={manageSelfChecked}
onCheckedChange={(v) => setManageSelfChecked(!!v)}
disabled={initialiseProjectWithVaultMutation.isPending}
/>
I want to manage my Vault keys myself (advanced)
</CheckboxWithLabel>
</div>
{errorMessage && (
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
onClick={handleCancelSecretKey}
variant="outline"
disabled={initialiseProjectWithVaultMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleSecretKeySubmit}
variant="primary"
disabled={
(!secretKey.trim() && !manageSelfChecked) ||
initialiseProjectWithVaultMutation.isPending
}
>
{initialiseProjectWithVaultMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Creating vault...
</>
) : (
"Create vault"
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Keys Display Modal */}
<Dialog modal={true} onOpenChange={handleCloseModal} open={modalOpen}>
<DialogContent
className="overflow-hidden p-0"
@@ -197,12 +266,10 @@ export function CreateVaultAccountButton(props: {
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
copyIconPosition="right"
textToCopy={
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey
initialiseProjectWithVaultMutation.data.adminKey
}
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey,
initialiseProjectWithVaultMutation.data.adminKey,
)}
tooltip="Copy Admin Key"
/>
@@ -212,32 +279,6 @@ export function CreateVaultAccountButton(props: {
</p>
</div>
</div>
{/* <div>
<h3 className="mb-2 font-medium text-sm">
Vault Access Token
</h3>
<div className="flex flex-col gap-2 ">
<CopyTextButton
textToCopy={
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken,
)}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This access token is used to sign transactions and
messages from your backend. Can be revoked and recreated
with your admin key.
</p>
</div>
</div> */}
</div>
<Alert variant="destructive">
<AlertTitle>Secure your admin key</AlertTitle>
@@ -285,6 +326,6 @@ export function CreateVaultAccountButton(props: {
) : null}
</DialogContent>
</Dialog>
</>
</div>
);
}
@@ -9,15 +9,17 @@ import RotateAdminKeyButton from "./rotate-admin-key.client";
export function KeyManagement({
maskedAdminKey,
project,
isManagedVault,
}: {
maskedAdminKey?: string;
project: Project;
isManagedVault: boolean;
}) {
return (
<div className="flex flex-col gap-6">
{!maskedAdminKey && <CreateVaultAccountAlert project={project} />}
{maskedAdminKey && (
{maskedAdminKey && !isManagedVault && (
<>
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
@@ -37,13 +39,42 @@ export function KeyManagement({
{maskedAdminKey}
</p>
</div>
<RotateAdminKeyButton project={project} />
<RotateAdminKeyButton
project={project}
isManagedVault={false}
/>
</div>
</div>
</div>
<ListAccessTokens project={project} />
</>
)}
{isManagedVault && (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
<h2 className="font-semibold text-xl tracking-tight">
Managed Vault
</h2>
<p className="text-muted-foreground text-sm">
Your vault is currently managed by Thirdweb so you can access it
via you project secret key. You can eject and manage your own
vault keys at any time. Doing so means you'll need to pass your
own vault access token to the transactions API additionally to
your project secret key.
</p>
<div className="h-4" />
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex min-w-[300px] flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground text-sm">
{maskedAdminKey}
</p>
</div>
<RotateAdminKeyButton project={project} isManagedVault={true} />
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,12 +1,12 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk";
import {
CheckIcon,
CircleAlertIcon,
DownloadIcon,
Loader2Icon,
LogOutIcon,
RefreshCcwIcon,
} from "lucide-react";
import { useState } from "react";
@@ -27,13 +27,14 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import {
createManagementAccessToken,
createWalletAccessToken,
initVaultClient,
maskSecret,
rotateVaultAccountAndAccessToken,
} from "../../transactions/lib/vault.client";
export default function RotateAdminKeyButton(props: { project: Project }) {
export default function RotateAdminKeyButton(props: {
project: Project;
isManagedVault: boolean;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
@@ -41,55 +42,15 @@ export default function RotateAdminKeyButton(props: { project: Project }) {
const rotateAdminKeyMutation = useMutation({
mutationFn: async () => {
const vaultClient = await initVaultClient();
const rotationCode = props.project.services.find(
(service) => service.name === "engineCloud",
)?.rotationCode;
if (!rotationCode) {
throw new Error("Rotation code not found");
}
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
auth: {
rotationCode,
},
},
});
if (rotateServiceAccountRes.error) {
throw new Error(rotateServiceAccountRes.error.message);
}
// need to recreate the management access token with the new admin key
const managementAccessTokenPromise = createManagementAccessToken({
adminKey: rotateServiceAccountRes.data.newAdminKey,
// passing no secret key means we're rotating the admin key and deleting any stored keys
const result = await rotateVaultAccountAndAccessToken({
project: props.project,
rotationCode: rotateServiceAccountRes.data.newRotationCode,
vaultClient,
});
const userAccesTokenPromise = createWalletAccessToken({
adminKey: rotateServiceAccountRes.data.newAdminKey,
project: props.project,
vaultClient,
});
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
userAccesTokenPromise,
managementAccessTokenPromise,
]);
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
throw new Error("Failed to create access token");
}
return {
adminKey: rotateServiceAccountRes.data.newAdminKey,
adminKey: result.adminKey,
success: true,
userAccessToken: userAccessTokenRes.data,
userAccessToken: result.walletToken,
};
},
onError: (error) => {
@@ -144,8 +105,12 @@ export default function RotateAdminKeyButton(props: { project: Project }) {
variant="outline"
>
{isLoading && <Loader2Icon className="size-4 animate-spin" />}
{!isLoading && <RefreshCcwIcon className="size-4" />}
Rotate Admin Key
{!isLoading && props.isManagedVault ? (
<LogOutIcon className="size-4" />
) : (
<RefreshCcwIcon className="size-4" />
)}
{props.isManagedVault ? "Eject From Managed Vault" : "Rotate Admin Key"}
</Button>
<Dialog modal={true} onOpenChange={handleCloseModal} open={modalOpen}>
@@ -22,11 +22,13 @@ export default async function VaultPage(props: {
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
return (
<div className="flex flex-col gap-8">
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
isManagedVault={isManagedVault}
project={project}
/>
</div>
@@ -0,0 +1,95 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { SearchType } from "./types";
export function AdvancedSearchInput(props: {
onSearch: (searchType: SearchType, query: string) => void;
onClear: () => void;
isLoading: boolean;
hasResults: boolean;
}) {
const [searchType, setSearchType] = useState<SearchType>("email");
const [query, setQuery] = useState("");
const handleSearch = () => {
if (query.trim()) {
props.onSearch(searchType, query.trim());
}
};
const handleClear = () => {
setQuery("");
props.onClear();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Select
value={searchType}
onValueChange={(value) => setSearchType(value as SearchType)}
>
<SelectTrigger className="w-[140px] bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="id">ID</SelectItem>
<SelectItem value="address">Address</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1">
<Input
className="bg-card pl-9"
placeholder={`Search by ${searchType}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
<Button
onClick={handleSearch}
disabled={!query.trim() || props.isLoading}
size="sm"
>
{props.isLoading ? "Searching..." : "Search"}
</Button>
</div>
{props.hasResults && (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="gap-2"
>
Clear Search & Return to List
</Button>
</div>
)}
</div>
);
}
@@ -1,22 +0,0 @@
"use client";
import { SearchIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
export function SearchInput(props: {
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative">
<Input
className="bg-card pl-9"
onChange={(e) => props.onValueChange(e.target.value)}
placeholder={props.placeholder}
value={props.value}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { format } from "date-fns";
import type { ThirdwebClient } from "thirdweb";
import type { WalletUser } from "thirdweb/wallets";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const getUserIdentifier = (user: WalletUser) => {
const mainDetail = user.linkedAccounts[0]?.details;
return (
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id ??
user.id
);
};
export function SearchResults(props: {
results: WalletUser[];
client: ThirdwebClient;
}) {
if (props.results.length === 0) {
return (
<Card>
<CardContent className="py-8">
<div className="flex flex-col items-center gap-3">
<p className="text-sm">No users found</p>
<p className="text-muted-foreground text-sm">
Try searching with different criteria
</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{props.results.map((user) => {
const walletAddress = user.wallets?.[0]?.address;
const createdAt = user.wallets?.[0]?.createdAt;
const mainDetail = user.linkedAccounts?.[0]?.details;
const email = mainDetail?.email as string | undefined;
const phone = mainDetail?.phone as string | undefined;
return (
<Card key={user.id}>
<CardHeader>
<CardTitle className="text-lg">User Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">
User Identifier
</p>
<p className="text-sm">{getUserIdentifier(user)}</p>
</div>
{walletAddress && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Wallet Address
</p>
<WalletAddress
address={walletAddress}
client={props.client}
/>
</div>
)}
{email && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Email
</p>
<p className="text-sm">{email}</p>
</div>
)}
{phone && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Phone
</p>
<p className="text-sm">{phone}</p>
</div>
)}
{createdAt && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Created
</p>
<p className="text-sm">
{format(new Date(createdAt), "MMM dd, yyyy")}
</p>
</div>
)}
<div>
<p className="text-sm font-medium text-muted-foreground">
Login Methods
</p>
<div className="flex flex-wrap gap-1">
{user.linkedAccounts?.map((account, index) => (
<TooltipProvider
key={`${user.id}-${account.type}-${index}`}
>
<Tooltip>
<TooltipTrigger>
<Badge variant="secondary" className="text-xs">
{account.type}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm space-y-1">
{Object.entries(account.details).map(
([key, value]) => (
<div key={key}>
<span className="font-medium">{key}:</span>{" "}
{String(value)}
</div>
),
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
@@ -21,7 +21,10 @@ import {
useAllEmbeddedWallets,
useEmbeddedWallets,
} from "@/hooks/useEmbeddedWallets";
import { SearchInput } from "./SearchInput";
import { AdvancedSearchInput } from "./AdvancedSearchInput";
import { SearchResults } from "./SearchResults";
import { searchUsers } from "./searchUsers";
import type { SearchType } from "./types";
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const mainDetail = accounts[0]?.details;
@@ -35,11 +38,16 @@ const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const columnHelper = createColumnHelper<WalletUser>();
export function InAppWalletUsersPageContent(props: {
authToken: string;
projectClientId: string;
client: ThirdwebClient;
}) {
export function InAppWalletUsersPageContent(
props: {
authToken: string;
client: ThirdwebClient;
teamId: string;
} & (
| { projectClientId: string; ecosystemSlug?: never }
| { ecosystemSlug: string; projectClientId?: never }
),
) {
const columns = useMemo(() => {
return [
columnHelper.accessor("linkedAccounts", {
@@ -110,49 +118,57 @@ export function InAppWalletUsersPageContent(props: {
}, [props.client]);
const [activePage, setActivePage] = useState(1);
const [searchValue, setSearchValue] = useState("");
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearchResults, setHasSearchResults] = useState(false);
const walletsQuery = useEmbeddedWallets({
authToken: props.authToken,
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
page: activePage,
});
const wallets = walletsQuery?.data?.users || [];
const filteredWallets = searchValue
? wallets.filter((wallet) => {
const term = searchValue.toLowerCase();
if (wallet.id.toLowerCase().includes(term)) {
return true;
}
if (
wallet.wallets?.some((w) => w.address?.toLowerCase().includes(term))
) {
return true;
}
if (
wallet.linkedAccounts?.some((acc) => {
return Object.values(acc.details).some(
(detail) =>
typeof detail === "string" &&
detail.toLowerCase().includes(term),
);
})
) {
return true;
}
return false;
})
: wallets;
const { mutateAsync: getAllEmbeddedWallets, isPending } =
useAllEmbeddedWallets({
authToken: props.authToken,
});
const handleSearch = async (searchType: SearchType, query: string) => {
setIsSearching(true);
try {
const results = await searchUsers(
props.authToken,
props.projectClientId,
props.ecosystemSlug,
props.teamId,
searchType,
query,
);
setSearchResults(results);
setHasSearchResults(true);
} catch (error) {
console.error("Search failed:", error);
setSearchResults([]);
setHasSearchResults(true);
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchResults([]);
setHasSearchResults(false);
};
const downloadCSV = useCallback(async () => {
if (wallets.length === 0 || !getAllEmbeddedWallets) {
return;
}
const usersWallets = await getAllEmbeddedWallets({
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
});
const csv = Papa.unparse(
usersWallets.map((row) => {
@@ -173,64 +189,79 @@ export function InAppWalletUsersPageContent(props: {
tempLink.href = csvUrl;
tempLink.setAttribute("download", "download.csv");
tempLink.click();
}, [wallets, props.projectClientId, getAllEmbeddedWallets]);
}, [
wallets,
props.projectClientId,
getAllEmbeddedWallets,
props.teamId,
props.ecosystemSlug,
]);
return (
<div>
<div className="flex flex-col gap-4">
{/* Top section */}
<div className="flex items-center justify-end gap-3">
<div className="w-full max-w-xs">
<SearchInput
onValueChange={setSearchValue}
placeholder="Search"
value={searchValue}
/>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end gap-3">
<div className="w-full max-w-lg">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
size="sm"
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
<Button
className="gap-2"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
size="sm"
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
<div>
<TWTable
columns={columns}
data={filteredWallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
title="in-app wallets"
/>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
title="in-app wallets"
/>
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 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 justify-center gap-3 rounded-b-lg border border-t-0 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>
</div>
@@ -0,0 +1,56 @@
import type { WalletUser } from "thirdweb/wallets";
import { THIRDWEB_EWS_API_HOST } from "@/constants/urls";
import type { SearchType } from "./types";
export async function searchUsers(
authToken: string,
clientId: string | undefined,
ecosystemSlug: string | undefined,
teamId: string,
searchType: SearchType,
query: string,
): Promise<WalletUser[]> {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
}
// Add search parameter based on search type
switch (searchType) {
case "email":
url.searchParams.append("email", query);
break;
case "phone":
url.searchParams.append("phone", query);
break;
case "id":
url.searchParams.append("id", query);
break;
case "address":
url.searchParams.append("address", query);
break;
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to search users: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
return data.users as WalletUser[];
}
@@ -0,0 +1 @@
export type SearchType = "email" | "phone" | "id" | "address";
@@ -34,6 +34,7 @@ export default async function Page(props: {
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
);
}
@@ -0,0 +1,231 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import type { WebhookConfig } from "@/api/webhook-configs";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import type { ChartConfig } from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { DateRangeControls, WebhookPicker } from "./WebhookAnalyticsFilter";
interface WebhookAnalyticsChartsProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhookAnalyticsCharts({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhookAnalyticsChartsProps) {
// Data is already filtered server-side
const filteredRequestsData = requestsData;
const filteredLatencyData = latencyData;
// Process status code distribution data by individual status codes
const statusCodeData = useMemo(() => {
if (!filteredRequestsData.length) return [];
const groupedData = filteredRequestsData.reduce(
(acc, item) => {
const date = new Date(item.date).getTime();
if (!acc[date]) {
acc[date] = { time: date };
}
// Only include valid status codes (not 0) with actual request counts
if (item.httpStatusCode > 0 && item.totalRequests > 0) {
const statusKey = item.httpStatusCode.toString();
acc[date][statusKey] =
(acc[date][statusKey] || 0) + item.totalRequests;
}
return acc;
},
{} as Record<string, Record<string, number> & { time: number }>,
);
return Object.values(groupedData).sort(
(a, b) => (a.time || 0) - (b.time || 0),
);
}, [filteredRequestsData]);
// Process latency data for charts
const latencyChartData = useMemo(() => {
if (!filteredLatencyData.length) return [];
return filteredLatencyData
.map((item) => ({
p50: item.p50LatencyMs,
p90: item.p90LatencyMs,
p99: item.p99LatencyMs,
time: new Date(item.date).getTime(),
}))
.sort((a, b) => a.time - b.time);
}, [filteredLatencyData]);
// Chart configurations
const latencyChartConfig: ChartConfig = {
p50: {
color: "hsl(var(--chart-1))",
label: "P50 Latency",
},
p90: {
color: "hsl(var(--chart-2))",
label: "P90 Latency",
},
p99: {
color: "hsl(var(--chart-3))",
label: "P99 Latency",
},
};
// Generate status code chart config dynamically with class-based colors
const statusCodeConfig: ChartConfig = useMemo(() => {
const statusCodes = new Set<string>();
statusCodeData.forEach((item) => {
Object.keys(item).forEach((key) => {
if (key !== "time" && !Number.isNaN(Number.parseInt(key))) {
statusCodes.add(key);
}
});
});
const getColorForStatusCode = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) {
return "hsl(var(--chart-1))"; // Green for 2xx
} else if (statusCode >= 300 && statusCode < 400) {
return "hsl(var(--chart-2))"; // Yellow for 3xx
} else if (statusCode >= 400 && statusCode < 500) {
return "hsl(var(--chart-3))"; // Orange for 4xx
} else {
return "hsl(var(--chart-4))"; // Red for 5xx
}
};
const config: ChartConfig = {};
Array.from(statusCodes)
.sort((a, b) => {
const codeA = Number.parseInt(a);
const codeB = Number.parseInt(b);
return codeA - codeB;
})
.forEach((statusKey) => {
const statusCode = Number.parseInt(statusKey);
config[statusKey] = {
color: getColorForStatusCode(statusCode),
label: statusCode.toString(),
};
});
return config;
}, [statusCodeData]);
const hasData = statusCodeData.length > 0 || latencyChartData.length > 0;
const selectedWebhookConfig = webhookConfigs.find(
(w) => w.id === selectedWebhookId,
);
return (
<div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6">
<WebhookPicker
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
<DateRangeControls />
</div>
{/* Selected webhook URL */}
{selectedWebhookConfig && selectedWebhookId !== "all" && (
<div className="mb-6">
<CopyTextButton
className="font-mono text-sm"
copyIconPosition="right"
textToCopy={selectedWebhookConfig.destinationUrl}
textToShow={selectedWebhookConfig.destinationUrl}
tooltip="Copy webhook URL"
variant="outline"
/>
</div>
)}
{!hasData ? (
<div className="flex items-center justify-center p-8 rounded-xl border border-border bg-card">
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">
No webhook data available
</h3>
<p className="text-sm text-muted-foreground">
Webhook analytics will appear here once you start receiving
webhook events.
</p>
</div>
</div>
) : (
<ResponsiveSuspense
fallback={
<div className="flex flex-col gap-6">
<Skeleton className="h-[350px] rounded-xl" />
<Skeleton className="h-[350px] rounded-xl" />
</div>
}
searchParamsUsed={["from", "to", "interval", "webhook"]}
>
<div className="flex flex-col gap-6">
{/* Status Code Distribution Chart */}
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3]"
config={statusCodeConfig}
data={statusCodeData}
header={{
description: "Breakdown of responses by status code",
title: "Response Status Codes",
}}
isPending={false}
showLegend
toolTipLabelFormatter={(label) =>
format(
new Date(Number.parseInt(label as string)),
"MMM dd, yyyy HH:mm",
)
}
toolTipValueFormatter={(value) => `${value} requests`}
variant="stacked"
/>
{/* Latency Chart */}
<ThirdwebAreaChart
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="w-full"
config={latencyChartConfig}
data={latencyChartData}
header={{
description: "Webhook response times in milliseconds",
title: "Response Latency",
}}
isPending={false}
showLegend
toolTipLabelFormatter={(label) =>
format(
new Date(Number.parseInt(label as string)),
"MMM dd, yyyy HH:mm",
)
}
toolTipValueFormatter={(value) => `${value}ms`}
/>
</div>
</ResponsiveSuspense>
)}
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import { DateRangeSelector } from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
type SearchParams = {
from?: string;
to?: string;
webhook?: string;
interval?: "day" | "week";
};
interface WebhookAnalyticsFilterProps {
webhookConfigs: Array<{
id: string;
description: string | null;
}>;
selectedWebhookId: string;
}
export function WebhookPicker({
webhookConfigs,
selectedWebhookId,
}: WebhookAnalyticsFilterProps) {
const setResponsiveSearchParams = useSetResponsiveSearchParams();
return (
<Select
onValueChange={(value) => {
setResponsiveSearchParams((prev) => ({
...prev,
webhook: value,
}));
}}
value={selectedWebhookId}
>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Webhooks</SelectItem>
{webhookConfigs.map((config) => (
<SelectItem key={config.id} value={config.id}>
<span>{config.description || "Unnamed webhook"}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function DateRangeControls() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: responsiveSearchParams.from,
interval: responsiveSearchParams.interval,
to: responsiveSearchParams.to,
});
return (
<div className="flex items-center gap-3">
<DateRangeSelector
popoverAlign="end"
range={range}
setRange={(newRange) => {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
return newParams;
});
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
interval: newInterval,
};
return newParams;
});
}}
/>
</div>
);
}
@@ -0,0 +1,29 @@
import type { WebhookConfig } from "@/api/webhook-configs";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts";
interface WebhookAnalyticsServerProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhookAnalyticsServer({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhookAnalyticsServerProps) {
return (
<WebhookAnalyticsCharts
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,29 @@
import type { WebhookConfig } from "@/api/webhook-configs";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer";
interface WebhooksAnalyticsProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhooksAnalytics({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhooksAnalyticsProps) {
return (
<WebhookAnalyticsServer
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -0,0 +1,134 @@
import { notFound, redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { getWebhookLatency, getWebhookRequests } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { getWebhookConfigs } from "@/api/webhook-configs";
import { getFiltersFromSearchParams } from "@/lib/time";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { WebhooksAnalytics } from "./components/WebhooksAnalytics";
export default async function WebhooksAnalyticsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
webhook?: string | undefined | string[];
}>;
}) {
const [authToken, params, account] = await Promise.all([
getAuthToken(),
props.params,
getValidAccount(),
]);
if (!account || !authToken) {
notFound();
}
const [isFeatureEnabled, project] = await Promise.all([
isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
}),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
notFound();
}
if (!isFeatureEnabled) {
redirect(
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
);
}
const searchParams = await props.searchParams;
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-7",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
// Get webhook configs
const webhookConfigsResponse = await getWebhookConfigs({
projectIdOrSlug: params.project_slug,
teamIdOrSlug: params.team_slug,
}).catch(() => ({
body: "",
data: [],
reason: "Failed to fetch webhook configs",
status: "error" as const,
}));
if (
webhookConfigsResponse.status === "error" ||
webhookConfigsResponse.data.length === 0
) {
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">
No webhook configurations found.
</p>
</div>
</ResponsiveSearchParamsProvider>
);
}
// Get selected webhook ID from search params
const selectedWebhookId = Array.isArray(searchParams.webhook)
? searchParams.webhook[0] || "all"
: searchParams.webhook || "all";
// Fetch webhook analytics data
const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId;
const [requestsData, latencyData] = await Promise.all([
(async () => {
const res = await getWebhookRequests({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
if ("error" in res) {
console.error("Failed to fetch webhook requests:", res.error);
return [];
}
return res.data;
})(),
(async () => {
const res = await getWebhookLatency({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
if ("error" in res) {
console.error("Failed to fetch webhook latency:", res.error);
return [];
}
return res.data;
})(),
]);
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<WebhooksAnalytics
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigsResponse.data}
/>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,23 @@
import type { Topic } from "@/api/webhook-configs";
import { WebhookConfigModal } from "./webhook-config-modal";
interface CreateWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
}
export function CreateWebhookConfigModal(props: CreateWebhookConfigModalProps) {
return (
<WebhookConfigModal
mode="create"
onOpenChange={props.onOpenChange}
open={props.open}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
/>
);
}
@@ -0,0 +1,101 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { AlertTriangleIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import type { WebhookSummaryStats } from "@/types/analytics";
import type { WebhookConfig } from "../../../../../../../../@/api/webhook-configs";
interface DeleteWebhookModalProps {
webhookConfig: WebhookConfig | null;
metrics: WebhookSummaryStats | null;
onConfirm: () => void;
isPending: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DeleteWebhookModal(props: DeleteWebhookModalProps) {
if (!props.webhookConfig) {
return null;
}
// Use real metrics data
const requests24h = props.metrics?.totalRequests ?? 0;
const hasRecentActivity = requests24h > 0;
return (
<Dialog onOpenChange={props.onOpenChange} open={props.open}>
<DialogContent className="p-0">
<DialogHeader className="mb-4 p-6">
<DialogTitle className="font-semibold text-2xl tracking-tight">
Delete Webhook Configuration
</DialogTitle>
<DialogDescription className="space-y-4">
<div>
Are you sure you want to delete this webhook configuration? This
action cannot be undone.
</div>
<div className="space-y-4 py-4">
<div>
<div className="font-medium text-sm">Description</div>
<div className="text-sm text-muted-foreground">
{props.webhookConfig.description || "No description"}
</div>
</div>
<div>
<div className="font-medium text-sm">URL</div>
<div className="text-sm text-muted-foreground break-all">
{props.webhookConfig.destinationUrl}
</div>
</div>
</div>
{hasRecentActivity && (
<Alert variant="warning">
<AlertTriangleIcon className="size-5" />
<AlertTitle className="text-sm">
Recent Activity Detected
</AlertTitle>
<AlertDescription className="mt-0.5 text-sm">
This webhook has received {requests24h} requests in the last
24 hours. Deleting it may impact your integrations.
</AlertDescription>
</Alert>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-4 border-border border-t bg-card p-6 lg:gap-2">
<Button
disabled={props.isPending}
onClick={() => {
props.onOpenChange(false);
}}
variant="outline"
>
Cancel
</Button>
<Button
className="min-w-28 gap-2"
disabled={props.isPending}
onClick={props.onConfirm}
variant="destructive"
>
{props.isPending && <Spinner className="size-4" />}
Delete Webhook
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,25 @@
import type { Topic, WebhookConfig } from "@/api/webhook-configs";
import { WebhookConfigModal } from "./webhook-config-modal";
interface EditWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
webhookConfig: WebhookConfig;
}
export function EditWebhookConfigModal(props: EditWebhookConfigModalProps) {
return (
<WebhookConfigModal
mode="edit"
onOpenChange={props.onOpenChange}
open={props.open}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
webhookConfig={props.webhookConfig}
/>
);
}
@@ -0,0 +1,50 @@
"use client";
import { redirect } from "next/navigation";
import type { WebhookSummaryStats } from "@/types/analytics";
import type {
Topic,
WebhookConfig,
} from "../../../../../../../../@/api/webhook-configs";
import { WebhookConfigsTable } from "./webhook-configs-table";
interface WebhooksOverviewProps {
teamId: string;
teamSlug: string;
projectId: string;
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
metricsMap: Map<string, WebhookSummaryStats | null>;
}
export function WebhooksOverview({
teamId,
teamSlug,
projectId,
projectSlug,
webhookConfigs,
topics,
metricsMap,
}: WebhooksOverviewProps) {
// Feature is enabled (matches server component behavior)
const isFeatureEnabled = true;
// Redirect to contracts tab if feature is disabled
if (!isFeatureEnabled) {
redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`);
}
// Show full webhook functionality
return (
<WebhookConfigsTable
metricsMap={metricsMap}
projectId={projectId}
projectSlug={projectSlug}
teamId={teamId}
teamSlug={teamSlug}
topics={topics}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,195 @@
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { Topic } from "@/api/webhook-configs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
const TOPIC_IDS_THAT_SUPPORT_FILTERS = [
"insight.event.confirmed",
"insight.transaction.confirmed",
];
interface TopicSelectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
topics: Topic[];
selectedTopics: { id: string; filters: object | null }[];
onSelectionChange: (topics: { id: string; filters: object | null }[]) => void;
}
export function TopicSelectorModal(props: TopicSelectorModalProps) {
const [tempSelection, setTempSelection] = useState<
{ id: string; filters: object | null }[]
>(props.selectedTopics);
// Initialize topicFilters with existing filters
const [topicFilters, setTopicFilters] = useState<Record<string, string>>(
() => {
const initialFilters: Record<string, string> = {};
props.selectedTopics.forEach((topic) => {
if (topic.filters) {
initialFilters[topic.id] = JSON.stringify(topic.filters, null, 2);
}
});
return initialFilters;
},
);
const groupedTopics = useMemo(() => {
const groups: Record<string, Topic[]> = {};
props.topics.forEach((topic) => {
const service = topic.id.split(".")[0] || "other";
if (!groups[service]) {
groups[service] = [];
}
groups[service].push(topic);
});
// Sort groups by service name and topics within each group
const sortedGroups: Record<string, Topic[]> = {};
Object.keys(groups)
.sort()
.forEach((service) => {
sortedGroups[service] =
groups[service]?.sort((a, b) => a.id.localeCompare(b.id)) || [];
});
return sortedGroups;
}, [props.topics]);
function handleTopicToggle(topicId: string, checked: boolean) {
if (checked) {
// Preserve existing filters if re-selecting a topic
const existingTopic = props.selectedTopics.find((t) => t.id === topicId);
setTempSelection((prev) => [
...prev,
{ id: topicId, filters: existingTopic?.filters || null },
]);
} else {
setTempSelection((prev) => prev.filter((topic) => topic.id !== topicId));
}
}
function handleSave() {
const processedSelection = tempSelection.map((topic) => {
const filters = topicFilters[topic.id];
if (filters) {
try {
return { ...topic, filters: JSON.parse(filters) };
} catch (_error) {
toast.error(`Invalid JSON in filters for ${topic.id}`);
throw new Error(`Invalid JSON in filters for ${topic.id}`);
}
}
return topic;
});
props.onSelectionChange(processedSelection);
props.onOpenChange(false);
}
function handleCancel() {
setTempSelection(props.selectedTopics);
// Reset filter texts to original values
const originalFilters: Record<string, string> = {};
props.selectedTopics.forEach((topic) => {
if (topic.filters) {
originalFilters[topic.id] = JSON.stringify(topic.filters, null, 2);
}
});
setTopicFilters(originalFilters);
props.onOpenChange(false);
}
return (
<Dialog onOpenChange={handleCancel} open={props.open}>
<DialogContent className="max-h-[80vh] max-w-2xl p-0 overflow-hidden flex flex-col">
<DialogHeader className="p-6 pb-4 flex-shrink-0">
<DialogTitle className="font-semibold text-xl tracking-tight">
Select Topics
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 min-h-0">
<div className="space-y-6 pb-4">
{Object.entries(groupedTopics).map(([service, topics]) => (
<div key={service}>
<h3 className="font-medium text-sm mb-4 text-foreground capitalize">
{service}
</h3>
<div className="space-y-3 ml-4">
{topics.map((topic) => (
<div className="flex items-start space-x-3" key={topic.id}>
<Checkbox
checked={tempSelection.some((t) => t.id === topic.id)}
id={topic.id}
onCheckedChange={(checked) =>
handleTopicToggle(topic.id, !!checked)
}
/>
<div className="grid gap-1.5 leading-none flex-1">
<label
className="text-sm font-mono font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
htmlFor={topic.id}
>
{topic.id}
</label>
<p className="text-xs text-muted-foreground">
{topic.description}
</p>
{/* Show textarea when selecting a topic that supports filters */}
{TOPIC_IDS_THAT_SUPPORT_FILTERS.includes(topic.id) &&
tempSelection.some((t) => t.id === topic.id) && (
<div className="mt-2">
<Textarea
placeholder={`{\n "key": "value"\n}`}
rows={3}
value={topicFilters[topic.id] || ""}
onChange={(e) => {
setTopicFilters((prev) => ({
...prev,
[topic.id]: e.target.value,
}));
}}
className="text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
Enter JSON filters (optional). Leave empty to
receive all events.
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter className="gap-4 border-border border-t bg-card p-6 lg:gap-2 flex-shrink-0">
<Button onClick={handleCancel} variant="outline">
Cancel
</Button>
<Button onClick={handleSave}>
Select {tempSelection.length} Topic
{tempSelection.length !== 1 ? "s" : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,346 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
createWebhookConfig,
type Topic,
updateWebhookConfig,
type WebhookConfig,
} from "@/api/webhook-configs";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Switch } from "@/components/ui/switch";
import { TopicSelectorModal } from "./topic-selector-modal";
const formSchema = z.object({
description: z.string().min(1, "Description is required"),
destinationUrl: z
.string()
.min(1, "Destination URL is required")
.url("Must be a valid URL")
.refine((url) => url.startsWith("https://"), {
message: "URL must start with https://",
}),
isPaused: z.boolean().default(false),
topics: z
.array(
z.object({ id: z.string(), filters: z.union([z.object({}), z.null()]) }),
)
.min(1, "At least one topic is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface WebhookConfigModalProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
webhookConfig?: WebhookConfig; // Only required for edit mode
}
export function WebhookConfigModal(props: WebhookConfigModalProps) {
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false);
const queryClient = useQueryClient();
const isEditMode = props.mode === "edit";
const webhookConfig = props.webhookConfig;
const form = useForm<FormValues>({
defaultValues: {
description: isEditMode ? webhookConfig?.description || "" : "",
destinationUrl: isEditMode ? webhookConfig?.destinationUrl || "" : "",
isPaused: isEditMode ? !!webhookConfig?.pausedAt : false,
topics:
isEditMode && webhookConfig?.topics
? webhookConfig.topics.map((t) => ({
id: t.id,
filters: t.filters,
}))
: [],
},
resolver: zodResolver(formSchema),
});
const mutation = useMutation({
mutationFn: async (values: FormValues) => {
// Topics already have filters applied from the selector modal
const processedTopics = values.topics;
if (isEditMode && webhookConfig) {
const result = await updateWebhookConfig({
config: {
description: values.description,
destinationUrl: values.destinationUrl,
isPaused: values.isPaused,
topics: processedTopics,
},
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
webhookConfigId: webhookConfig.id,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
} else {
const result = await createWebhookConfig({
config: {
description: values.description,
destinationUrl: values.destinationUrl,
isPaused: values.isPaused,
topics: processedTopics,
},
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
}
},
onError: (error) => {
toast.error(`Failed to ${isEditMode ? "update" : "create"} webhook`, {
description: error.message,
});
},
onSuccess: () => {
toast.success(
`Webhook ${isEditMode ? "updated" : "created"} successfully`,
);
props.onOpenChange(false);
if (!isEditMode) {
form.reset();
}
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
},
});
function onSubmit(values: FormValues) {
mutation.mutate(values);
}
function handleOpenChange(open: boolean) {
if (!open && !mutation.isPending) {
if (isEditMode && webhookConfig) {
// Reset form to original values when closing edit modal
form.reset({
description: webhookConfig.description || "",
destinationUrl: webhookConfig.destinationUrl || "",
isPaused: !!webhookConfig.pausedAt,
topics: webhookConfig.topics.map((t) => ({
id: t.id,
filters: t.filters,
})),
});
} else {
// Reset to empty values for create modal
form.reset();
}
}
props.onOpenChange(open);
}
return (
<Dialog onOpenChange={handleOpenChange} open={props.open}>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
<DialogHeader className="mb-4">
<DialogTitle className="font-semibold text-2xl tracking-tight">
{isEditMode ? "Edit" : "Create"} Webhook Configuration
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-5">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={
isEditMode
? "Enter webhook description..."
: "Enter a description"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Destination URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhook"
type="url"
{...field}
/>
</FormControl>
<FormDescription>
{isEditMode
? "The URL where webhook events will be sent"
: "Enter your webhook URL. Only https:// is supported."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topics"
render={({ field }) => (
<FormItem>
<FormLabel>Topics</FormLabel>
<FormControl>
<Button
className="w-full justify-start text-left font-normal"
onClick={() => setIsTopicSelectorOpen(true)}
type="button"
variant="outline"
>
{field.value && field.value.length > 0
? `${field.value.length} topic${field.value.length !== 1 ? "s" : ""} selected`
: "Select topics to subscribe to"}
</Button>
</FormControl>
<FormDescription>
{isEditMode
? "Select the events you want to receive notifications for"
: "Select the events to trigger calls to your webhook."}
</FormDescription>
{field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{field.value.map((topic) => {
return (
<div
className="flex items-center gap-1 rounded-full bg-accent px-2 py-1 text-xs"
key={topic.id}
>
{topic.id}
<button
className="ml-1 text-muted-foreground hover:text-foreground"
onClick={() => {
field.onChange(
field.value?.filter(
(t) => t.id !== topic.id,
),
);
}}
type="button"
>
×
</button>
</div>
);
})}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPaused"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">
{isEditMode ? "Paused" : "Start Paused"}
</FormLabel>
<FormDescription className="text-xs">
{isEditMode
? "Pause webhook notifications"
: "Do not send events yet. You can unpause at any time."}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<DialogFooter className="mt-4 gap-4 border-border border-t bg-card p-6 lg:gap-2">
<Button
disabled={mutation.isPending}
onClick={() => handleOpenChange(false)}
variant="outline"
>
Cancel
</Button>
<Button
className="min-w-28 gap-2"
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending && <Spinner className="size-4" />}
{isEditMode ? "Update" : "Create"} Webhook
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<TopicSelectorModal
onOpenChange={setIsTopicSelectorOpen}
onSelectionChange={(topics) => {
form.setValue("topics", topics);
}}
open={isTopicSelectorOpen}
selectedTopics={form.watch("topics")}
topics={props.topics}
/>
</Dialog>
);
}
@@ -0,0 +1,428 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArrowUpDownIcon,
CalendarIcon,
CheckIcon,
EditIcon,
GlobeIcon,
LetterTextIcon,
MoreHorizontalIcon,
PauseIcon,
PlusIcon,
TrashIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { WebhookSummaryStats } from "@/types/analytics";
import type {
Topic,
WebhookConfig,
} from "../../../../../../../../@/api/webhook-configs";
import { deleteWebhookConfig } from "../../../../../../../../@/api/webhook-configs";
import { CreateWebhookConfigModal } from "./create-webhook-config-modal";
import { DeleteWebhookModal } from "./delete-webhook-modal";
import { EditWebhookConfigModal } from "./edit-webhook-config-modal";
import { WebhookMetrics } from "./webhook-metrics";
type SortById = "description" | "createdAt" | "destinationUrl" | "pausedAt";
export function WebhookConfigsTable(props: {
teamId: string;
teamSlug: string;
projectId: string;
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
metricsMap: Map<string, WebhookSummaryStats | null>;
}) {
const { webhookConfigs } = props;
const [sortBy, setSortBy] = useState<SortById>("createdAt");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(
null,
);
const [deletingWebhook, setDeletingWebhook] = useState<WebhookConfig | null>(
null,
);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async (webhookId: string) => {
const result = await deleteWebhookConfig({
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
webhookConfigId: webhookId,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
},
onError: (error) => {
toast.error("Failed to delete webhook", {
description: error.message,
});
},
onSuccess: () => {
toast.success("Webhook deleted successfully");
setDeletingWebhook(null);
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
},
});
const sortedConfigs = useMemo(() => {
let _configsToShow = webhookConfigs;
if (sortBy === "description") {
_configsToShow = _configsToShow.sort((a, b) =>
(a.description || "").localeCompare(b.description || ""),
);
} else if (sortBy === "createdAt") {
_configsToShow = _configsToShow.sort(
(a, b) =>
new Date(b.createdAt || 0).getTime() -
new Date(a.createdAt || 0).getTime(),
);
} else if (sortBy === "destinationUrl") {
_configsToShow = _configsToShow.sort((a, b) =>
(a.destinationUrl || "").localeCompare(b.destinationUrl || ""),
);
} else if (sortBy === "pausedAt") {
_configsToShow = _configsToShow.sort((a, b) =>
a.pausedAt === b.pausedAt ? 0 : a.pausedAt ? 1 : -1,
);
}
return _configsToShow;
}, [sortBy, webhookConfigs]);
const pageSize = 10;
const [page, setPage] = useState(1);
const paginatedConfigs = sortedConfigs.slice(
(page - 1) * pageSize,
page * pageSize,
);
const showPagination = sortedConfigs.length > pageSize;
const totalPages = Math.ceil(sortedConfigs.length / pageSize);
const hasActiveFilters = sortBy !== "createdAt";
return (
<div>
<div className="relative flex flex-col gap-5 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="mb-1 font-semibold text-2xl tracking-tight">
Configuration
</h2>
<p className="text-muted-foreground text-sm">
Manage your webhook endpoints.
</p>
</div>
{/* Filters + Add New */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<SortDropdown
hasActiveFilters={hasActiveFilters}
onSortChange={(v) => {
setSortBy(v);
setPage(1);
}}
sortBy={sortBy}
/>
<Button
className="gap-1.5 rounded-full"
onClick={() => setIsCreateModalOpen(true)}
>
<PlusIcon className="size-4" />
Create Webhook
</Button>
</div>
</div>
{/* Webhook Configs Table */}
{paginatedConfigs.length === 0 ? (
<div className="flex min-h-[300px] grow items-center justify-center border rounded-lg border-dashed bg-card">
<div className="flex flex-col items-center">
<p className="text-center font-semibold text-lg">
No webhooks created yet.
</p>
</div>
</div>
) : (
<div className="flex grow flex-col">
<TableContainer className={cn(showPagination && "rounded-b-none")}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[150px]">Description</TableHead>
<TableHead className="w-[150px]">Destination URL</TableHead>
<TableHead>Topics</TableHead>
<TableHead>Activity (24h)</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedConfigs.map((config) => (
<TableRow
className="hover:bg-accent/50"
key={config.id}
linkBox
>
<TableCell>
<span className="font-medium text-sm">
{config.description || "No description"}
</span>
</TableCell>
<TableCell className="w-[200px]">
{config.destinationUrl ? (
<CopyTextButton
className="max-w-[200px] text-sm text-muted-foreground font-mono justify-start p-0 h-auto"
textToCopy={config.destinationUrl}
textToShow={
config.destinationUrl.length > 30
? `${config.destinationUrl.substring(0, 30)}...`
: config.destinationUrl
}
variant="ghost"
copyIconPosition="right"
tooltip={config.destinationUrl}
/>
) : (
<span className="text-sm text-muted-foreground">
No URL
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(config.topics || []).slice(0, 3).map((topic) => {
// Check if this topic has filters (for webhook configs from new API)
const hasFilters =
topic.filters &&
Object.keys(topic.filters).length > 0;
return (
<span
className={cn(
"rounded-full px-2 py-1 text-xs",
hasFilters
? "bg-blue-100 text-blue-800"
: "bg-accent",
)}
key={topic.id}
>
{topic.id}
{hasFilters && " *"}
</span>
);
})}
{(config.topics || []).length > 3 && (
<span className="rounded-full bg-accent px-2 py-1 text-xs">
+{(config.topics || []).length - 3}
</span>
)}
</div>
</TableCell>
<TableCell>
<WebhookMetrics
isPaused={!!config.pausedAt}
metrics={props.metricsMap.get(config.id) || null}
/>
</TableCell>
<TableCell className="text-muted-foreground">
{config.createdAt
? format(new Date(config.createdAt), "MMM d, yyyy")
: "Unknown"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-8 w-8 p-0" variant="ghost">
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
setEditingWebhook(config);
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onClick={() => {
setDeletingWebhook(config);
}}
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{showPagination && (
<div className="rounded-b-lg border border-t-0 bg-card p-4">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
)}
<CreateWebhookConfigModal
onOpenChange={setIsCreateModalOpen}
open={isCreateModalOpen}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
/>
{editingWebhook && (
<EditWebhookConfigModal
onOpenChange={(open) => {
if (!open) setEditingWebhook(null);
}}
open={!!editingWebhook}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
webhookConfig={editingWebhook}
/>
)}
<DeleteWebhookModal
isPending={deleteMutation.isPending}
metrics={
deletingWebhook
? props.metricsMap.get(deletingWebhook.id) || null
: null
}
onConfirm={() => {
if (deletingWebhook) {
deleteMutation.mutate(deletingWebhook.id);
}
}}
onOpenChange={(open) => {
if (!open) setDeletingWebhook(null);
}}
open={!!deletingWebhook}
webhookConfig={deletingWebhook}
/>
</div>
);
}
const sortByIcon: Record<SortById, React.FC<{ className?: string }>> = {
createdAt: CalendarIcon,
description: LetterTextIcon,
destinationUrl: GlobeIcon,
pausedAt: PauseIcon,
};
function SortDropdown(props: {
sortBy: SortById;
onSortChange: (value: SortById) => void;
hasActiveFilters: boolean;
}) {
const values: SortById[] = [
"description",
"createdAt",
"destinationUrl",
"pausedAt",
];
const valueToLabel: Record<SortById, string> = {
createdAt: "Creation Date",
description: "Description",
destinationUrl: "Destination URL",
pausedAt: "Pause Status",
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="gap-1.5 rounded-full bg-card" variant="outline">
<ArrowUpDownIcon className="size-4 text-muted-foreground" />
<span>Sort by</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-60 rounded-xl p-1.5 shadow-lg"
sideOffset={10}
>
<DropdownMenuRadioGroup
className="flex flex-col gap-1"
onValueChange={(v) => props.onSortChange(v as SortById)}
value={props.sortBy}
>
{values.map((value) => {
const Icon = sortByIcon[value];
return (
<DropdownMenuItem
className={cn(
"flex cursor-pointer items-center justify-between gap-2 rounded-lg py-2",
props.sortBy === value && "bg-accent",
)}
key={value}
onClick={() => props.onSortChange(value)}
>
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
{valueToLabel[value]}
</div>
{props.sortBy === value ? (
<CheckIcon className="size-4 text-foreground" />
) : (
<div className="size-4" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,38 @@
"use client";
import type { WebhookSummaryStats } from "@/types/analytics";
interface WebhookMetricsProps {
metrics: WebhookSummaryStats | null;
isPaused: boolean;
}
export function WebhookMetrics({ metrics, isPaused }: WebhookMetricsProps) {
if (isPaused) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
Paused
</span>
);
}
if (!metrics) {
return (
<div className="text-sm text-muted-foreground">No metrics available</div>
);
}
const totalRequests = metrics.totalRequests ?? 0;
const errorRequests = metrics.errorRequests ?? 0;
const errorRate =
totalRequests > 0 ? (errorRequests / totalRequests) * 100 : 0;
return (
<div className="text-sm">
<div className="font-medium">{totalRequests} requests</div>
<div className="text-xs text-muted-foreground">
{errorRate.toFixed(1)}% errors
</div>
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page";
export default async function ContractsPage({
params,
}: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [authToken, resolvedParams] = await Promise.all([
getAuthToken(),
params,
]);
const project = await getProject(
resolvedParams.team_slug,
resolvedParams.project_slug,
);
if (!project || !authToken) {
notFound();
}
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Contract Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified about blockchain events, transactions and more.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/insight/webhooks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</p>
<div className="h-4" />
<ContractsWebhooksPageContent authToken={authToken} project={project} />
</div>
);
}
@@ -1,3 +1,5 @@
import { getValidAccount } from "@app/account/settings/getAccount";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { TabPathLinks } from "@/components/ui/tabs";
export default async function WebhooksLayout(props: {
@@ -7,6 +9,13 @@ export default async function WebhooksLayout(props: {
project_slug: string;
}>;
}) {
const account = await getValidAccount();
const isFeatureEnabled = await isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
});
const params = await props.params;
return (
<div className="flex grow flex-col">
@@ -21,20 +30,46 @@ export default async function WebhooksLayout(props: {
</div>
</div>
<TabPathLinks
links={[
{
exactMatch: true,
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
},
{
name: "Payments",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
scrollableClassName="container max-w-7xl"
/>
{isFeatureEnabled ? (
<TabPathLinks
links={[
{
exactMatch: true,
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
},
{
exactMatch: true,
name: "Analytics",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/analytics`,
},
{
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Payments",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
scrollableClassName="container max-w-7xl"
/>
) : (
<TabPathLinks
links={[
{
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Payments",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
scrollableClassName="container max-w-7xl"
/>
)}
<div className="h-6" />
<div className="container flex max-w-7xl grow flex-col">
{props.children}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -1,45 +1,97 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { getWebhookSummary } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { ContractsWebhooksPageContent } from "./contract-webhooks/contract-webhooks-page";
import { getAvailableTopics, getWebhookConfigs } from "@/api/webhook-configs";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { WebhooksOverview } from "./components/overview";
export default async function WebhooksPage({
params,
}: {
export default async function WebhooksPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [authToken, resolvedParams] = await Promise.all([
const [authToken, params, account] = await Promise.all([
getAuthToken(),
params,
props.params,
getValidAccount(),
]);
const project = await getProject(
resolvedParams.team_slug,
resolvedParams.project_slug,
);
if (!account || !authToken) {
notFound();
}
const [isFeatureEnabled, project] = await Promise.all([
isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
}),
getProject(params.team_slug, params.project_slug),
]);
if (!project || !authToken) {
notFound();
}
if (!isFeatureEnabled) {
redirect(
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
);
}
// Fetch webhook configs and topics in parallel
const [webhookConfigsResult, topicsResult] = await Promise.all([
getWebhookConfigs({
projectIdOrSlug: params.project_slug,
teamIdOrSlug: params.team_slug,
}),
getAvailableTopics(),
]);
if (
webhookConfigsResult.status === "error" ||
topicsResult.status === "error"
) {
notFound();
}
const webhookConfigs = webhookConfigsResult.data || [];
const topics = topicsResult.data || [];
// Fetch metrics for all webhooks in parallel
const webhookMetrics = await Promise.all(
webhookConfigs.map(async (config) => {
const metricsResult = await getWebhookSummary({
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
period: "day",
projectId: project.id,
teamId: project.teamId, // 24 hours ago
to: new Date(),
webhookId: config.id,
});
return {
metrics:
"error" in metricsResult ? null : (metricsResult.data[0] ?? null),
webhookId: config.id,
};
}),
);
// Create a map for easy lookup
const metricsMap = new Map(
webhookMetrics.map((item) => [item.webhookId, item.metrics]),
);
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Contract Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified about blockchain events, transactions and more.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/insight/webhooks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</p>
<div className="h-4" />
<ContractsWebhooksPageContent authToken={authToken} project={project} />
</div>
<WebhooksOverview
metricsMap={metricsMap}
projectId={project.id}
projectSlug={params.project_slug}
teamId={project.teamId}
teamSlug={params.team_slug}
topics={topics}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -1,7 +1,8 @@
"use client";
import { useTheme } from "next-themes";
import { PayEmbed, type TokenInfo } from "thirdweb/react";
import type { Address } from "thirdweb";
import { BuyWidget, type TokenInfo } from "thirdweb/react";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { bridgeAppThirdwebClient } from "../../constants";
@@ -19,19 +20,11 @@ export function UniversalBridgeEmbed({
const chain = useV5DashboardChain(chainId || 1);
return (
<PayEmbed
<BuyWidget
client={bridgeAppThirdwebClient}
payOptions={{
mode: "fund_wallet",
prefillBuy:
chainId && token
? {
amount,
chain,
token,
}
: undefined,
}}
amount={amount || "0"}
chain={chain}
tokenAddress={token?.address as Address | undefined}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
/>
);
@@ -73,7 +73,7 @@ function Example() {
<${componentName}
client={client}
chain={defineChain(${options.payOptions.buyTokenChain.id})}
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t token="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t token="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
options.payOptions.widget === "transaction"
? `\n\t transaction={claimTo({
contract: nftContract,
@@ -2,6 +2,34 @@ import type { Chain } from "thirdweb/chains";
import type { ThemeOverrides } from "thirdweb/react";
import type { Address } from "thirdweb/utils";
const CURRENCIES = [
"USD",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
] as const;
type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
export type BridgeComponentsPlaygroundOptions = {
theme: {
type: "dark" | "light";
@@ -26,6 +54,8 @@ export type BridgeComponentsPlaygroundOptions = {
paymentMethods: ("crypto" | "card")[];
currency?: SupportedFiatCurrency;
showThirdwebBranding: boolean;
};
};
@@ -17,6 +17,13 @@ import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import type { TokenMetadata } from "@/lib/types";
@@ -134,6 +141,51 @@ export function LeftSection(props: {
/>
</section>
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<Select
value={payOptions.currency || "USD"}
onValueChange={(value) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
currency: value,
},
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
</section>
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
{(!payOptions.widget ||
payOptions.widget === "buy" ||
@@ -64,6 +64,7 @@ export function RightSection(props: {
theme={themeObj}
title={props.options.payOptions.title}
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
@@ -88,6 +89,7 @@ export function RightSection(props: {
seller={props.options.payOptions.sellerAddress}
theme={themeObj}
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
@@ -108,6 +110,7 @@ export function RightSection(props: {
to: account?.address || "",
tokenId: 2n,
})}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
@@ -18,6 +18,7 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
title: "",
transactionData: "",
widget: "buy",
currency: "USD",
showThirdwebBranding: true,
},
theme: {
+28
View File
@@ -1,5 +1,33 @@
# @thirdweb-dev/nebula
## 0.2.16
### Patch Changes
- Updated dependencies [[`f33e31a`](https://github.com/thirdweb-dev/js/commit/f33e31ad6e045fe751c2d7a51874f8a13e99861f), [`592bf6f`](https://github.com/thirdweb-dev/js/commit/592bf6f1fdd4f1a2babbef94a37b93881d28b6f5)]:
- thirdweb@5.105.16
## 0.2.15
### Patch Changes
- Updated dependencies [[`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb), [`d62e997`](https://github.com/thirdweb-dev/js/commit/d62e9971ee7707211b513509de80a5b0abdaa4d0)]:
- thirdweb@5.105.15
## 0.2.14
### Patch Changes
- Updated dependencies [[`f3234c3`](https://github.com/thirdweb-dev/js/commit/f3234c3c414f2fb4ee41c2394bf52a56fa081f73)]:
- thirdweb@5.105.14
## 0.2.13
### Patch Changes
- Updated dependencies [[`772eb0c`](https://github.com/thirdweb-dev/js/commit/772eb0cc53982951dc6b6baed1c4411bd3e39934), [`446a69b`](https://github.com/thirdweb-dev/js/commit/446a69b1d18b8711324b24377abecdbda10b8a1f)]:
- thirdweb@5.105.13
## 0.2.12
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.12"
"version": "0.2.16"
}
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/service-utils
## 0.10.3
### Patch Changes
- [#7570](https://github.com/thirdweb-dev/js/pull/7570) [`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add encryption utilities
## 0.10.2
### Patch Changes

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