Compare commits

...
Author SHA1 Message Date
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
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
5c6dd874e0 Version Packages (#7577)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-09 16:55:48 -07:00
a9e47e4759 Replace PayEmbed with BuyWidget code (#7574)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-09 15:47:29 -07:00
bfd728a9d1 Add branding hide prop to widgets (#7575)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-09 15:46:29 -07:00
saminacodes 381a2823d7 [Docs] Remove Nebula from portal navigation (#7576)
```
<!--

## title your PR with this format: "[Portal] Feature: Remove Nebula from portal navigation"

## Notes for the reviewer

This PR removes all user-facing references to Nebula from the portal navigation and homepage, as requested.

-   **Key point:** All Nebula pages and underlying content remain untouched; only navigation links and homepage sections were removed.
-   Removed Nebula from the main header navigation and the APIs dropdown.
-   Removed Nebula sections from the homepage (API Reference and Learning Resources).
-   Updated the Vault section icon on the homepage from `NebulaIcon` (which was removed) to `ConnectIcon`.

## How to test

1.  Run the portal locally.
2.  Verify "Nebula" is no longer present in the main navigation bar or the "APIs" dropdown.
3.  On the homepage (`/`), confirm that the "Nebula" cards are removed from the "API Reference" and "Learning Resources" sections.
4.  Confirm that the "Vault" card in the "Learning Resources" section now uses the `ConnectIcon`.
5.  Run `npm run portal:build` to ensure the project builds successfully.

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C04DYC3G5NZ/p1752094214240499?thread_ts=1752094214.240499&cid=C04DYC3G5NZ)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on removing references to `Nebula` from the application, including links and UI components, while retaining and emphasizing `Insight` and `Vault`.

### Detailed summary
- Removed `Nebula` link from `connectLinks` in `Header.tsx`.
- Deleted `Nebula` API reference card from `ReferenceSection` in `page.tsx`.
- Eliminated `Nebula` card from `LearningResourcesSection` in `page.tsx`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * None.

* **Bug Fixes**
  * None.

* **Refactor**
  * Removed all references to the "Nebula" feature from navigation menus and resource sections.
  * Updated the icon for the "Vault" article card.

* **Chores**
  * Cleaned up unused imports related to "Nebula".

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 21:53:40 +00:00
saminacodes 2111b8b12c Rename Universal Bridge to Payments (#7571)
```
<!--

## title your PR with this format: "[Dashboard] Feature: Rename Universal Bridge to Payments"

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

## Notes for the reviewer

This PR renames all instances of "Universal Bridge" to "Payments" across the projects dashboard. This is a branding update and does not change any underlying functionality or URLs.

Changes include:
- Sidebar navigation label
- Page titles and descriptions
- Callouts and sub-text (e.g., analytics, FTUX)
- Webhooks section (tab name and heading)

## How to test

1. Navigate to any project dashboard.
2. Verify the sidebar now shows "Payments" instead of "Universal Bridge".
3. Click on "Payments" in the sidebar and confirm the page title and description are updated.
4. Check the "Analytics" page to ensure "Universal Bridge" references are updated to "Payments".
5. Go to "Webhooks" and then the "Payments" tab to confirm the heading and tab name are correct.
6. Verify the FTUX section also reflects "Payments".

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C08GEMP858V/p1752037029611589?thread_ts=1752037029.611589&cid=C08GEMP858V)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming references from "Universal Bridge" to "Payments" throughout the codebase, reflecting a change in terminology for a feature that facilitates payments.

### Detailed summary
- Updated `name` in `layout.tsx` from "Universal Bridge" to "Payments".
- Changed `label` in `ProjectSidebarLayout.tsx` from "Universal Bridge" to "Payments".
- Modified `title` in `ProjectFTUX.tsx` from "Universal Bridge" to "Payments".
- Changed heading in `page.tsx` from "Universal Bridge Webhooks" to "Payments Webhooks".
- Updated description in `analytics/page.tsx` from "Universal Bridge" to "Payments".
- Changed description in `sidebar/page.tsx` from "Universal Bridge" to "Payments".
- Updated heading in `universal-bridge/page.tsx` from "Get Started with Universal Bridge" to "Get Started with Payments".
- Changed heading in `universal-bridge/layout.tsx` from "Universal Bridge" to "Payments".
- Updated description in `universal-bridge/layout.tsx` to reflect "Payments" instead of "Universal Bridge".

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

<!-- end pr-codex -->
2025-07-09 05:13:22 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1665e4d534 Version Packages (#7568)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-08 17:18:24 -07:00
Manan TankandGitHub 692cedf921 [TOOL-4987] SDK: Restore CSS resets, remove biome rules for sorting attributes and properties (#7567) 2025-07-08 15:47:22 -07:00
e1f4901b80 [SDK] Remove page and set up redirect (#7565)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-08 12:06:36 -07:00
MananTank 028f871d40 [TOOL-4972] Dashboard: Add field to configure admins in NFT asset creation (#7542)
<!--

## 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 NFT creation process by adding the ability to set admin addresses and refining various components for better user experience and validation.

### Detailed summary
- Added `"set-admins"` step to the NFT asset type.
- Introduced `AdminAddressesFieldset` for managing admin addresses.
- Updated forms to include admin validation.
- Improved UI components with consistent styling.
- Enhanced error handling during admin setting.

> ✨ 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 the ability to specify multiple admin wallet addresses when creating an NFT collection, with dynamic form fields and validation.
  * NFT launch workflow now includes a step to assign admin roles to specified addresses.
  * NFT collection info and launch dialogs now display the list of assigned admins.

* **Improvements**
  * The NFT and token creation forms now automatically set the default blockchain network based on the active wallet chain.
  * NFT upload form includes default values for price and supply fields.
  * Visual spacing adjustments in social URL and token launch dialogs for improved UI consistency.

* **Bug Fixes**
  * Ensured at least one admin address is required when creating an NFT collection.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-08 15:37:47 +00:00
2821b4eeb7 Make contract webhook addresses mandatory (#7544)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-08 15:20:49 +02:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
adfa56b2a2 Version Packages (#7555)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-07 18:54:19 -07:00
YashandGitHub 415ad90986 feat: add T-Rex testnet chain (#7540) 2025-07-07 18:17:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
afcb7bba84 chore(deps): bump zod from 3.25.71 to 3.25.75 (#7552)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 18:15:59 -07:00
Joaquim VergesandGitHub 10c3b6f27f [SDK] Respect fee payer in token payment selection quotes (#7556) 2025-07-07 18:14:50 -07:00
Joaquim VergesandGitHub c64446306e [SDK] Use new BuyWidget in wallet details modal (#7551) 2025-07-07 17:18:28 -07:00
saminaandGitHub 2136c7ba4a [Docs] Wallets Overview (#7543) 2025-07-07 16:21:33 -07:00
MananTank 67c3d861fe [TOOL-4986] Dashboard: Fix ERC20 asset page crash (#7541)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling by adding `try-catch` blocks around asynchronous calls in the `getContractCreator` function. This change ensures that if an error occurs during the execution of `owner` or `getRoleMember`, the function will return `null` instead of throwing an unhandled error.

### Detailed summary
- Wrapped the calls to `owner` and `getRoleMember` in a `try-catch` block.
- Changed the calls to `owner` and `getRoleMember` to use `await` for proper asynchronous handling.
- Ensured that if an error occurs, the function returns `null` instead of failing silently.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved error handling when retrieving contract creator information, ensuring failures no longer cause crashes and instead return a safe null value.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-07 14:15:01 +00:00
d2c12cc6ef Create session key guide and update sidebar (#7538)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-07 03:30:38 -07:00
653 changed files with 13553 additions and 17344 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=
+2 -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",
@@ -81,7 +82,7 @@
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.71"
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
@@ -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
},
);
+5 -1
View File
@@ -362,7 +362,11 @@ export function reportAssetCreationFailed(
properties: { contractType: AssetContractType; error: string } & (
| {
assetType: "nft";
step: "deploy-contract" | "mint-nfts" | "set-claim-conditions";
step:
| "deploy-contract"
| "mint-nfts"
| "set-claim-conditions"
| "set-admins";
}
| {
assetType: "coin";
+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>
);
@@ -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,10 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
<CreateProjectDialogUI
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
});
return {
project: res.project,
secret: res.secret,
+15 -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 { createVaultAccountAndAccessToken } 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,35 @@ 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 a vault admin key, rotate it as well
await createVaultAccountAndAccessToken({
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,
),
});
+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>
);
@@ -9,19 +9,23 @@ export async function getContractCreator(
contract: ThirdwebContract,
functionSelectors: string[],
) {
if (isOwnerSupported(functionSelectors)) {
return owner({
contract,
});
}
try {
if (isOwnerSupported(functionSelectors)) {
return await owner({
contract,
});
}
if (isGetRoleAdminSupported(functionSelectors)) {
return getRoleMember({
contract,
index: BigInt(0),
role: "admin",
});
}
if (isGetRoleAdminSupported(functionSelectors)) {
return await getRoleMember({
contract,
index: BigInt(0),
role: "admin",
});
}
return null;
return null;
} catch {
return null;
}
}
@@ -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;
}
@@ -367,7 +367,7 @@ function AppHighlightsCard({
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/connect/pay/overview"
metric="Payments"
/>
@@ -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>
@@ -257,7 +256,7 @@ function ProductsSection(props: { teamSlug: string; projectSlug: string }) {
"Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing",
href: `/team/${props.teamSlug}/${props.projectSlug}/universal-bridge`,
icon: PayIcon,
title: "Universal Bridge",
title: "Payments",
},
];
@@ -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
@@ -75,7 +81,7 @@ export function ProjectSidebarLayout(props: {
{
href: `${layoutPath}/universal-bridge`,
icon: PayIcon,
label: "Universal Bridge",
label: "Payments",
},
{
href: `${layoutPath}/tokens`,
@@ -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>
@@ -435,7 +435,7 @@ function AppHighlightsCard({
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/connect/pay/overview"
metric="Payments"
/>
@@ -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}
@@ -34,7 +34,7 @@ export function SocialUrlsFieldset<T extends WithSocialUrls>(props: {
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
{fields.length > 0 && (
<div className="mb-5 space-y-4">
<div className="mb-4 space-y-3">
{fields.map((field, index) => (
<div
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
@@ -0,0 +1,115 @@
"use client";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { type UseFormReturn, useFieldArray } from "react-hook-form";
import { useActiveAccount } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
type WithAdmins = {
admins: {
address: string;
}[];
};
export function AdminAddressesFieldset<T extends WithAdmins>(props: {
form: UseFormReturn<T>;
}) {
// T contains all properties of WithAdmins, so this is ok
const form = props.form as unknown as UseFormReturn<WithAdmins>;
const account = useActiveAccount();
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "admins",
});
const handleAddAddress = () => {
append({ address: "" });
};
const handleRemoveAddress = (index: number) => {
const field = fields[index];
if (field?.address === account?.address) {
return; // Don't allow removing the connected address
}
remove(index);
};
return (
<div className="border-t border-dashed px-4 py-6 lg:px-6">
<div className="mb-3">
<h2 className="mb-1 font-medium text-sm">Admins</h2>
<p className="text-sm text-muted-foreground">
These wallets will have authority on the token
</p>
</div>
{fields.length > 0 && (
<div className="mb-4 space-y-3">
{fields.map((field, index) => (
<div
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
key={field.id}
>
<div className="flex flex-1 flex-col gap-3 lg:flex-row">
<FormField
control={form.control}
name={`admins.${index}.address`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...field}
aria-label="Admin Address"
disabled={field.value === account?.address}
placeholder="0x..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
className="rounded-full"
disabled={field.address === account?.address}
onClick={() => handleRemoveAddress(index)}
size="icon"
type="button"
variant="outline"
>
<Trash2Icon className="h-4 w-4" />
<span className="sr-only">Remove</span>
</Button>
</div>
))}
</div>
)}
<Button
className="h-auto gap-1.5 rounded-full px-3 py-1.5 text-xs"
onClick={handleAddAddress}
size="sm"
type="button"
variant="outline"
>
<PlusIcon className="size-3.5" />
Add Admin
</Button>
{form.watch("admins").length === 0 && (
<p className="text-sm text-destructive mt-2">
At least one admin address is required
</p>
)}
</div>
);
}
@@ -22,6 +22,22 @@ export const socialUrlsSchema = z.array(
}),
);
export const addressArraySchema = z.array(
z.object({
address: z.string().refine(
(value) => {
if (isAddress(value)) {
return true;
}
return false;
},
{
message: "Invalid address",
},
),
}),
);
export const addressSchema = z.string().refine(
(value) => {
if (isAddress(value)) {
@@ -1,9 +1,15 @@
import { isAddress } from "thirdweb";
import * as z from "zod";
import { socialUrlsSchema } from "../../_common/schema";
import {
addressArraySchema,
addressSchema,
socialUrlsSchema,
} from "../../_common/schema";
import type { NFTMetadataWithPrice } from "../upload-nfts/batch-upload/process-files";
export const nftCollectionInfoFormSchema = z.object({
admins: addressArraySchema.refine((addresses) => addresses.length > 0, {
message: "At least one admin is required",
}),
chain: z.string().min(1, "Chain is required"),
description: z.string().optional(),
image: z.instanceof(File).optional(),
@@ -12,14 +18,6 @@ export const nftCollectionInfoFormSchema = z.object({
symbol: z.string(),
});
const addressSchema = z.string().refine((value) => {
if (isAddress(value)) {
return true;
}
return false;
});
export const nftSalesSettingsFormSchema = z.object({
primarySaleRecipient: addressSchema,
royaltyBps: z.coerce.number().min(0).max(10000),
@@ -37,15 +35,36 @@ export type CreateNFTCollectionAllValues = {
};
export type CreateNFTCollectionFunctions = {
setAdmins: (values: {
contractAddress: string;
contractType: "DropERC721" | "DropERC1155";
admins: {
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: {
@@ -54,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>;
};
};
@@ -10,6 +10,7 @@ import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
import { StepCard } from "../../_common/step-card";
import type { NFTCollectionInfoFormValues } from "../_common/form";
@@ -126,6 +127,8 @@ export function NFTCollectionInfoFieldset(props: {
</div>
<SocialUrlsFieldset form={form} />
<AdminAddressesFieldset form={form} />
</StepCard>
</form>
</Form>
@@ -8,7 +8,7 @@ import {
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { reportAssetCreationStepConfigured } from "@/analytics/report";
import type { Team } from "@/api/team";
import {
@@ -163,11 +163,16 @@ export function CreateNFTPageUI(props: {
}
function useNFTCollectionInfoForm() {
const chain = useActiveWalletChain();
const account = useActiveAccount();
return useForm<NFTCollectionInfoFormValues>({
resolver: zodResolver(nftCollectionInfoFormSchema),
reValidateMode: "onChange",
values: {
chain: "1",
defaultValues: {
admins: [
{
address: account?.address || "",
},
],
chain: chain?.id.toString() || "1",
description: "",
image: undefined,
name: "",
@@ -183,5 +188,7 @@ function useNFTCollectionInfoForm() {
],
symbol: "",
},
resolver: zodResolver(nftCollectionInfoFormSchema),
reValidateMode: "onChange",
});
}
@@ -18,8 +18,10 @@ import {
lazyMint as lazyMint1155,
setClaimConditions as setClaimConditions1155,
} from "thirdweb/extensions/erc1155";
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,
@@ -41,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));
@@ -59,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));
@@ -92,7 +101,7 @@ export function CreateNFTPage(props: {
if (ercType === "erc721") {
contractAddress = await deployERC721Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -109,7 +118,7 @@ export function CreateNFTPage(props: {
});
} else {
contractAddress = await deployERC1155Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -165,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) {
@@ -204,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");
}
@@ -234,7 +253,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -258,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);
@@ -314,7 +338,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (error) {
@@ -332,47 +356,106 @@ export function CreateNFTPage(props: {
}
}
async function handleSetAdmins(params: {
contractAddress: string;
contractType: "DropERC721" | "DropERC1155";
admins: {
address: string;
}[];
gasless: boolean;
chain: string;
}) {
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 !== account.address,
);
const encodedTxs = await Promise.all(
adminsToAdd.map((admin) => {
const tx = grantRole({
contract,
role: "admin",
targetAccountAddress: admin.address,
});
return encode(tx);
}),
);
const tx = multicall({
contract,
data: encodedTxs,
});
try {
await sendAndConfirmTransaction({
account,
transaction: tx,
});
} catch (e) {
const errorMessage = parseError(e);
console.error(errorMessage);
reportAssetCreationFailed({
assetType: "nft",
contractType: params.contractType,
error: errorMessage,
step: "set-admins",
});
throw e;
}
}
return (
<CreateNFTPageUI
{...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) => {
return handleSetClaimConditionsERC1155(params);
},
},
setAdmins: handleSetAdmins,
}}
onLaunchSuccess={() => {
revalidatePathAction(
@@ -8,12 +8,18 @@ import {
import Link from "next/link";
import { useMemo, useRef, useState } from "react";
import { defineChain, type ThirdwebClient } from "thirdweb";
import { TokenProvider, TokenSymbol, useActiveWallet } from "thirdweb/react";
import {
TokenProvider,
TokenSymbol,
useActiveAccount,
useActiveWallet,
} from "thirdweb/react";
import {
reportAssetCreationFailed,
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";
@@ -41,6 +47,7 @@ import type {
const stepIds = {
"deploy-contract": "deploy-contract",
"mint-nfts": "mint-nfts",
"set-admins": "set-admins",
"set-claim-conditions": "set-claim-conditions",
} as const;
@@ -61,7 +68,8 @@ export function LaunchNFT(props: {
const [isModalOpen, setIsModalOpen] = useState(false);
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
const [contractLink, setContractLink] = useState<string | null>(null);
const account = useActiveAccount();
const contractAddressRef = useRef<string | null>(null);
function updateStatus(
index: number,
@@ -108,6 +116,19 @@ export function LaunchNFT(props: {
},
];
if (
account &&
formValues.collectionInfo.admins.some(
(admin) => admin.address !== account.address,
)
) {
initialSteps.push({
id: stepIds["set-admins"],
label: "Set admins",
status: { type: "idle" },
});
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0);
@@ -134,16 +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);
setContractLink(
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${result.contractAddress}`,
);
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;
@@ -168,6 +201,7 @@ export function LaunchNFT(props: {
count: batchSize,
startIndex: batchIndex * batchSize,
},
gasless: isGasless,
values: formValues,
});
@@ -179,12 +213,29 @@ 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) {
throw new Error("Contract address not set");
}
await props.createNFTFunctions.setAdmins({
admins: formValues.collectionInfo.admins,
chain: formValues.collectionInfo.chain,
contractAddress: contractAddressRef.current,
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
gasless: isGasless,
});
}
}
@@ -277,6 +328,7 @@ export function LaunchNFT(props: {
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -285,7 +337,7 @@ export function LaunchNFT(props: {
variant="default"
>
<ArrowUpFromLineIcon className="size-4" />
Launch NFT
Launch NFT Collection
</TransactionButton>
),
type: "custom",
@@ -293,7 +345,7 @@ export function LaunchNFT(props: {
prevButton={{
onClick: props.onPrevious,
}}
title="Launch NFT"
title="Launch NFT Collection"
>
<Dialog
open={isModalOpen}
@@ -304,7 +356,7 @@ export function LaunchNFT(props: {
dialogCloseClassName="hidden"
>
<div className="flex flex-col gap-6 p-6">
<DialogHeader>
<DialogHeader className="space-y-0.5">
<DialogTitle className="font-semibold text-xl tracking-tight">
Status
</DialogTitle>
@@ -412,10 +464,29 @@ export function LaunchNFT(props: {
}
/>
</OverviewField>
<OverviewField
name={
formValues.collectionInfo.admins.length > 1 ? "Admins" : "Admin"
}
>
<div className="space-y-0.5">
{formValues.collectionInfo.admins.map((admin) => (
<WalletAddress
address={admin.address}
className="text-foreground text-sm h-auto py-1"
client={props.client}
iconClassName="size-3.5"
key={admin.address}
/>
))}
</div>
</OverviewField>
</div>
</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">
@@ -457,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">
@@ -485,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>
);
}
@@ -4,7 +4,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeftIcon, ArrowRightIcon, AsteriskIcon } from "lucide-react";
import { useId } from "react";
import { useForm } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import { FileInput } from "@/components/blocks/FileInput";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { TokenSelector } from "@/components/blocks/TokenSelector";
@@ -32,6 +36,8 @@ import type { NFTMetadataWithPrice } from "../batch-upload/process-files";
import { nftWithPriceSchema } from "../schema";
import { AttributesFieldset } from "./attributes";
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
export function SingleUploadNFT(props: {
client: ThirdwebClient;
onNext: () => void;
@@ -46,6 +52,9 @@ export function SingleUploadNFT(props: {
description: "",
image: undefined,
name: "",
price_amount: "1",
price_currency: nativeTokenAddress,
supply: "1",
},
resolver: zodResolver(nftWithPriceSchema),
reValidateMode: "onChange",
@@ -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(),
},
];
@@ -8,6 +8,7 @@ import {
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import { useActiveWalletChain } from "thirdweb/react";
import { reportAssetCreationStepConfigured } from "@/analytics/report";
import type { Team } from "@/api/team";
import {
@@ -21,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);
@@ -44,12 +50,11 @@ export function CreateTokenAssetPageUI(props: {
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
"token-info",
);
const activeWalletChain = useActiveWalletChain();
const tokenInfoForm = useForm<TokenInfoFormValues>({
resolver: zodResolver(tokenInfoFormSchema),
reValidateMode: "onChange",
values: {
chain: "1",
defaultValues: {
chain: activeWalletChain?.id.toString() || "1",
description: "",
image: undefined,
name: "",
@@ -65,6 +70,8 @@ export function CreateTokenAssetPageUI(props: {
],
symbol: "",
},
resolver: zodResolver(tokenInfoFormSchema),
reValidateMode: "onChange",
});
const tokenDistributionForm = useForm<TokenDistributionFormValues>({
@@ -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
@@ -262,7 +303,7 @@ export function LaunchTokenStatus(props: {
dialogCloseClassName="hidden"
>
<div className="flex flex-col gap-6 p-6">
<DialogHeader>
<DialogHeader className="space-y-0.5">
<DialogTitle className="font-semibold text-xl tracking-tight">
Status
</DialogTitle>
@@ -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,6 +1,6 @@
"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";
@@ -9,7 +9,6 @@ import { CreateVaultAccountButton } from "../../vault/components/create-vault-ac
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 +18,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 +32,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 +42,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 +62,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 +75,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 +86,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 (
@@ -122,10 +104,7 @@ function CreateVaultAccountStep(props: {
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateVaultAccountButton
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
project={props.project}
/>
<CreateVaultAccountButton project={props.project} />
<Button asChild variant="outline">
<Link
href="https://portal.thirdweb.com/vault"
@@ -11,7 +11,6 @@ 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 { Input } from "@/components/ui/input";
import {
Select,
@@ -24,10 +23,9 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
import { useDashboardRouter } from "@/lib/DashboardRouter";
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(),
});
@@ -38,9 +36,9 @@ export function SendTestTransaction(props: {
wallets?: Wallet[];
project: Project;
teamSlug: string;
userAccessToken?: string;
expanded?: boolean;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
}) {
const queryClient = useQueryClient();
@@ -49,12 +47,9 @@ export function SendTestTransaction(props: {
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 +68,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 +88,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",
@@ -123,7 +120,7 @@ export function SendTestTransaction(props: {
const onSubmit = async (data: FormValues) => {
await sendDummyTxMutation.mutateAsync({
accessToken: data.accessToken,
secretKey: data.secretKey,
chainId: data.chainId,
walletAddress: selectedWallet.address,
});
@@ -141,44 +138,39 @@ export function SendTestTransaction(props: {
)}
<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."}
Every server wallet action requires your{" "}
{props.isManagedVault ? "project secret key" : "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}
/>
)}
<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>
<div className="h-4" />
<div className="h-6" />
{/* 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">
@@ -268,8 +260,6 @@ export function SendTestTransaction(props: {
} else {
router.refresh();
}
// clear token from local storage after FTUX is complete
deleteUserAccessToken(props.project.id);
}}
variant="primary"
>
@@ -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,183 @@ export async function initVaultClient() {
return vc;
}
export async function createVaultAccountAndAccessToken(props: {
project: Project;
projectSecretKey?: string;
projectSecretHash?: string;
}) {
try {
const vaultClient = await initVaultClient();
const service = props.project.services.find(
(service) => service.name === "engineCloud",
);
const storedRotationCode = service?.rotationCode;
const storedEncryptedAdminKey = service?.encryptedAdminKey;
let adminKey: string | null = null;
let rotationCode: string | null = null;
if (storedRotationCode && storedEncryptedAdminKey) {
// if the project has a managed vault admin key, rotate it
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
auth: {
rotationCode: storedRotationCode,
},
},
});
if (rotateServiceAccountRes.error) {
throw new Error(rotateServiceAccountRes.error.message);
}
adminKey = rotateServiceAccountRes.data.newAdminKey;
rotationCode = rotateServiceAccountRes.data.newRotationCode;
} else {
// otherwise create a new service account
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;
adminKey = serviceAccount.adminKey;
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;
@@ -243,13 +423,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 +512,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}
@@ -203,7 +203,6 @@ const client = createThirdwebClient({
const serverWallet = Engine.serverWallet({
client,
address: "<your-server-wallet-address>",
vaultAccessToken: "<your-vault-access-token>",
});
// Prepare the transaction
@@ -241,7 +240,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 +262,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 +287,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 +351,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 +403,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,13 +1,20 @@
export const embedCode = (clientId: string) => `\
import { createThirdwebClient } from "thirdweb";
import { PayEmbed } from "thirdweb/react";
import { BuyWidget } from "thirdweb/react";
import { ethereum } from "thirdweb/chains";
const client = createThirdwebClient({
clientId: "${clientId}",
});
export default function App() {
return <PayEmbed client={client} />;
return (
<BuyWidget
client={client}
chain={ethereum}
amount="0.1"
/>
);
}`;
export const sdkCode = (clientId: string) => `\
@@ -25,10 +25,10 @@ export default async function Layout(props: {
<div className="pt-4 lg:pt-6">
<div className="container max-w-7xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Universal Bridge
Payments
</h1>
<p className="max-w-3xl text-muted-foreground text-sm leading-relaxed">
Universal Bridge allows your users to bridge, swap, and purchase
Payments allows your users to bridge, swap, and purchase
cryptocurrencies and execute transactions with any fiat options or
tokens via cross-chain routing.{" "}
<UnderlineLink
@@ -63,9 +63,7 @@ export default async function Page(props: {
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with Universal Bridge
</h3>
<h3 className="font-medium text-lg">Get Started with Payments</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
</p>
@@ -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) {
// 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,
createVaultAccountAndAccessToken,
maskSecret,
} 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 createVaultAccountAndAccessToken({
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>
);
}
@@ -109,7 +109,9 @@ export function CreateContractWebhookButton({
await handleSubmit(data);
} catch (error) {
toast.error(
`Failed to process webhook: ${error instanceof Error ? error.message : String(error)}`,
`Failed to process webhook: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
@@ -142,7 +144,9 @@ export function CreateContractWebhookButton({
router.refresh();
} catch (error) {
toast.error(
`Failed to create webhook: ${error instanceof Error ? error.message : String(error)}`,
`Failed to create webhook: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
setIsLoading(false);
@@ -173,7 +177,11 @@ export function CreateContractWebhookButton({
if (filterType === "event") {
result = await formHook.trigger(["chainIds", "addresses"]);
} else {
result = await formHook.trigger(["chainIds", "toAddresses"]);
result = await formHook.trigger([
"chainIds",
"toAddresses",
"fromAddresses",
]);
}
if (result) {
@@ -131,7 +131,9 @@ export function FilterDetailsStep({
render={({ field }) => (
<FormItem className="flex flex-col">
<div className="flex items-center justify-between text-xs">
<FormLabel>Contract Addresses</FormLabel>
<FormLabel>
Contract Addresses <span className="text-red-500">*</span>
</FormLabel>
<p className="text-muted-foreground">
Enter a contract address
</p>
@@ -196,7 +198,9 @@ export function FilterDetailsStep({
render={({ field }) => (
<FormItem className="flex flex-col">
<div className="flex items-center justify-between text-xs">
<FormLabel>From Address</FormLabel>
<FormLabel>
From Address <span className="text-red-500">*</span>
</FormLabel>
<p className="text-muted-foreground">
Enter a from address
</p>
@@ -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: "Universal Bridge",
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" />;
}
@@ -19,7 +19,7 @@ export default async function Page(props: {
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Universal Bridge Webhooks
Payments Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified for Bridge, Swap and Onramp events.{" "}
@@ -24,75 +24,108 @@ const inputAbi = z.object({
type: z.string(),
});
export const webhookFormSchema = z.object({
abi: z.string().optional(),
addresses: z
.string()
.optional()
.refine(
(val) => {
if (val === undefined || val.trim() === "") {
return true;
}
return val
.split(/[,\s]+/)
.filter(Boolean)
.every((a) => isAddress(a.trim()));
},
{
message: "Enter valid addresses (comma-separated) or leave empty",
},
),
chainIds: z
.array(z.string())
.min(1, { message: "Select at least one chain" }),
eventTypes: z.array(z.string()).optional(),
filterType: z.enum(["event", "transaction"]),
fromAddresses: z
.string()
.optional()
.refine(
(val) => {
if (val === undefined || val.trim() === "") {
return true;
}
return val
.split(/[,\s]+/)
.filter(Boolean)
.every((a) => isAddress(a.trim()));
},
{
message: "Enter valid addresses (comma-separated) or leave empty",
},
),
inputAbi: z.array(inputAbi).optional(),
name: z
.string()
.min(3, { message: "Name must be at least 3 characters long" })
.max(100, { message: "Name must be at most 100 characters long" }),
params: z.record(z.unknown()).optional(),
secret: z.string().optional(),
sigHash: z.string().optional(),
sigHashAbi: z.string().optional(),
toAddresses: z
.string()
.optional()
.refine(
(val) => {
if (val === undefined || val.trim() === "") {
return true;
}
return val
.split(/[,\s]+/)
.filter(Boolean)
.every((a) => isAddress(a.trim()));
},
{
message: "Enter valid addresses (comma-separated) or leave empty",
},
),
webhookUrl: z.string().url({ message: "Must be a valid URL" }),
});
export const webhookFormSchema = z
.object({
abi: z.string().optional(),
addresses: z
.string()
.optional()
.refine(
(val: string | undefined) => {
if (val === undefined || val.trim() === "") {
return true;
}
return val
.split(/[,\s]+/)
.filter(Boolean)
.every((a: string) => isAddress(a.trim()));
},
{
message: "Enter valid addresses (comma-separated)",
},
),
chainIds: z
.array(z.string())
.min(1, { message: "Select at least one chain" }),
eventTypes: z.array(z.string()).optional(),
filterType: z.enum(["event", "transaction"]),
fromAddresses: z
.string()
.optional()
.refine(
(val: string | undefined) => {
if (val === undefined || val.trim() === "") {
return true;
}
return val
.split(/[,\s]+/)
.filter(Boolean)
.every((a: string) => isAddress(a.trim()));
},
{
message: "Enter valid addresses (comma-separated)",
},
),
inputAbi: z.array(inputAbi).optional(),
name: z
.string()
.min(3, { message: "Name must be at least 3 characters long" })
.max(100, { message: "Name must be at most 100 characters long" }),
params: z.record(z.unknown()).optional(),
secret: z.string().optional(),
sigHash: z.string().optional(),
sigHashAbi: z.string().optional(),
toAddresses: z
.string()
.optional()
.refine(
(val: string | undefined) => {
if (val === undefined || val.trim() === "") {
return true;
}
return val
.split(/[,\s]+/)
.filter(Boolean)
.every((a: string) => isAddress(a.trim()));
},
{
message: "Enter valid addresses (comma-separated) or leave empty",
},
),
webhookUrl: z.string().url({ message: "Must be a valid URL" }),
})
.refine(
(data: {
filterType: "event" | "transaction";
addresses?: string;
fromAddresses?: string;
}) => {
if (data.filterType === "event") {
return data.addresses && data.addresses.trim() !== "";
}
return true;
},
{
message: "Contract address is required for event webhooks",
path: ["addresses"],
},
)
.refine(
(data: {
filterType: "event" | "transaction";
addresses?: string;
fromAddresses?: string;
}) => {
if (data.filterType === "transaction") {
return data.fromAddresses && data.fromAddresses.trim() !== "";
}
return true;
},
{
message: "From address is required for transaction webhooks",
path: ["fromAddresses"],
},
);
export type WebhookFormValues = z.infer<typeof webhookFormSchema>;
+1 -1
View File
@@ -37,7 +37,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"zod": "3.25.71"
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
+1 -1
View File
@@ -32,7 +32,7 @@
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"zod": "3.25.71"
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
@@ -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";
@@ -25,5 +53,9 @@ export type BridgeComponentsPlaygroundOptions = {
transactionData?: string; // Simplified for demo; could be more complex in real implementation
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" ||
@@ -464,6 +516,23 @@ export function LeftSection(props: {
}}
theme={options.theme}
/>
<div className="my-4 flex items-center gap-2">
<Checkbox
checked={payOptions.showThirdwebBranding}
id={"branding"}
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
showThirdwebBranding: checked === true,
},
}));
}}
/>
<Label htmlFor={"branding"}>Show Branding</Label>
</div>
</CollapsibleSection>
<CollapsibleSection icon={FuelIcon} title="Sponsor gas fees">
@@ -64,6 +64,8 @@ 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}
/>
);
}
@@ -87,6 +89,8 @@ 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}
/>
);
}
@@ -106,6 +110,8 @@ export function RightSection(props: {
to: account?.address || "",
tokenId: 2n,
})}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -18,6 +18,8 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
title: "",
transactionData: "",
widget: "buy",
currency: "USD",
showThirdwebBranding: true,
},
theme: {
darkColorOverrides: {},
-53
View File
@@ -5533,60 +5533,7 @@ let returnType: Promise<string>;
The deployed contract address.
---
## DEPLOY.deployPackContract
Deprecated
\[Pack contract is incompatible with Pectra update. Support for this contract is being removed in next release.\]
Deploy a thirdweb Pack contract
### Example
```ts
import { deployPackContract } from "thirdweb/extensions/deploy";
const packAddress = await deployPackContract({
account,
client,
chain,
params: {
name: "Pack contract name",
symbol: "PACK1155",
},
});
```
```ts
function deployPackContract(options: {
account: Account;
chain: Readonly;
client: ThirdwebClient;
params: PackContractParams;
}): Promise<string>;
```
### Parameters
params for deploying [Pack contract](https://thirdweb.com/thirdweb.eth/Pack)
#### Type
```ts
let options: {
account: Account;
chain: Readonly;
client: ThirdwebClient;
params: PackContractParams;
};
```
### Returns
```ts
let returnType: Promise<string>;
```
---
## DEPLOY.deployPublishedContract
+1 -1
View File
@@ -135,7 +135,7 @@
* [DEPLOY.deployERC1155Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC1155Contract): Deploys an thirdweb ERC1155 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.deployERC20Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC20Contract): Deploys an thirdweb ERC20 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.deployERC721Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC721Contract): Deploys an thirdweb ERC721 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.deployPackContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployPackContract): \[Pack contract is incompatible with Pectra update. Support for this contract is being removed in next release.\]
* [DEPLOY.deployPublishedContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployPublishedContract): Deploy an instance of a published contract on a given chain
* [DEPLOY.deploySplitContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deploySplitContract): Deploys a thirdweb [Split contract](https://thirdweb.com/thirdweb.eth/Split)On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.prepareDeterministicDeployTransaction](https://portal.thirdweb.com/references/typescript/v5/deploy/prepareDeterministicDeployTransaction): Deploy a contract deterministically - will maintain the same address across chains. This is meant to be used with published contracts configured with the 'direct deploy' method. Under the hood, this uses a keyless transaction with a common create2 factory.
+6 -2
View File
@@ -369,6 +369,8 @@ const typescriptRedirects = {
"/typescript/sdk.thirdwebsdk.fromwallet":
"/references/typescript/v4/ThirdwebSDK#fromWallet",
"/typescript/sdk.thirdwebsdk.smartcontract": "/typescript/v4/extensions",
// v5 deploy redirects
"/references/typescript/v5/deploy/deployPackContract": "/contracts",
};
const reactNativeRedirects = {
@@ -803,7 +805,7 @@ const contractRedirects = {
"/contracts/explore/pre-built-contracts/nft-drop",
"/pre-built-contracts/open-edition-erc721":
"/contracts/explore/pre-built-contracts/open-edition",
"/pre-built-contracts/pack": "/contracts/explore/pre-built-contracts/pack",
"/pre-built-contracts/pack": "/contracts",
"/pre-built-contracts/signature-drop": "/contracts",
"/pre-built-contracts/split": "/contracts/explore/pre-built-contracts/split",
"/pre-built-contracts/stake-erc20":
@@ -816,6 +818,8 @@ const contractRedirects = {
"/pre-built-contracts/token-drop":
"/contracts/explore/pre-built-contracts/token-drop",
"/pre-built-contracts/vote": "/contracts/explore/pre-built-contracts/vote",
// pack contract deprecation redirect
"/contracts/explore/pre-built-contracts/pack": "/contracts",
//publish
"/publish": "/contracts/publish/overview",
"/publish/deployment-options": "/contracts/publish/publish-options",
@@ -940,7 +944,7 @@ const otherRedirects = {
// solidity sdk
"/contracts/nft-drop": "/contracts/explore/pre-built-contracts/nft-drop",
"/contracts/nfts-and-tokens": "/contracts",
"/contracts/pack": "/contracts/explore/pre-built-contracts/pack",
"/contracts/pack": "/contracts",
"/contracts/split": "/contracts/explore/pre-built-contracts/split",
"/contracts/token": "/contracts/explore/pre-built-contracts/token",
"/contracts/token-drop": "/contracts/explore/pre-built-contracts/token-drop",
-8
View File
@@ -51,10 +51,6 @@ const links = [
href: "/insight",
name: "Insight",
},
{
href: "/nebula",
name: "Nebula",
},
{
href: "/vault",
name: "Vault",
@@ -127,10 +123,6 @@ export const connectLinks: Array<{
] as const;
const apisLinks = [
{
href: "/nebula/api-reference",
name: "Nebula",
},
{
href: "https://insight.thirdweb.com/reference",
name: "Insight",
+66 -33
View File
@@ -3,6 +3,7 @@ import {
GithubTemplateCard,
Grid,
ExpandableGrid,
FeatureCard,
createMetadata,
OpenSourceCard,
Stack,
@@ -12,7 +13,6 @@ import {
ArticleIconCard,
DocImage
} from "@doc";
import { ExternalLink } from "lucide-react";
import {
TypeScriptIcon,
ReactIcon,
@@ -24,6 +24,8 @@ import { cn } from "@/lib/utils";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import SupportedChains from "../_images/supported-chains.png";
import {FuelIcon,MonitorCheckIcon,WalletCardsIcon,UserLockIcon, ExternalLinkIcon, WalletIcon, PencilIcon, ShieldCheckIcon, PiggyBankIcon, GlobeIcon, ComponentIcon, CodeIcon, ApiIcon, WebhookIcon, IdCardIcon, HandCoinsIcon, MonitorSmartphoneIcon} from "lucide-react";
export const metadata = createMetadata({
title: "thirdweb Connect",
@@ -36,26 +38,79 @@ export const metadata = createMetadata({
# Wallets
Wallets is the complete toolkit for connecting every user to your application. It features customizable onboarding flows, self-custodial in-app wallets, account abstraction, onramps, and a performant API to interact with the blockchain.
Wallets is a complete toolkit for connecting any user to your application. It includes **customizable components** for onboarding, **embedded wallets** , **account abstraction**
for gasless transactions, **server wallets** for developers to carry out transactions, and a secure **non-custodial key management system**.
## Live Playground
You can interact with the React SDK in the [Live Playground](https://playground.thirdweb.com/).
<Stack>
<ArticleIconCard
title="Try the demo"
icon={ExternalLink}
title="Try Wallets"
icon={ExternalLinkIcon}
href="https://playground.thirdweb.com"
description="See the SDK in action on the live playground"
description="Try out the wallet components live in the interactive playground"
/>
</Stack>
## Quick starts
## Features
Get started with Wallets in your preferred language.
<div
className="my-4 grid gap-2 md:grid-cols-2 lg:grid-cols-2"
>
<FeatureCard
title="Non-Custodial Wallets"
description="Secure non-custodial wallets only accessible by the owner."
iconUrl={<UserLockIcon />}
/>
<FeatureCard
title="EOA Support"
description="Connect to over 500+ EOA wallet providers for crypto native users."
iconUrl={<WalletCardsIcon />}
/>
<FeatureCard
title="Gas Sponsorship"
description="Support for gasless transactions with account abstraction."
iconUrl={<FuelIcon />}
/>
<FeatureCard
title="Ecosystem Wallets"
description="Global wallets to connect your users to any application in an ecosystem."
iconUrl={<MonitorCheckIcon />}
/>
<FeatureCard
title="Cross Platform Support"
description="Create and manage wallets for web, mobile, games, and agents."
iconUrl={<MonitorSmartphoneIcon />}
/>
<FeatureCard
title="One-Click Funding"
description="Buy crypto or swap tokens directly in the wallet modal."
iconUrl={<HandCoinsIcon />}
/>
<FeatureCard
title="Authentication"
description="Enable SIWE standard for blockchain-based ID verification."
iconUrl={<IdCardIcon />}
/>
<FeatureCard
title="Hardware-Level Security"
description="Built with AWS Enclaves (TEE) to isolate and protect keys."
iconUrl={<ShieldCheckIcon />}
/>
</div>
## Quick Starts
<Grid>
<SDKCard
@@ -78,37 +133,15 @@ Get started with Wallets in your preferred language.
/>
</Grid>
## With Wallets, you can
- **Connect to 500+ different wallet providers** with support for every EVM network
- **Log in and authenticate your users** with customizable and secure [email, phone, passkeys and social login](https://portal.thirdweb.com/connect/in-app-wallet/overview) flows.
- **Sponsor transactions** to seamlessly onboard non-native or new crypto users with [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview).
- **Perform wallet actions** like connecting and disconnecting wallets, viewing balance, displaying ENS names, and execute transactions with our [performant, reliable and type safe API](https://portal.thirdweb.com/connect/blockchain-api)
- **Easily integrate with thirdweb's contracts** to enable users to interact with your application.
- **Facilitate payments** by letting user top up their wallets or do onchain purchases with a credit card with [Pay](https://portal.thirdweb.com/connect/pay/overview).
<Callout variant="info" title="Have you generated a client ID?">
<div className="flex flex-col gap-4 items-start">
<p>
You'll need a client ID to access the Wallet APIs, storage, and more.
</p>
<Button asChild variant='outline'>
<Link href="https://thirdweb.com/create-api-key" target="_blank"> Generate </Link>
</Button>
</div>
</Callout>
## Supported Chains
Wallets are supported on every EVM compatible chain. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist).
Wallets are supported on EVM compatible chains. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist?service=connect-sdk).
<DocImage src={SupportedChains} />
## Starter Kits & demos
View all available starter kits, demos and templates on Github.
View all available starter kits, demos, and templates on Github.
<GithubTemplateCard
title="View all templates"
-4
View File
@@ -17,10 +17,6 @@ export const sidebar: SideBar = {
href: "/connect",
name: "Introduction",
},
{
href: "/connect/why-thirdweb",
name: "Why thirdweb?",
},
{
href: "https://playground.thirdweb.com/",
icon: <ExternalLinkIcon />,
@@ -1,56 +0,0 @@
import { createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Pack contract",
icon: "contract",
},
title: "Pack contract | thirdweb",
description: `The packs are ERC1155 NFTs themselves, which can have the metadata you'd expect of an NFT, such as a name, image, description, etc.`,
});
# Pack
The packs are ERC1155 NFTs themselves, which can have the metadata you'd expect of an NFT,
such as a name, image, description, etc.
When a pack is opened, a pre-defined quantity of tokens are randomly selected from the ones
that were used to create the packs _(that haven't already been selected)_, and awarded to the opener.
The pack NFT is burned as it's opened.
You can configure how many tokens get selected for the pack:
- How many tokens are in each "unit" (for ERC20 & ERC1155 tokens - one unit could be multiple tokens).
- How many units are in each pack.
> **IMPORTANT**: _Pack functions, such as opening of packs, can be costly in terms of gas usage due to random selection of rewards. Please check your gas estimates/usage, and do a trial on testnets before any mainnet deployment._
## Use Cases & Examples
You could use the Pack contract to:
- Create randomized NFT loot boxes
- Run a randomized raffle where some users open a winning ticket
- Reward your community with token gift boxes
## Smart Contract Design
For a detailed exploration of how the pack smart contract works, why it was built,
and it's limitations, visit our [Pack Design Doc](/contracts/design-docs/pack)!
## Creating & Configuring the Pack
Learn how to create and configure your smart contract using the [dashboard](https://thirdweb.com/team).
### Creating a Pack Contract
Deploy the pack contract to any of our
[supported networks](https://blog.thirdweb.com/guides/which-network-should-you-use) using the button below.
### Contract Metadata
To configure the image, name, and description of the contract, click the **Settings** tab:
### Royalty & Platform Fees
From the **Settings** tab, you also configure the royalty fee.
@@ -0,0 +1,23 @@
import { createMetadata, Details } from "@doc";
export const metadata = createMetadata({
title: "Contracts FAQs",
description: "Frequently asked questions about thirdweb contracts",
image: {
title: "Contracts FAQs",
icon: "contract",
},
});
# Contracts FAQs
<Details summary="Why can I no longer deploy a pack contract?">
Pack contracts were officially deprecated after the Pectra release from Ethereum. The pack contract implementation is incompatible with the Pectra update, which introduced breaking changes to the Ethereum protocol. As a result, support for pack contracts has been removed from thirdweb's deployment tools to ensure compatibility and security.
If you need similar functionality, consider using:
- **Edition Drop contracts** for distributing multiple quantities of NFTs
- **Marketplace contracts** for trading and bundle functionality
- **Custom smart contracts** built with our modular contract framework
For existing pack contracts, they will continue to function on-chain, but new deployments are no longer supported.
</Details>

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