Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
67293fc269 Version Packages (#7534)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-06 01:17:37 -07:00
Joaquim VergesandGitHub cb77544a46 [SDK] Respect feePayer property in Payment widgets (#7533) 2025-07-06 01:08:46 -07:00
770e7b24da Enhance transaction details with decoded tab (#7531)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-06 00:58:14 -07:00
ff9df497c9 Update branding from Engine Cloud to Transactions (#7516)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-05 13:37:17 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2d6c245b5e Version Packages (#7511)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-06 07:21:32 +12:00
37b3d7fa07 Add activity log section to transaction page (#7529)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-05 01:01:24 -07:00
YashandGitHub 8def4f33f6 Stylus CLI updates (#7502) 2025-07-05 07:47:29 +12:00
saminaandGitHub d92ab6fabe [Docs] Portal Images (#7526) 2025-07-03 21:39:03 -07:00
Joaquim VergesandGitHub 198cfd0f95 [Playground] Add TokenSelector component to Pay embed configuration (#7527) 2025-07-03 21:38:44 -07:00
jnsdls 9fba016cff Remove Plausible analytics integration (#7525)
# Remove Plausible Analytics Integration

This PR removes the Plausible Analytics integration from our applications:

- Removed `next-plausible` dependency from the dashboard app
- Removed Plausible script tags from dashboard, playground-web, and portal layouts
- Removed the Plausible proxy configuration from the Next.js config
- Updated the webpack configuration to work without the Plausible proxy wrapper

<!-- start pr-codex -->

---

## PR-Codex overview
This PR removes the usage of `next-plausible` from various files and updates dependencies accordingly. It also cleans up the code by removing unnecessary `<head>` elements and script imports across multiple layout files.

### Detailed summary
- Removed `next-plausible` dependency from `apps/dashboard/package.json`.
- Deleted `<Script>` components related to Plausible from `layout.tsx` files in `apps/playground-web`, `apps/dashboard`, and `apps/portal`.
- Cleaned up `next.config.ts` by removing `withPlausibleProxy`.
- Updated `pnpm-lock.yaml` to reflect the removal of `next-plausible`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Chores**
  * Removed Plausible analytics integration and related dependencies from the dashboard.
  * Removed external script loading from the layouts of the playground-web and portal apps.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 02:35:21 +00:00
Jonas DanielsandGitHub 4b8badba2e upgrade dependencies (#7524) 2025-07-03 19:22:27 -07:00
Joaquim VergesandGitHub 341325f844 [SDK] Show all tokens in payment selection regardless of balance (#7523) 2025-07-03 19:16:17 -07:00
7500d8723f [Dashboard] Fix validation for smart account options (#7522)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-03 18:35:31 -07:00
Jonas DanielsandGitHub fa45dea07d upgrade biome to 2.0.6 (#7517) 2025-07-03 18:32:04 -07:00
Joaquim VergesandGitHub f94226ad7e [SDK] update wallets, add sh.frame (#7521) 2025-07-03 17:43:39 -07:00
Jonas DanielsandGitHub 0e159f776c [Dashboard] Add chain infrastructure deployment and management (#7456) 2025-07-03 17:32:14 -07:00
jnsdls f1a965b046 Enable engineCloud service in project creation modal (#7520)
### TL;DR

Enabled Engine Cloud service in the project creation modal.

### What changed?

Removed the filter that was excluding the "engineCloud" service from the list of available project services in the create project modal. This change allows users to select Engine Cloud when creating a new project.

### How to test?

1. Open the dashboard application
2. Click on "Create Project" button
3. Verify that Engine Cloud appears as an available service option in the project creation modal

### Why make this change?

The Engine Cloud service was previously excluded with a TODO comment indicating it should be enabled once the API server was ready. This change suggests that the API server is now available, so Engine Cloud can be offered as a service option during project creation.

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Updated project service selection to allow "engineCloud" as an available option.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 23:04:16 +00:00
MananTank 9f88b5c2be [TOOL-4951] Dashboard: Show Plan upsell on storage error in asset creation modal (#7512)
<!--

## 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 the `teamPlan` property to various components related to NFT and token creation, allowing the application to handle billing plans effectively. It also adds a new component for handling storage limit errors and integrates upsell reporting for plan upgrades.

### Detailed summary
- Added `teamPlan` prop to components: `CreateNFTPage`, `CreateTokenAssetPage`, `CreateNFTPageUI`, `CreateTokenAssetPageUI`, `LaunchNFT`, `LaunchTokenStatus`.
- Introduced `StorageErrorPlanUpsell` component for storage limit handling.
- Implemented upsell reporting functions: `reportUpsellShown`, `reportUpsellClicked`.
- Updated `MultiStepStatus` to use a render function for error messages.
- Changed error message type from `React.ReactNode` to `string` in `MultiStepStatus`.

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

<!-- end pr-codex -->

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

* **New Features**
  * Introduced a tailored upsell experience when users on a free plan hit the storage limit during token or NFT creation, including a new prompt to upgrade and real-time detection of plan changes.
  * Added analytics tracking for upsell prompts and upgrade link clicks.

* **Enhancements**
  * Error messages in multi-step workflows can now be customized, allowing for more informative and actionable error displays.
  * Components involved in token and NFT creation now accept and utilize the team's billing plan to enable conditional UI and logic.

* **Storybook**
  * Updated stories to reflect storage limit scenarios and demonstrate the new upsell flow for free plan users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 20:39:36 +00:00
MananTank 7e73c344f5 Dashboard: asset page header: hide zero-address, fix icon (#7514)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of wallet addresses and token fetching in the application. It enhances the rendering of wallet address components and modifies the fetching logic for token information, including error handling.

### Detailed summary
- Updated rendering of the wallet address in `apps/dashboard/src/@/components/blocks/wallet-address.tsx` and `apps/nebula/src/@/components/blocks/wallet-address.tsx` to include `props.className` and added a `text-xs` class in the latter.
- Enhanced the fetch request in `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_utils/fetch-coin-info.ts` by moving `clientId` to headers and added error logging for failed requests.
- Modified the condition for rendering contract creator in `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx` to check if `contractCreator` is not equal to `ZERO_ADDRESS`.

> ✨ 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**
  * Improved wallet address display to support external styling and smaller text size for the zero address.

* **Bug Fixes**
  * Updated contract header to ensure the contract creator badge is not shown for zero addresses.
  * Enhanced error logging for token info fetching to aid in troubleshooting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 20:15:47 +00:00
iuwqyir 4bd4efce4b add rpc tab with analytics (#7489)
# [Dashboard] Feature: Add RPC Edge analytics page

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the RPC (Remote Procedure Call) analytics feature in the dashboard. It introduces new components, updates existing ones, and adds functionality for fetching and displaying RPC usage data and statistics.

### Detailed summary
- Added `RpcMethodStats` and `RpcUsageTypeStats` interfaces in `analytics.ts`.
- Introduced `getRpcUsageByType` function to fetch RPC usage data.
- Updated `RpcMethodBarChartCardUI` to use new data structure.
- Added new `RPC` link in `ProjectSidebarLayout`.
- Created `RpcAnalyticsFilter` for filtering RPC analytics.
- Implemented `RequestsGraph` to visualize RPC requests over time.
- Developed `TopRPCMethodsTable` to display top EVM methods called.
- Added `RpcFTUX` component for user onboarding in RPC section.
- Updated layout files to integrate new components and improve styling.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * Introduced a dedicated "RPC" section in the project sidebar for easy access to RPC analytics.
  * Added a comprehensive RPC analytics dashboard, including:
    * Interactive requests graph and top RPC methods table.
    * Date range and interval filtering for analytics.
    * First-time user experience (FTUX) with ready-to-use code examples and documentation links.
  * Enhanced layout and navigation for the new RPC analytics section.

* **Style**
  * Improved UI spacing, sizing, and visual consistency for analytics components and placeholders.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 19:58:55 +00:00
MananTank 84f80437c1 [TOOL-4908] Dashboard: Fix 'Owner only' claim condition phase type condition (#7506)
<!--

## 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 adding a condition to check if the `maxClaimablePerWallet` is `"0"` and enhancing the validation of the `phase.snapshot` property within the `claim-conditions-form` component.

### Detailed summary
- Added a condition to check if `phase.maxClaimablePerWallet` is `"0"`.
- Ensured `phase.price` is `"0"`.
- Checked that `phase.snapshot` is not a string and has a length of 1.

> ✨ 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 claim condition handling to more accurately identify "creator" claim types based on updated criteria.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 19:11:32 +00:00
iuwqyir ac48b2409b add analytics to insight page (#7488)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces enhancements to the `Insight` feature in the application, including new analytics components, improved data fetching, and user interface updates for better user experience.

### Detailed summary
- Added `limit` parameter to analytics type definitions.
- Refactored imports in `insight-ftux.tsx`.
- Introduced `InsightAnalyticsFilter` component for filtering analytics data.
- Created `Layout` component with project validation and redirection.
- Added `InsightFooter` for footer links.
- Implemented `TopInsightChainsTable` and `TopInsightEndpointsTable` for displaying analytics data.
- Added `getInsightChainUsage`, `getInsightStatusCodeUsage`, and `getInsightEndpointUsage` API functions for fetching analytics.
- Enhanced `RequestsByStatusGraph` for visualizing status code requests.
- Updated `Page` component to handle authentication and data fetching more efficiently.

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

<!-- end pr-codex -->

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

* **New Features**
  * Introduced a comprehensive analytics dashboard for project insights, including interactive charts and tables for usage, errors, top endpoints, and top chains.
  * Added filters for date range and interval selection, with preferences saved for future visits.
  * Enhanced visualization with a stacked bar chart for HTTP status codes and detailed tables for endpoints and chains.
  * Included a streamlined layout with improved navigation, footer resources, and a "Get Started with Insight" call-to-action.
  * Added new analytics data fetching capabilities covering usage by chain, status code, endpoint, and overall metrics.

* **Refactor**
  * Simplified the insights page by removing redundant logic and focusing on authenticated analytics display.

* **Bug Fixes**
  * Improved error handling during data fetching to ensure UI stability when analytics data is unavailable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 19:01:44 +00:00
jnsdls bde53fcf34 Remove "starter" plan checks from team invitation logic (#7513)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on refining the invitation feature within the onboarding and settings sections of the application. It simplifies the conditions for inviting team members based on the billing plan.

### Detailed summary
- Removed `"Invite Team Members"` from the pricing options in `pricing.tsx`.
- Updated the conditional rendering for the custom CTA in `InviteTeamMembers.tsx` to check only for the `"free"` billing plan.
- Simplified the `inviteEnabled` condition in `InviteSection.tsx` to exclude the `"starter"` plan.
- Adjusted the conditional check for displaying the bottom section in `InviteSection.tsx` to only check for the `"free"` plan.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * Updated team plan features by removing "Invite Team Members" from the "growth" plan feature list.

* **Bug Fixes**
  * Adjusted upgrade prompts and invite functionality so that upgrade options and invite restrictions now apply only to teams on the "free" plan, rather than both "free" and "starter" plans.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 18:51:42 +00:00
FirekeeperandGitHub 7437289140 Update import path in createSessionKey example (#7510) 2025-07-02 11:12:23 -07:00
Jonas DanielsandGitHub ca1fb84eba Properly type purchaseData as Record<string, unknown> (#7508) 2025-07-02 11:12:06 -07:00
jnsdls 087c29efdd [Dashboard] Rename RPC Edge to RPC and add redirect (#7509)
# Rename `/rpc-edge` to `/rpc`

This PR renames the `/rpc-edge` route to `/rpc` in the dashboard:

- Updates the route in `framer-rewrites.js`
- Adds a redirect from `/rpc-edge` to `/rpc` to maintain backward compatibility

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

## Summary by CodeRabbit

* **New Features**
  * Added a redirect from /rpc-edge to /rpc for dashboard requests.

* **Chores**
  * Updated references from /rpc-edge to /rpc in the scale category configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 17:58:07 +00:00
347 changed files with 14137 additions and 8018 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- name: Setup Biome
uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1
with:
version: 2.0.4
version: 2.0.6
- run: pnpm lint
+1 -1
View File
@@ -1,4 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"extends": "//"
}
+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = [
// -- end build category
// -- scale category
"/rpc-edge",
"/rpc",
"/insight",
"/storage",
// -- end scale category
+19 -25
View File
@@ -208,35 +208,29 @@ function getConfig(): NextConfig {
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { withPlausibleProxy } = require("next-plausible");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { withSentryConfig } = require("@sentry/nextjs");
return withBundleAnalyzer(
withPlausibleProxy({
customDomain: "https://pl.thirdweb.com",
scriptName: "pl",
})(
withSentryConfig(
{
...baseNextConfig,
// @ts-expect-error - this is a valid option
webpack: (config) => {
if (config.cache) {
config.cache = Object.freeze({
type: "memory",
});
}
config.module = {
...config.module,
exprContextCritical: false,
};
// Important: return the modified config
return config;
},
withSentryConfig(
{
...baseNextConfig,
// @ts-expect-error - this is a valid option
webpack: (config) => {
if (config.cache) {
config.cache = Object.freeze({
type: "memory",
});
}
config.module = {
...config.module,
exprContextCritical: false,
};
// Important: return the modified config
return config;
},
SENTRY_OPTIONS,
),
},
SENTRY_OPTIONS,
),
);
}
+24 -25
View File
@@ -4,7 +4,7 @@
"@chakra-ui/styled-system": "^2.9.2",
"@chakra-ui/theme-tools": "^2.1.2",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@emotion/styled": "11.14.1",
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.11",
@@ -24,10 +24,10 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@scalar/api-reference-react": "^0.6.19",
"@sentry/nextjs": "9.29.0",
"@scalar/api-reference-react": "0.7.25",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@tanstack/react-query": "5.80.7",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
@@ -41,29 +41,28 @@
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"framer-motion": "12.17.0",
"framer-motion": "12.23.0",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.514.0",
"next": "15.3.3",
"next-plausible": "^3.12.4",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^6.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.252.0",
"prettier": "3.5.3",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"qrcode": "^1.5.3",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.1.0",
"react-dropzone": "^14.3.8",
"react-error-boundary": "^5.0.0",
"react-error-boundary": "6.0.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-table": "^7.8.0",
@@ -72,7 +71,7 @@
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"sonner": "2.0.5",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.24.1",
@@ -82,19 +81,19 @@
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.67"
"zod": "3.25.71"
},
"devDependencies": {
"@biomejs/biome": "2.0.4",
"@biomejs/biome": "2.0.6",
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "4.0.0",
"@next/bundle-analyzer": "15.3.3",
"@next/eslint-plugin-next": "15.3.3",
"@playwright/test": "1.53.0",
"@storybook/addon-docs": "9.0.8",
"@storybook/addon-links": "9.0.8",
"@storybook/addon-onboarding": "9.0.8",
"@storybook/nextjs": "9.0.8",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "15.3.5",
"@playwright/test": "1.53.2",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/color": "4.2.0",
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
@@ -112,11 +111,11 @@
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.8",
"eslint-plugin-storybook": "9.0.15",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.5",
"storybook": "9.0.8",
"postcss": "8.5.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
+5
View File
@@ -435,6 +435,11 @@ async function redirects() {
permanent: false,
source: "/engine",
},
{
destination: "/rpc",
permanent: false,
source: "/rpc-edge",
},
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
+87 -1
View File
@@ -1,7 +1,10 @@
"use server";
import "server-only";
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { ChainInfraSKU } from "@/types/billing";
import { getAbsoluteUrl } from "@/utils/vercel";
export async function reSubscribePlan(options: {
teamId: string;
@@ -14,7 +17,10 @@ export async function reSubscribePlan(options: {
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
new URL(
`/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
body: JSON.stringify({}),
headers: {
@@ -35,3 +41,83 @@ export async function reSubscribePlan(options: {
status: 200,
};
}
export async function getChainInfraCheckoutURL(options: {
teamSlug: string;
skus: ChainInfraSKU[];
chainId: number;
annual: boolean;
}) {
const token = await getAuthToken();
if (!token) {
return {
error: "You are not logged in",
status: "error",
} as const;
}
const res = await fetch(
new URL(
`/v1/teams/${options.teamSlug}/checkout/create-link`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
body: JSON.stringify({
annual: options.annual,
baseUrl: getAbsoluteUrl(),
chainId: options.chainId,
skus: options.skus,
}),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!res.ok) {
const text = await res.text();
console.error("Failed to create checkout link", text, res.status);
switch (res.status) {
case 402: {
return {
error:
"You have outstanding invoices, please pay these first before re-subscribing.",
status: "error",
} as const;
}
case 429: {
return {
error: "Too many requests, please try again later.",
status: "error",
} as const;
}
case 403: {
return {
error: "You are not authorized to deploy infrastructure.",
status: "error",
} as const;
}
default: {
return {
error: "An unknown error occurred, please try again later.",
status: "error",
} as const;
}
}
}
const json = await res.json();
if (!json.result) {
return {
error: "An unknown error occurred, please try again later.",
status: "error",
} as const;
}
return {
data: json.result as string,
status: "success",
} as const;
}
+29
View File
@@ -1,6 +1,7 @@
import posthog from "posthog-js";
import type { Team } from "@/api/team";
import type { ProductSKU } from "../types/billing";
// ----------------------------
// CONTRACTS
@@ -380,3 +381,31 @@ export function reportAssetCreationFailed(
step: properties.step,
});
}
type UpsellParams = {
content: "storage-limit";
campaign: "create-coin" | "create-nft";
sku: Exclude<ProductSKU, null>;
};
/**
* ### Why do we need to report this event?
* - To track how effective the upsells are in driving users to upgrade
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportUpsellShown(properties: UpsellParams) {
posthog.capture("upsell shown", properties);
}
/**
* ### Why do we need to report this event?
* - To track how effective the upsells are in driving users to upgrade
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportUpsellClicked(properties: UpsellParams) {
posthog.capture("upsell clicked", properties);
}
+146 -1
View File
@@ -6,7 +6,6 @@ import type {
EcosystemWalletStats,
EngineCloudStats,
InAppWalletStats,
RpcMethodStats,
TransactionStats,
UniversalBridgeStats,
UniversalBridgeWalletStats,
@@ -17,6 +16,41 @@ import type {
import { getAuthToken } from "./auth-token";
import { getChains } from "./chain";
export interface InsightChainStats {
date: string;
chainId: string;
totalRequests: number;
}
export interface InsightStatusCodeStats {
date: string;
httpStatusCode: number;
totalRequests: number;
}
export interface InsightEndpointStats {
date: string;
endpoint: string;
totalRequests: number;
}
interface InsightUsageStats {
date: string;
totalRequests: number;
}
export interface RpcMethodStats {
date: string;
evmMethod: string;
count: number;
}
export interface RpcUsageTypeStats {
date: string;
usageType: string;
count: number;
}
async function fetchAnalytics(
input: string | URL,
init?: RequestInit,
@@ -76,6 +110,9 @@ function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams {
if (params.period) {
searchParams.append("period", params.period);
}
if (params.limit) {
searchParams.append("limit", params.limit.toString());
}
return searchParams;
}
@@ -225,6 +262,26 @@ export async function getRpcMethodUsage(
return json.data as RpcMethodStats[];
}
export async function getRpcUsageByType(
params: AnalyticsQueryParams,
): Promise<RpcUsageTypeStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/rpc/usage-types?${searchParams.toString()}`,
{
method: "GET",
},
);
if (res?.status !== 200) {
console.error("Failed to fetch RPC usage");
return [];
}
const json = await res.json();
return json.data as RpcUsageTypeStats[];
}
export async function getWalletUsers(
params: AnalyticsQueryParams,
): Promise<WalletUserStats[]> {
@@ -424,3 +481,91 @@ export async function getEngineCloudMethodUsage(
const json = await res.json();
return json.data as EngineCloudStats[];
}
export async function getInsightChainUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/insight/usage/by-chain?${searchParams.toString()}`,
{
method: "GET",
},
);
if (res?.status !== 200) {
const reason = await res?.text();
const errMsg = `Failed to fetch Insight chain usage: ${res?.status} - ${res.statusText} - ${reason}`;
console.error(errMsg);
return { errorMessage: errMsg };
}
const json = await res.json();
return { data: json.data as InsightChainStats[] };
}
export async function getInsightStatusCodeUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightStatusCodeStats[] } | { errorMessage: string }> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/insight/usage/by-status-code?${searchParams.toString()}`,
{
method: "GET",
},
);
if (res?.status !== 200) {
const reason = await res?.text();
const errMsg = `Failed to fetch Insight status code usage: ${res?.status} - ${res.statusText} - ${reason}`;
console.error(errMsg);
return { errorMessage: errMsg };
}
const json = await res.json();
return { data: json.data as InsightStatusCodeStats[] };
}
export async function getInsightEndpointUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightEndpointStats[] } | { errorMessage: string }> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/insight/usage/by-endpoint?${searchParams.toString()}`,
{
method: "GET",
},
);
if (res?.status !== 200) {
const reason = await res?.text();
const errMsg = `Failed to fetch Insight endpoint usage: ${res?.status} - ${res.statusText} - ${reason}`;
console.error(errMsg);
return { errorMessage: errMsg };
}
const json = await res.json();
return { data: json.data as InsightEndpointStats[] };
}
export async function getInsightUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightUsageStats[] } | { errorMessage: string }> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/insight/usage?${searchParams.toString()}`,
{
method: "GET",
},
);
if (res?.status !== 200) {
const reason = await res?.text();
const errMsg = `Failed to fetch Insight usage: ${res?.status} - ${res.statusText} - ${reason}`;
console.error(errMsg);
return { errorMessage: errMsg };
}
const json = await res.json();
return { data: json.data as InsightUsageStats[] };
}
@@ -1,22 +1,26 @@
export const authOptions = [
"email",
"phone",
"passkey",
"siwe",
"guest",
"google",
"facebook",
"x",
"discord",
"farcaster",
"telegram",
"github",
"twitch",
"steam",
"apple",
"coinbase",
"line",
] as const;
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "./auth-token";
export type AuthOption =
| "email"
| "phone"
| "passkey"
| "siwe"
| "guest"
| "google"
| "facebook"
| "x"
| "discord"
| "farcaster"
| "telegram"
| "github"
| "twitch"
| "steam"
| "apple"
| "coinbase"
| "line";
export type Ecosystem = {
name: string;
@@ -24,7 +28,7 @@ export type Ecosystem = {
id: string;
slug: string;
permission: "PARTNER_WHITELIST" | "ANYONE";
authOptions: (typeof authOptions)[number][];
authOptions: AuthOption[];
customAuthOptions?: {
authEndpoint?: {
url: string;
@@ -47,6 +51,54 @@ export type Ecosystem = {
updatedAt: string;
};
export async function fetchEcosystemList(teamIdOrSlug: string) {
const token = await getAuthToken();
if (!token) {
return [];
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return [];
}
return (await res.json()).result as Ecosystem[];
}
export async function fetchEcosystem(slug: string, teamIdOrSlug: string) {
const token = await getAuthToken();
if (!token) {
return null;
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
return null;
}
const data = (await res.json()) as { result: Ecosystem };
return data.result;
}
type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
export type Partner = {
id: string;
+67 -2
View File
@@ -1,6 +1,6 @@
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { ProductSKU } from "@/types/billing";
import type { ChainInfraSKU, ProductSKU } from "@/types/billing";
type InvoiceLine = {
// amount for this line item
@@ -22,7 +22,7 @@ type Invoice = {
export type TeamSubscription = {
id: string;
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT" | "CHAIN";
status:
| "incomplete"
| "incomplete_expired"
@@ -37,6 +37,13 @@ export type TeamSubscription = {
trialStart: string | null;
trialEnd: string | null;
upcomingInvoice: Invoice;
skus: (ProductSKU | ChainInfraSKU)[];
};
type ChainTeamSubscription = Omit<TeamSubscription, "skus"> & {
chainId: string;
skus: ChainInfraSKU[];
isLegacy: boolean;
};
export async function getTeamSubscriptions(slug: string) {
@@ -60,3 +67,61 @@ export async function getTeamSubscriptions(slug: string) {
}
return null;
}
const CHAIN_PLAN_TO_INFRA = {
"chain:plan:gold": ["chain:infra:rpc", "chain:infra:account_abstraction"],
"chain:plan:platinum": [
"chain:infra:rpc",
"chain:infra:insight",
"chain:infra:account_abstraction",
],
"chain:plan:ultimate": [
"chain:infra:rpc",
"chain:infra:insight",
"chain:infra:account_abstraction",
],
};
export async function getChainSubscriptions(slug: string) {
const allSubscriptions = await getTeamSubscriptions(slug);
if (!allSubscriptions) {
return null;
}
// first replace any sku that MIGHT match a chain plan
const updatedSubscriptions = allSubscriptions
.filter((s) => s.type === "CHAIN")
.map((s) => {
const skus = s.skus;
const updatedSkus = skus.flatMap((sku) => {
const plan =
CHAIN_PLAN_TO_INFRA[sku as keyof typeof CHAIN_PLAN_TO_INFRA];
return plan ? plan : sku;
});
return {
...s,
isLegacy: updatedSkus.length !== skus.length,
skus: updatedSkus,
};
});
return updatedSubscriptions.filter(
(s): s is ChainTeamSubscription =>
"chainId" in s && typeof s.chainId === "string",
);
}
export async function getChainSubscriptionForChain(
slug: string,
chainId: number,
) {
const chainSubscriptions = await getChainSubscriptions(slug);
if (!chainSubscriptions) {
return null;
}
return (
chainSubscriptions.find((s) => s.chainId === chainId.toString()) ?? null
);
}
@@ -152,6 +152,7 @@ export function SingleNetworkSelector(props: {
disableChainId?: boolean;
align?: "center" | "start" | "end";
disableTestnets?: boolean;
disableDeprecated?: boolean;
placeholder?: string;
client: ThirdwebClient;
}) {
@@ -169,8 +170,17 @@ export function SingleNetworkSelector(props: {
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
}
if (props.disableDeprecated) {
chains = chains.filter((chain) => chain.status !== "deprecated");
}
return chains;
}, [allChains, props.chainIds, props.disableTestnets]);
}, [
allChains,
props.chainIds,
props.disableTestnets,
props.disableDeprecated,
]);
const options = useMemo(() => {
return chainsToShow.map((chain) => {
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
import {
@@ -164,7 +165,6 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
return (
<TableRow
key={row.id}
// biome-ignore lint/a11y/useSemanticElements: FIXME
role="group"
{...(tableProps.onRowClick
? {
@@ -38,12 +38,18 @@ const ACCENT = {
type UpsellBannerCardProps = {
title: React.ReactNode;
description: React.ReactNode;
cta: {
text: React.ReactNode;
icon?: React.ReactNode;
target?: "_blank";
link: string;
};
cta?:
| {
text: React.ReactNode;
icon?: React.ReactNode;
target?: "_blank";
link: string;
}
| {
text: React.ReactNode;
icon?: React.ReactNode;
onClick: () => void;
};
accentColor?: keyof typeof ACCENT;
icon?: React.ReactNode;
};
@@ -93,25 +99,41 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) {
</div>
</div>
<Button
asChild
className={cn(
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
color.btn,
)}
size="sm"
>
<Link
href={props.cta.link}
rel={
props.cta.target === "_blank" ? "noopener noreferrer" : undefined
}
target={props.cta.target}
{props.cta && "target" in props.cta ? (
<Button
asChild
className={cn(
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
color.btn,
)}
size="sm"
>
<Link
href={props.cta.link}
rel={
props.cta.target === "_blank"
? "noopener noreferrer"
: undefined
}
target={props.cta.target}
>
{props.cta.text}
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
</Link>
</Button>
) : props.cta && "onClick" in props.cta ? (
<Button
className={cn(
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
color.btn,
)}
onClick={props.cta.onClick}
size="sm"
>
{props.cta.text}
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
</Link>
</Button>
</Button>
) : null}
</div>
</div>
);
@@ -279,7 +279,14 @@ function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) {
// subnav
if ("subMenu" in link) {
return (
<RenderSidebarSubmenu links={link.links} subMenu={link.subMenu} />
<RenderSidebarSubmenu
key={`submenu_$${
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
idx
}`}
links={link.links}
subMenu={link.subMenu}
/>
);
}
@@ -18,7 +18,7 @@ export type MultiStepState<T extends string> = {
}
| {
type: "error";
message: React.ReactNode;
message: string;
};
label: string;
description?: string;
@@ -27,6 +27,10 @@ export type MultiStepState<T extends string> = {
export function MultiStepStatus<T extends string>(props: {
steps: MultiStepState<T>[];
onRetry: (step: MultiStepState<T>) => void;
renderError?: (
step: MultiStepState<T>,
errorMessage: string,
) => React.ReactNode;
}) {
return (
<DynamicHeight>
@@ -66,22 +70,24 @@ export function MultiStepStatus<T extends string>(props: {
</p>
)}
{step.status.type === "error" && (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
{step.status.message}
</p>
<Button
className="gap-2"
onClick={() => props.onRetry(step)}
size="sm"
variant="destructive"
>
<RefreshCwIcon className="size-4" />
Retry
</Button>
</div>
)}
{step.status.type === "error"
? props.renderError?.(step, step.status.message) || (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
{step.status.message}
</p>
<Button
className="gap-2"
onClick={() => props.onRetry(step)}
size="sm"
variant="destructive"
>
<RefreshCwIcon className="size-4" />
Retry
</Button>
</div>
)
: null}
</div>
</div>
))}
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
@@ -190,7 +191,6 @@ export const SelectWithSearch = React.forwardRef<
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
// biome-ignore lint/a11y/useSemanticElements: TDOO
role="option"
variant="ghost"
>
@@ -59,7 +59,11 @@ export function WalletAddress(props: {
// special case for zero address
if (address === ZERO_ADDRESS) {
return <span className="cursor-pointer font-mono">{shortenedAddress}</span>;
return (
<span className={cn("cursor-pointer font-mono", props.className)}>
{shortenedAddress}
</span>
);
}
return (
@@ -120,20 +120,18 @@ export const CustomConnectWallet = (props: {
if ((!isLoggedIn || !account) && loginRequired) {
return (
<>
<Button
asChild
className={props.signInLinkButtonClassName}
size="lg"
variant="default"
<Button
asChild
className={props.signInLinkButtonClassName}
size="lg"
variant="default"
>
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Connect Wallet
</Link>
</Button>
</>
Connect Wallet
</Link>
</Button>
);
}
@@ -3,7 +3,8 @@
import type { StaticImageData } from "next/image";
import Image from "next/image";
import type { FetchDeployMetadataResult } from "thirdweb/contract";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { replaceIpfsUrl } from "@/lib/sdk";
import generalContractIcon from "../../../../../public/assets/tw-icons/general.png";
@@ -26,7 +27,17 @@ export const ContractIdImage: React.FC<ContractIdImageProps> = ({
<img
alt=""
className="size-8 rounded-full"
src={replaceIpfsUrl(logo, serverThirdwebClient)}
src={
DASHBOARD_THIRDWEB_SECRET_KEY
? replaceIpfsUrl(
logo,
getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
)
: logo
}
/>
);
}
@@ -314,7 +314,7 @@ const NetworkFilterCell = React.memo(function NetworkFilterCell({
client: ThirdwebClient;
}) {
if (chainIds.length < 2) {
return <> NETWORK </>;
return "NETWORK";
}
return (
@@ -40,10 +40,7 @@ import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) =>
srv.name !== "relayer" &&
srv.name !== "chainsaw" &&
srv.name !== "engineCloud", // TODO enable once API server is out
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
);
export type CreateProjectPrefillOptions = {
@@ -3,7 +3,9 @@ import "server-only";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "./server-envs";
import { getConfiguredThirdwebClient } from "./thirdweb.server";
// During build time, the secret key might not be available
// Create a client that will work for build but may fail at runtime if secret key is needed
export const serverThirdwebClient = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY || "dummy-build-time-secret",
teamId: undefined,
});
@@ -76,14 +76,18 @@ export function getConfiguredThirdwebClient(options: {
});
}
// During build time, provide fallbacks if credentials are missing
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
const secretKey = options.secretKey || undefined;
return createThirdwebClient({
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
clientId: clientId,
config: {
storage: {
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
},
},
secretKey: options.secretKey,
secretKey: secretKey,
teamId: options.teamId,
});
}
+1 -1
View File
@@ -30,7 +30,7 @@ export const ChainIconClient = ({
fallback={<img alt="" src={fallbackChainIcon} />}
key={resolvedSrc}
loading={restProps.loading || "lazy"}
skeleton={<div className="animate-pulse rounded-full bg-border" />}
skeleton={<span className="animate-pulse rounded-full bg-border" />}
src={resolvedSrc}
/>
);
+5 -9
View File
@@ -192,6 +192,7 @@ export function teamSubscriptionsStub(
currentPeriodEnd: "2024-12-15T20:56:06.000Z",
currentPeriodStart: "2024-11-15T20:56:06.000Z",
id: "sub-1",
skus: [],
status: "active",
trialEnd: overrides?.trialEnd || null,
trialStart: null,
@@ -212,6 +213,7 @@ export function teamSubscriptionsStub(
currentPeriodEnd: "2024-12-15T20:56:06.000Z",
currentPeriodStart: "2024-11-15T20:56:15.000Z",
id: "sub-2",
skus: [],
status: "active",
trialEnd: null,
trialStart: null,
@@ -229,25 +231,19 @@ export function teamSubscriptionsStub(
// In-App Wallets
{
amount: usage.inAppWalletAmount?.amount || 0,
description: `${
usage.inAppWalletAmount?.quantity || 0
} x In-App Wallets (Tier 1 at $0.00 / month)`,
description: `${usage.inAppWalletAmount?.quantity || 0} x In-App Wallets (Tier 1 at $0.00 / month)`,
thirdwebSku: "usage:in_app_wallet",
},
// AA Sponsorship
{
amount: usage.aaSponsorshipAmount?.amount || 0,
description: `${
usage.aaSponsorshipAmount?.quantity || 0
} x AA Gas Sponsorship (at $0.011 / month)`,
description: `${usage.aaSponsorshipAmount?.quantity || 0} x AA Gas Sponsorship (at $0.011 / month)`,
thirdwebSku: "usage:aa_sponsorship",
},
// OP Grant
{
amount: usage.aaSponsorshipOpGrantAmount?.amount || 0,
description: `${
usage.aaSponsorshipOpGrantAmount?.quantity || 0
} x AA Gas Sponsorship (OP) (at $0.011 / month)`,
description: `${usage.aaSponsorshipOpGrantAmount?.quantity || 0} x AA Gas Sponsorship (OP) (at $0.011 / month)`,
thirdwebSku: "usage:aa_sponsorship_op_grant",
},
],
+1 -6
View File
@@ -39,12 +39,6 @@ export interface TransactionStats {
count: number;
}
export interface RpcMethodStats {
date: string;
evmMethod: string;
count: number;
}
export interface EngineCloudStats {
date: string;
chainId: string;
@@ -78,4 +72,5 @@ export interface AnalyticsQueryParams {
from?: Date;
to?: Date;
period?: "day" | "week" | "month" | "year" | "all";
limit?: number;
}
+5
View File
@@ -14,3 +14,8 @@ export type ProductSKU =
| "usage:aa_sponsorship"
| "usage:aa_sponsorship_op_grant"
| null;
export type ChainInfraSKU =
| "chain:infra:rpc"
| "chain:infra:insight"
| "chain:infra:account_abstraction";
-1
View File
@@ -23,7 +23,6 @@ export const TEAM_PLANS: Record<
features: [
"Email Support",
"48hr Guaranteed Response",
"Invite Team Members",
"Custom In-App Wallet Auth",
],
price: 99,
@@ -1,7 +1,8 @@
import { ImageResponse } from "next/og";
import { useId } from "react";
import { download } from "thirdweb/storage";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { fetchChain } from "@/utils/fetchChain";
// Route segment config
@@ -81,16 +82,29 @@ export default async function Image({
fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) =>
res.arrayBuffer(),
),
// download the chain icon if there is one
chain.icon?.url && hasWorkingChainIcon
? download({
client: serverThirdwebClient,
uri: chain.icon.url,
}).then((res) => res.arrayBuffer())
// download the chain icon if there is one and secret key is available
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
? (async () => {
try {
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const response = await download({
client,
uri: chain.icon?.url || "",
});
return response.arrayBuffer();
} catch (error) {
// If download fails, return undefined to fallback to no icon
console.warn("Failed to download chain icon:", error);
return undefined;
}
})()
: undefined,
// download the background image (based on chain)
fetch(
chain.icon?.url && hasWorkingChainIcon
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
? new URL(
"og-lib/assets/chain/bg-with-icon.png",
@@ -118,7 +132,7 @@ export default async function Image({
/>
{/* the actual component starts here */}
{hasWorkingChainIcon && (
{hasWorkingChainIcon && chainIcon && (
<img
alt=""
// @ts-expect-error - TS doesn't know about the ImageResponse component
@@ -1,4 +1,5 @@
// biome-ignore-all lint/nursery/noNestedComponentDefinitions: TODO
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
import {
IconButton,
@@ -256,7 +257,6 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={rowIndex}
onClick={() => setTokenRow(row.original)}
// biome-ignore lint/a11y/useSemanticElements: FIXME
role="group"
style={{ cursor: "pointer" }}
>
@@ -130,6 +130,7 @@ const getClaimConditionTypeFromPhase = (
if (phase.snapshot) {
if (
phase.maxClaimablePerWallet?.toString() === "0" &&
phase.price === "0" &&
typeof phase.snapshot !== "string" &&
phase.snapshot.length === 1 &&
@@ -464,204 +465,198 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}
return (
<>
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
<Flex direction="column" gap={6}>
{/* Show the reason why the form is disabled */}
{!isAdmin && (
<Text>Connect with admin wallet to edit claim conditions.</Text>
)}
{controlledFields.map((field, index) => {
const dropType: DropType = field.snapshot
? field.maxClaimablePerWallet?.toString() === "0"
? "specific"
: "overrides"
: "any";
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
<Flex direction="column" gap={6}>
{/* Show the reason why the form is disabled */}
{!isAdmin && (
<Text>Connect with admin wallet to edit claim conditions.</Text>
)}
{controlledFields.map((field, index) => {
const dropType: DropType = field.snapshot
? field.maxClaimablePerWallet?.toString() === "0"
? "specific"
: "overrides"
: "any";
const claimConditionType = getClaimConditionTypeFromPhase(field);
const claimConditionType = getClaimConditionTypeFromPhase(field);
const isActive = activePhaseId === field.id;
const isActive = activePhaseId === field.id;
const snapshotValue = field.snapshot?.map((v) =>
typeof v === "string"
? {
address: v,
currencyAddress: ZERO_ADDRESS,
maxClaimable: "unlimited",
price: "unlimited",
}
: {
...v,
currencyAddress: v?.currencyAddress || ZERO_ADDRESS,
maxClaimable: v?.maxClaimable?.toString() || "unlimited",
price: v?.price?.toString() || "unlimited",
},
);
const snapshotValue = field.snapshot?.map((v) =>
typeof v === "string"
? {
address: v,
currencyAddress: ZERO_ADDRESS,
maxClaimable: "unlimited",
price: "unlimited",
}
: {
...v,
currencyAddress: v?.currencyAddress || ZERO_ADDRESS,
maxClaimable: v?.maxClaimable?.toString() || "unlimited",
price: v?.price?.toString() || "unlimited",
},
);
return (
<Fragment key={`snapshot_${field.id}_${index}`}>
<SnapshotViewerSheet
client={contract.client}
dropType={dropType}
isDisabled={!canEditForm}
isOpen={openSnapshotIndex === index}
onClose={() => {
setOpenSnapshotIndex(-1);
}}
setSnapshot={(snapshot) =>
form.setValue(`phases.${index}.snapshot`, snapshot)
}
value={snapshotValue}
/>
return (
<Fragment key={`snapshot_${field.id}_${index}`}>
<SnapshotViewerSheet
client={contract.client}
dropType={dropType}
isDisabled={!canEditForm}
isOpen={openSnapshotIndex === index}
onClose={() => {
setOpenSnapshotIndex(-1);
}}
setSnapshot={(snapshot) =>
form.setValue(`phases.${index}.snapshot`, snapshot)
}
value={snapshotValue}
/>
<ClaimsConditionFormContext.Provider
value={{
claimConditionType,
dropType,
field,
form,
formDisabled: !canEditForm,
isActive,
isAdmin,
isColumn,
isErc20,
isMultiPhase,
phaseIndex: index,
setOpenSnapshotIndex,
tokenDecimals: tokenDecimals.data,
}}
>
<ClaimConditionsPhase
contract={contract}
isPending={sendTx.isPending}
onRemove={() => {
removePhase(index);
}}
/>
</ClaimsConditionFormContext.Provider>
</Fragment>
);
})}
{phases?.length === 0 && (
<Alert borderRadius="md" status="warning">
<AlertIcon />
<div className="flex flex-col">
<AlertTitle as={Heading} size="label.lg">
{isMultiPhase
? "Missing Claim Phases"
: "Missing Claim Conditions"}
</AlertTitle>
<AlertDescription as={Text}>
{isMultiPhase
? "You need to set at least one claim phase for people to claim this drop."
: "You need to set claim conditions for people to claim this drop."}
</AlertDescription>
</div>
</Alert>
)}
<Flex
flexDir={{ base: "column", md: "row" }}
gap={2}
justifyContent="space-between"
>
<div className="flex flex-row gap-2">
<AdminOnly contract={contract}>
<Menu>
<MenuButton
as={Button}
borderRadius="md"
colorScheme="primary"
isDisabled={
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
}
leftIcon={<PlusIcon className="size-5" />}
size="sm"
variant={phases?.length > 0 ? "outline" : "solid"}
>
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
</MenuButton>
<MenuList
borderRadius="lg"
overflow="hidden"
zIndex="overlay"
>
{Object.keys(ClaimConditionTypeData).map((key) => {
const type = key as ClaimConditionType;
if (type === "custom") {
return null;
}
return (
<MenuItem
key={type}
onClick={() => {
addPhase(type);
// TODO: Automatically start editing the new phase after adding it
}}
>
<div className="flex items-center gap-1">
{ClaimConditionTypeData[type].name}
<TooltipBox
content={
<Text size="body.md">
{ClaimConditionTypeData[type].description}
</Text>
}
/>
</div>
</MenuItem>
);
})}
</MenuList>
</Menu>
</AdminOnly>
{controlledFields.some((field) => field.fromSdk) && (
<ResetClaimEligibility
<ClaimsConditionFormContext.Provider
value={{
claimConditionType,
dropType,
field,
form,
formDisabled: !canEditForm,
isActive,
isAdmin,
isColumn,
isErc20,
isMultiPhase,
phaseIndex: index,
setOpenSnapshotIndex,
tokenDecimals: tokenDecimals.data,
}}
>
<ClaimConditionsPhase
contract={contract}
isErc20={isErc20}
isLoggedIn={isLoggedIn}
isMultiphase={isMultiPhase}
tokenId={tokenId}
isPending={sendTx.isPending}
onRemove={() => {
removePhase(index);
}}
/>
)}
</div>
</ClaimsConditionFormContext.Provider>
</Fragment>
);
})}
<div className="flex flex-row">
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
<Flex alignItems="center" gap={3} justifyContent="center">
{(hasRemovedPhases || hasAddedPhases) && (
<Text color="red.500" fontWeight="bold">
You have unsaved changes
</Text>
)}
{controlledFields.length > 0 ||
hasRemovedPhases ||
!isMultiPhase ? (
<TransactionButton
client={contract.client}
disabled={claimConditionsQuery.isPending}
isLoggedIn={isLoggedIn}
isPending={sendTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
{claimConditionsQuery.isPending
? "Saving Phases"
: "Save Phases"}
</TransactionButton>
) : null}
</Flex>
</AdminOnly>
{phases?.length === 0 && (
<Alert borderRadius="md" status="warning">
<AlertIcon />
<div className="flex flex-col">
<AlertTitle as={Heading} size="label.lg">
{isMultiPhase
? "Missing Claim Phases"
: "Missing Claim Conditions"}
</AlertTitle>
<AlertDescription as={Text}>
{isMultiPhase
? "You need to set at least one claim phase for people to claim this drop."
: "You need to set claim conditions for people to claim this drop."}
</AlertDescription>
</div>
</Flex>
</Alert>
)}
<Flex
flexDir={{ base: "column", md: "row" }}
gap={2}
justifyContent="space-between"
>
<div className="flex flex-row gap-2">
<AdminOnly contract={contract}>
<Menu>
<MenuButton
as={Button}
borderRadius="md"
colorScheme="primary"
isDisabled={
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
}
leftIcon={<PlusIcon className="size-5" />}
size="sm"
variant={phases?.length > 0 ? "outline" : "solid"}
>
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
</MenuButton>
<MenuList borderRadius="lg" overflow="hidden" zIndex="overlay">
{Object.keys(ClaimConditionTypeData).map((key) => {
const type = key as ClaimConditionType;
if (type === "custom") {
return null;
}
return (
<MenuItem
key={type}
onClick={() => {
addPhase(type);
// TODO: Automatically start editing the new phase after adding it
}}
>
<div className="flex items-center gap-1">
{ClaimConditionTypeData[type].name}
<TooltipBox
content={
<Text size="body.md">
{ClaimConditionTypeData[type].description}
</Text>
}
/>
</div>
</MenuItem>
);
})}
</MenuList>
</Menu>
</AdminOnly>
{controlledFields.some((field) => field.fromSdk) && (
<ResetClaimEligibility
contract={contract}
isErc20={isErc20}
isLoggedIn={isLoggedIn}
isMultiphase={isMultiPhase}
tokenId={tokenId}
/>
)}
</div>
<div className="flex flex-row">
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
<Flex alignItems="center" gap={3} justifyContent="center">
{(hasRemovedPhases || hasAddedPhases) && (
<Text color="red.500" fontWeight="bold">
You have unsaved changes
</Text>
)}
{controlledFields.length > 0 ||
hasRemovedPhases ||
!isMultiPhase ? (
<TransactionButton
client={contract.client}
disabled={claimConditionsQuery.isPending}
isLoggedIn={isLoggedIn}
isPending={sendTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
{claimConditionsQuery.isPending
? "Saving Phases"
: "Save Phases"}
</TransactionButton>
) : null}
</Flex>
</AdminOnly>
</div>
</Flex>
</Flex>
</>
</Flex>
);
};
@@ -1,6 +1,7 @@
import { getAddress, getContract, isAddress } from "thirdweb";
import { localhost } from "thirdweb/chains";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
@@ -18,13 +19,21 @@ export async function getContractPageParamsInfo(params: {
return undefined;
}
// attempt to get the auth token
// Create server client only if secret key is available
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
return undefined;
}
const serverClient = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const serverContract = getContract({
address: contractAddress,
// eslint-disable-next-line no-restricted-syntax
chain: mapV4ChainToV5Chain(chainMetadata),
client: serverThirdwebClient,
client: serverClient,
});
const clientContract = getContract({
@@ -1,4 +1,5 @@
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
import {
@@ -320,7 +321,6 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
}}
opacity={failedToLoad ? 0.3 : 1}
pointerEvents={failedToLoad ? "none" : "auto"}
// biome-ignore lint/a11y/useSemanticElements: FIXME
role="group"
style={{ cursor: "pointer" }}
>
@@ -1,7 +1,7 @@
import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { Img } from "@/components/blocks/Img";
import { Button } from "@/components/ui/button";
@@ -149,7 +149,7 @@ export function ContractHeaderUI(props: {
{/* bottom row */}
<div className="flex flex-row flex-wrap items-center gap-2">
{props.contractCreator && (
{props.contractCreator && props.contractCreator !== ZERO_ADDRESS && (
<ContractCreatorBadge
clientContract={props.clientContract}
contractCreator={props.contractCreator}
@@ -7,10 +7,18 @@ export async function fetchTokenInfoFromBridge(params: {
}) {
try {
const res = await fetch(
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}&clientId=${params.clientId}`,
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
{
headers: {
"x-client-id": params.clientId,
},
},
);
if (!res.ok) {
console.error(
`Failed to fetch token info from bridge: ${await res.text()}`,
);
return null;
}
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
"use client";
import { CheckCheckIcon } from "lucide-react";
@@ -210,7 +211,6 @@ function NFTCard(props: {
}
: undefined
}
// biome-ignore lint/a11y/useSemanticElements: FIXME
role="button"
tabIndex={props.onClick ? 0 : undefined}
>
@@ -43,94 +43,92 @@ export const TokenAirdropForm: React.FC<TokenAirdropFormProps> = ({
);
return (
<>
<div className="pt-3">
<form
onSubmit={handleSubmit(async (data) => {
try {
const tx = transferBatch({
batch: data.addresses
.filter((address) => address.quantity !== undefined)
.map((address) => ({
amount: address.quantity,
to: address.address,
})),
contract,
});
await sendTransaction.mutateAsync(tx, {
onError: (error) => {
console.error(error);
},
onSuccess: () => {
// Close the sheet/modal on success
if (toggle) {
toggle(false);
}
},
});
airdropNotifications.onSuccess();
} catch (err) {
airdropNotifications.onError(err);
console.error(err);
}
})}
>
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
{airdropFormOpen ? (
<AirdropUpload
client={contract.client}
onClose={() => setAirdropFormOpen(false)}
setAirdrop={(value) =>
setValue("addresses", value, { shouldDirty: true })
<div className="pt-3">
<form
onSubmit={handleSubmit(async (data) => {
try {
const tx = transferBatch({
batch: data.addresses
.filter((address) => address.quantity !== undefined)
.map((address) => ({
amount: address.quantity,
to: address.address,
})),
contract,
});
await sendTransaction.mutateAsync(tx, {
onError: (error) => {
console.error(error);
},
onSuccess: () => {
// Close the sheet/modal on success
if (toggle) {
toggle(false);
}
/>
) : (
<div className="flex flex-col gap-4 md:flex-row">
<Button
borderRadius="md"
colorScheme="primary"
onClick={() => setAirdropFormOpen(true)}
rightIcon={<UploadIcon className="size-4" />}
>
Upload addresses
</Button>
{addresses.length > 0 && (
<div className="flex flex-row items-center justify-center gap-2 text-green-500">
<CircleCheckIcon className="text-green-500" size={16} />
<Text color="inherit" size="body.sm">
<strong>{addresses.length} addresses</strong> ready to be
airdropped
</Text>
</div>
)}
</div>
)}
</div>
{addresses?.length > 0 && !airdropFormOpen && (
<>
{estimateGasCost && (
<Text>
This transaction requires at least {estimateGasCost} gas.
Since each chain has a different gas limit, please split this
operation into multiple transactions if necessary. Usually
under 10M gas is safe.
</Text>
)}
<TransactionButton
className="self-end"
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={sendTransaction.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
},
});
airdropNotifications.onSuccess();
} catch (err) {
airdropNotifications.onError(err);
console.error(err);
}
})}
>
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
{airdropFormOpen ? (
<AirdropUpload
client={contract.client}
onClose={() => setAirdropFormOpen(false)}
setAirdrop={(value) =>
setValue("addresses", value, { shouldDirty: true })
}
/>
) : (
<div className="flex flex-col gap-4 md:flex-row">
<Button
borderRadius="md"
colorScheme="primary"
onClick={() => setAirdropFormOpen(true)}
rightIcon={<UploadIcon className="size-4" />}
>
Airdrop
</TransactionButton>
</>
Upload addresses
</Button>
{addresses.length > 0 && (
<div className="flex flex-row items-center justify-center gap-2 text-green-500">
<CircleCheckIcon className="text-green-500" size={16} />
<Text color="inherit" size="body.sm">
<strong>{addresses.length} addresses</strong> ready to be
airdropped
</Text>
</div>
)}
</div>
)}
</form>
</div>
</>
</div>
{addresses?.length > 0 && !airdropFormOpen && (
<>
{estimateGasCost && (
<Text>
This transaction requires at least {estimateGasCost} gas. Since
each chain has a different gas limit, please split this
operation into multiple transactions if necessary. Usually under
10M gas is safe.
</Text>
)}
<TransactionButton
className="self-end"
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={sendTransaction.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
Airdrop
</TransactionButton>
</>
)}
</form>
</div>
);
};
@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import "server-only";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons";
@@ -13,10 +13,16 @@ export async function ChainIcon(props: {
if (props.iconUrl) {
let imageLink = fallbackChainIcon;
const resolved = resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: props.iconUrl,
});
// Only resolve if we have a secret key available
const resolved = DASHBOARD_THIRDWEB_SECRET_KEY
? resolveSchemeWithErrorHandler({
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
uri: props.iconUrl,
})
: null;
if (resolved) {
// check if it loads or not
@@ -5,7 +5,7 @@ import { Suspense } from "react";
import {
ContractCard,
ContractCardSkeleton,
} from "../../../../../../@/components/contracts/contract-card";
} from "@/components/contracts/contract-card";
interface ContractRowProps {
category: ExploreCategory;
@@ -182,19 +182,17 @@ function ContractTableRow(props: { row: Row<ContractDataRow> }) {
const { row } = props;
const { key, ...rowProps } = row.getRowProps();
return (
<>
<TableRow
className="cursor-pointer hover:bg-card"
linkBox
{...rowProps}
key={key}
>
{row.cells.map((cell) => (
<TableCell {...cell.getCellProps()} key={cell.getCellProps().key}>
{cell.render("Cell")}
</TableCell>
))}
</TableRow>
</>
<TableRow
className="cursor-pointer hover:bg-card"
linkBox
{...rowProps}
key={key}
>
{row.cells.map((cell) => (
<TableCell {...cell.getCellProps()} key={cell.getCellProps().key}>
{cell.render("Cell")}
</TableCell>
))}
</TableRow>
);
}
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { resolveAvatar } from "thirdweb/extensions/ens";
import { GradientBlobbie } from "@/components/blocks/avatar/GradientBlobbie";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
/* eslint-disable @next/next/no-img-element */
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { shortenIfAddress } from "@/utils/usedapp-external";
@@ -23,10 +24,18 @@ type PageProps = {
export default async function Image(props: PageProps) {
const params = await props.params;
const resolvedInfo = await resolveAddressAndEns(
params.addressOrEns,
serverThirdwebClient,
);
// Create client only if secret key is available
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
notFound();
}
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const resolvedInfo = await resolveAddressAndEns(params.addressOrEns, client);
if (!resolvedInfo) {
notFound();
@@ -43,14 +52,14 @@ export default async function Image(props: PageProps) {
const ensImage = resolvedInfo.ensName
? await resolveAvatar({
client: serverThirdwebClient,
client,
name: resolvedInfo.ensName,
})
: null;
const resolvedENSImageSrc = ensImage
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
client,
uri: ensImage,
})
: null;
@@ -1,6 +1,7 @@
import { format } from "date-fns";
import { getSocialProfiles } from "thirdweb/social";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { resolveEns } from "@/lib/ens";
import { correctAndUniqueLicenses } from "@/lib/licenses";
import { getPublishedContractsWithPublisherMapping } from "../utils/getPublishedContractsWithPublisherMapping";
@@ -22,17 +23,25 @@ export default async function Image(props: {
}) {
const { publisher, contract_id } = props.params;
// Create client only if secret key is available
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
return null;
}
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const [publishedContracts, socialProfiles] = await Promise.all([
getPublishedContractsWithPublisherMapping({
client: serverThirdwebClient,
client,
contract_id: contract_id,
publisher: publisher,
}),
getSocialProfiles({
address:
(await resolveEns(publisher, serverThirdwebClient)).address ||
publisher,
client: serverThirdwebClient,
address: (await resolveEns(publisher, client)).address || publisher,
client,
}),
]);
@@ -1,6 +1,7 @@
import { format } from "date-fns";
import { getSocialProfiles } from "thirdweb/social";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { resolveEns } from "@/lib/ens";
import { correctAndUniqueLicenses } from "@/lib/licenses";
import { getLatestPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
@@ -21,17 +22,25 @@ export default async function Image(props: {
}) {
const { publisher, contract_id } = props.params;
// Create client only if secret key is available
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
return null;
}
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const [publishedContract, socialProfiles] = await Promise.all([
getLatestPublishedContractsWithPublisherMapping({
client: serverThirdwebClient,
client,
contract_id: contract_id,
publisher: publisher,
}),
getSocialProfiles({
address:
(await resolveEns(publisher, serverThirdwebClient)).address ||
publisher,
client: serverThirdwebClient,
address: (await resolveEns(publisher, client)).address || publisher,
client,
}),
]);
@@ -4,7 +4,8 @@ import { ImageResponse } from "next/og";
import { isAddress } from "thirdweb";
import { download } from "thirdweb/storage";
import { shortenAddress } from "thirdweb/utils";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
const OgBrandIcon: React.FC = () => (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
@@ -187,17 +188,41 @@ export async function publishedContractOGImageTemplate(params: {
ibmPlexMono500_,
ibmPlexMono700_,
image,
params.logo
? download({
client: serverThirdwebClient,
uri: params.logo,
}).then((res) => res.arrayBuffer())
params.logo && DASHBOARD_THIRDWEB_SECRET_KEY
? (async () => {
try {
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const response = await download({
client,
uri: params.logo || "",
});
return response.arrayBuffer();
} catch (error) {
console.warn("Failed to download logo:", error);
return undefined;
}
})()
: undefined,
params.publisherAvatar
? download({
client: serverThirdwebClient,
uri: params.publisherAvatar,
}).then((res) => res.arrayBuffer())
params.publisherAvatar && DASHBOARD_THIRDWEB_SECRET_KEY
? (async () => {
try {
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const response = await download({
client,
uri: params.publisherAvatar || "",
});
return response.arrayBuffer();
} catch (error) {
console.warn("Failed to download avatar:", error);
return undefined;
}
})()
: undefined,
]);
@@ -21,37 +21,33 @@ export const SupportForm_SelectInput = (props: Props) => {
const { options, formLabel, name, required, promptText } = props;
return (
<>
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={name}>
{formLabel}
{required && (
<span className="-top-1.5 -right-2 absolute text-destructive">
•
</span>
)}
</Label>
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={name}>
{formLabel}
{required && (
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
)}
</Label>
<Select
name={name}
onValueChange={(val) => {
props.onValueChange(val);
}}
required={required}
value={props.value}
>
<SelectTrigger>
<SelectValue placeholder={promptText}>{props.value}</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
<Select
name={name}
onValueChange={(val) => {
props.onValueChange(val);
}}
required={required}
value={props.value}
>
<SelectTrigger>
<SelectValue placeholder={promptText}>{props.value}</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -27,34 +27,32 @@ export const SupportForm_TeamSelection = (props: Props) => {
const teamId = useId();
return (
<>
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={teamId}>
Select Team
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
</Label>
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={teamId}>
Select Team
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
</Label>
<Select
name="teamId"
onValueChange={(selectedId) => {
props.onChange(selectedId);
}}
value={props.selectedTeamId}
>
<SelectTrigger id={teamId}>
<SelectValue placeholder="Select a Team">
{selectedTeamName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
<Select
name="teamId"
onValueChange={(selectedId) => {
props.onChange(selectedId);
}}
value={props.selectedTeamId}
>
<SelectTrigger id={teamId}>
<SelectValue placeholder="Select a Team">
{selectedTeamName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,7 +1,8 @@
import { ImageResponse } from "next/og";
import { useId } from "react";
import { download } from "thirdweb/storage";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { fetchChain } from "@/utils/fetchChain";
import { DROP_PAGES } from "./data";
@@ -84,16 +85,29 @@ export default async function Image({ params }: { params: { slug: string } }) {
fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) =>
res.arrayBuffer(),
),
// download the chain icon if there is one
chain.icon?.url && hasWorkingChainIcon
? download({
client: serverThirdwebClient,
uri: chain.icon.url,
}).then((res) => res.arrayBuffer())
// download the chain icon if there is one and secret key is available
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
? (async () => {
try {
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
const response = await download({
client,
uri: chain.icon?.url || "",
});
return response.arrayBuffer();
} catch (error) {
// If download fails, return undefined to fallback to no icon
console.warn("Failed to download chain icon:", error);
return undefined;
}
})()
: undefined,
// download the background image (based on chain)
fetch(
chain.icon?.url && hasWorkingChainIcon
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
? new URL(
"og-lib/assets/chain/bg-with-icon.png",
@@ -121,7 +135,7 @@ export default async function Image({ params }: { params: { slug: string } }) {
/>
{/* the actual component starts here */}
{hasWorkingChainIcon && (
{hasWorkingChainIcon && chainIcon && (
<img
alt=""
// @ts-expect-error - TS doesn't know about the ImageResponse component
@@ -11,7 +11,8 @@ import {
getActiveClaimCondition as getActiveClaimCondition1155,
getNFT as getNFT1155,
} from "thirdweb/extensions/erc1155";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
import { DROP_PAGES } from "./data";
import { NftMint } from "./mint-ui";
@@ -41,13 +42,24 @@ export default async function DropPage({
if (!project) {
return notFound();
}
// Create client only if secret key is available
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
return notFound();
}
const client = getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
});
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(project.chainId, undefined);
const contract = getContract({
address: project.contractAddress,
chain,
client: serverThirdwebClient,
client,
});
const [nft, claimCondition, contractMetadata] = await Promise.all([
@@ -92,7 +104,7 @@ export default async function DropPage({
contract: getContract({
address: claimCondition.currency,
chain,
client: serverThirdwebClient,
client,
}),
})
: undefined;
-9
View File
@@ -1,7 +1,6 @@
import "../../global.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import PlausibleProvider from "next-plausible";
import NextTopLoader from "nextjs-toploader";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
@@ -52,14 +51,6 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<PlausibleProvider
customDomain="https://pl.thirdweb.com"
domain="thirdweb.com"
selfHosted
/>
</head>
<body
className={cn("bg-background font-sans antialiased", fontSans.variable)}
>
@@ -87,8 +87,7 @@ export function InviteTeamMembersUI(props: {
client={props.client}
customCTASection={
<div className="flex gap-3">
{(props.team.billingPlan === "free" ||
props.team.billingPlan === "starter") && (
{props.team.billingPlan === "free" && (
<Button
className="gap-2"
onClick={() => {
@@ -9,7 +9,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { PROJECT_SHOWCASE_DATA } from "@/lib/project-showcase-constants";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
@@ -78,9 +79,13 @@ export default async function DetailPage(props: {
className="rounded-b-lg object-cover md:rounded-r-lg md:rounded-bl-none"
height={500}
src={
project.image?.startsWith("ipfs://")
project.image?.startsWith("ipfs://") &&
DASHBOARD_THIRDWEB_SECRET_KEY
? (resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
uri: project.image,
}) ?? "")
: (project.image ?? "/assets/showcase/default_image.png")
@@ -19,7 +19,8 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import {
PROJECT_SHOWCASE_DATA,
PROJECT_SHOWCASE_INDUSTRIES,
@@ -138,9 +139,13 @@ export default async function ProjectShowcasePage(props: {
className="h-48 w-full object-cover"
height={200}
src={
project.image?.startsWith("ipfs://")
project.image?.startsWith("ipfs://") &&
DASHBOARD_THIRDWEB_SECRET_KEY
? (resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
uri: project.image,
}) ?? "")
: (project.image ??
@@ -16,6 +16,11 @@ export function TeamSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
ecosystems: Array<{ name: string; slug: string }>;
chainSubscriptions: Array<{
chainId: number;
chainName: string;
slug: string;
}>;
}) {
const { layoutPath, children } = props;
@@ -59,6 +64,29 @@ export function TeamSidebarLayout(props: {
icon: DatabaseIcon,
label: "Usage",
},
...(props.chainSubscriptions.length > 0
? [
{
separator: true,
} as const,
{
links: [
...props.chainSubscriptions.map((chainSubscription) => ({
href: `${layoutPath}/~/infrastructure/${chainSubscription.slug}`,
label: chainSubscription.chainName,
})),
{
href: `${layoutPath}/~/infrastructure/deploy`,
label: "Deploy Infrastructure",
},
],
subMenu: {
icon: WalletCardsIcon,
label: "Chain Infrastucture",
},
},
]
: []),
]}
footerSidebarLinks={[
{
@@ -1,17 +1,18 @@
import { redirect } from "next/navigation";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { fetchEcosystemList } from "@/api/ecosystems";
import { getProjects } from "@/api/projects";
import { getTeamBySlug, getTeams } from "@/api/team";
import { getChainSubscriptions } from "@/api/team-subscription";
import { CustomChatButton } from "@/components/chat/CustomChatButton";
import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getChain } from "../../../(dashboard)/(chain)/utils";
import { siwaExamplePrompts } from "../../../(dashboard)/support/definitions";
import { getValidAccount } from "../../../account/settings/getAccount";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
import { StaffModeNotice } from "./_components/StaffModeNotice";
import type { Ecosystem } from "./~/ecosystem/types";
import { TeamSidebarLayout } from "./TeamSidebarLayout";
export default async function TeamLayout(props: {
@@ -20,32 +21,58 @@ export default async function TeamLayout(props: {
}) {
const params = await props.params;
const [accountAddress, account, teams, authToken, team] = await Promise.all([
const [
accountAddress,
account,
teams,
authToken,
team,
ecosystems,
chainSubscriptions,
] = await Promise.all([
getAuthTokenWalletAddress(),
getValidAccount(`/team/${params.team_slug}`),
getTeams(),
getAuthToken(),
getTeamBySlug(params.team_slug),
fetchEcosystemList(params.team_slug),
getChainSubscriptions(params.team_slug),
]);
if (!teams || !accountAddress || !authToken || !team) {
redirect("/login");
}
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
projects: await getProjects(team.slug),
team,
})),
);
const [teamsAndProjects, chainSidebarLinks] = await Promise.all([
Promise.all(
teams.map(async (team) => ({
projects: await getProjects(team.slug),
team,
})),
),
chainSubscriptions
? await Promise.all(
chainSubscriptions.map(async (chainSubscription) => {
if (!chainSubscription.chainId) {
throw new Error("Chain ID is required");
}
const chain = await getChain(chainSubscription.chainId);
return {
chainId: chain.chainId,
chainName: chain.name,
slug: chain.slug,
};
}),
).catch(() => [])
: [],
]);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const ecosystems = await fetchEcosystemList(team.id, authToken);
const isStaffMode = !teams.some((t) => t.slug === team.slug);
return (
@@ -65,6 +92,9 @@ export default async function TeamLayout(props: {
</div>
<TeamSidebarLayout
chainSubscriptions={chainSidebarLinks.sort(
(a, b) => a.chainId - b.chainId,
)}
ecosystems={ecosystems.map((ecosystem) => ({
name: ecosystem.name,
slug: ecosystem.slug,
@@ -90,20 +120,3 @@ export default async function TeamLayout(props: {
</SidebarProvider>
);
}
async function fetchEcosystemList(teamId: string, authToken: string) {
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamId}/ecosystem-wallet`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!res.ok) {
return [];
}
return (await res.json()).result as Ecosystem[];
}
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { TabPathLinks } from "../../../../../../../@/components/ui/tabs";
import { TabPathLinks } from "@/components/ui/tabs";
import { loginRedirect } from "../../../../../login/loginRedirect";
export default async function Layout(props: {
@@ -1,10 +1,10 @@
import { getEcosystemWalletUsage } from "@/api/analytics";
import type { Partner } from "@/api/ecosystems";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
import type { Partner } from "../../../../types";
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
import { EcosystemWalletsSummary } from "./Summary";
@@ -1,6 +1,7 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import type { Partner } from "@/api/ecosystems";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { DocLink } from "@/components/blocks/DocLink";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
@@ -10,7 +11,6 @@ import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
import type { EcosystemWalletStats } from "@/types/analytics";
import { formatTickerNumber } from "@/utils/format-utils";
import type { Partner } from "../../../../types";
type ChartData = Record<string, number> & {
time: string; // human readable date
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getAuthToken } from "../../../../../../../../../../@/api/auth-token";
import { fetchEcosystem } from "../../../utils/fetchEcosystem";
import { fetchPartners } from "../configuration/hooks/fetchPartners";
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";
@@ -29,7 +29,7 @@ export default async function Page(props: {
}
const [ecosystem, team] = await Promise.all([
fetchEcosystem(params.slug, authToken, params.team_slug),
fetchEcosystem(params.slug, params.team_slug),
getTeamBySlug(params.team_slug),
]);
@@ -1,9 +1,9 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { TabPathLinks } from "@/components/ui/tabs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { fetchEcosystem } from "../../../utils/fetchEcosystem";
import { EcosystemHeader } from "./ecosystem-header.client";
export async function EcosystemLayoutSlug({
@@ -21,11 +21,7 @@ export async function EcosystemLayoutSlug({
redirect(ecosystemLayoutPath);
}
const ecosystem = await fetchEcosystem(
params.slug,
authToken,
params.team_slug,
);
const ecosystem = await fetchEcosystem(params.slug, params.team_slug);
// Fetch team details to obtain team ID for further authenticated updates
const team = await getTeamBySlug(params.team_slug);
@@ -4,7 +4,7 @@ import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
/* eslint-disable */
import type { Ecosystem } from "@/api/ecosystems";
import { Img } from "@/components/blocks/Img";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -26,7 +26,6 @@ import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import type { Ecosystem } from "../../../types";
import { useUpdateEcosystem } from "../configuration/hooks/use-update-ecosystem";
import { useEcosystem } from "../hooks/use-ecosystem";
@@ -1,10 +1,10 @@
import { notFound } 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";
import { getAuthToken } from "../../../../../../../../../../../@/api/auth-token";
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
import { AddPartnerForm } from "../components/client/add-partner-form.client";
import { fetchEcosystem } from "../hooks/fetchEcosystem";
export default async function AddPartnerPage({
params,
@@ -34,11 +34,10 @@ export default async function AddPartnerPage({
});
try {
const ecosystem = await fetchEcosystem({
authToken,
slug: ecosystemSlug,
teamIdOrSlug: teamSlug,
});
const ecosystem = await fetchEcosystem(ecosystemSlug, teamSlug);
if (!ecosystem) {
throw new Error("Ecosystem not found");
}
return (
<div className="flex flex-col">
@@ -2,8 +2,8 @@
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import type { Ecosystem } from "@/api/ecosystems";
import { Button } from "@/components/ui/button";
import type { Ecosystem } from "../../../../../types";
export function AddPartnerDialogButton(props: {
teamSlug: string;
@@ -2,8 +2,8 @@
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import type { Ecosystem, Partner } from "@/api/ecosystems";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Ecosystem, Partner } from "../../../../../types";
import { useAddPartner } from "../../hooks/use-add-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
@@ -12,6 +12,7 @@ import {
} from "thirdweb/wallets/smart";
import invariant from "tiny-invariant";
import { z } from "zod";
import type { AuthOption, Ecosystem } from "@/api/ecosystems";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Button } from "@/components/ui/button";
@@ -36,9 +37,28 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { authOptions, type Ecosystem } from "../../../../../types";
import { useUpdateEcosystem } from "../../hooks/use-update-ecosystem";
const authOptions = [
"email",
"phone",
"passkey",
"siwe",
"guest",
"google",
"facebook",
"x",
"discord",
"farcaster",
"telegram",
"github",
"twitch",
"steam",
"apple",
"coinbase",
"line",
] as const satisfies AuthOption[];
type AuthOptionsFormData = {
authOptions: string[];
useCustomAuth: boolean;
@@ -113,6 +133,8 @@ export function AuthOptionsForm({
(data) => {
if (
data.useSmartAccount &&
data.executionMode === "EIP4337" &&
data.accountFactoryType === "custom" &&
data.customAccountFactoryAddress &&
!isAddress(data.customAccountFactoryAddress)
) {
@@ -125,6 +147,23 @@ export function AuthOptionsForm({
path: ["customAccountFactoryAddress"],
},
)
.refine(
(data) => {
if (
data.useSmartAccount &&
data.executionMode === "EIP4337" &&
data.accountFactoryType === "custom" &&
!data.customAccountFactoryAddress
) {
return false;
}
return true;
},
{
message: "Please enter a custom account factory address",
path: ["customAccountFactoryAddress"],
},
)
.refine(
(data) => {
if (data.useSmartAccount && (data.defaultChainId ?? 0) <= 0) {
@@ -192,7 +231,7 @@ export function AuthOptionsForm({
}
let smartAccountOptions: Ecosystem["smartAccountOptions"] | null = null;
if (data.useSmartAccount) {
if (data.useSmartAccount && data.executionMode === "EIP4337") {
let accountFactoryAddress: string;
switch (data.accountFactoryType) {
case "v0.6":
@@ -220,7 +259,7 @@ export function AuthOptionsForm({
updateEcosystem({
...ecosystem,
authOptions: data.authOptions as (typeof authOptions)[number][],
authOptions: data.authOptions as AuthOption[],
customAuthOptions,
smartAccountOptions,
});
@@ -2,11 +2,11 @@
import { useState } from "react";
import { toast } from "sonner";
import invariant from "tiny-invariant";
import type { Ecosystem } from "@/api/ecosystems";
import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog";
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { Ecosystem } from "../../../../../types";
import { useUpdateEcosystem } from "../../hooks/use-update-ecosystem";
export function IntegrationPermissionsToggle({
@@ -6,6 +6,7 @@ import { useId } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import type { z } from "zod";
import type { Partner } from "@/api/ecosystems";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -21,7 +22,6 @@ import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import type { Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { AllowedOperationsSection } from "./allowed-operations-section";
@@ -2,8 +2,8 @@
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import type { Ecosystem, Partner } from "@/api/ecosystems";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Ecosystem, Partner } from "../../../../../types";
import { useUpdatePartner } from "../../hooks/use-update-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
@@ -1,5 +1,5 @@
import type { ThirdwebClient } from "thirdweb";
import type { Ecosystem } from "../../../../../types";
import type { Ecosystem } from "@/api/ecosystems";
import {
AuthOptionsForm,
AuthOptionsFormSkeleton,
@@ -1,4 +1,4 @@
import type { Ecosystem } from "../../../../../types";
import type { Ecosystem } from "@/api/ecosystems";
import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton";
import { PartnersTable } from "./partners-table";
@@ -1,4 +1,4 @@
import type { Ecosystem } from "../../../../../types";
import type { Ecosystem } from "@/api/ecosystems";
import {
IntegrationPermissionsToggle,
IntegrationPermissionsToggleSkeleton,
@@ -1,6 +1,7 @@
import { Link } from "chakra/link";
import { PencilIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import type { Ecosystem, Partner } from "@/api/ecosystems";
import { Button } from "@/components/ui/button";
import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog";
import { CopyButton } from "@/components/ui/CopyButton";
@@ -17,7 +18,6 @@ import {
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Ecosystem, Partner } from "../../../../../types";
import { usePartners } from "../../../hooks/use-partners";
import { useDeletePartner } from "../../hooks/use-delete-partner";
@@ -1,34 +0,0 @@
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { Ecosystem } from "../../../../types";
/**
* Fetches ecosystem data from the server
*/
export async function fetchEcosystem(args: {
teamIdOrSlug: string;
slug: string;
authToken: string;
}): Promise<Ecosystem> {
const { teamIdOrSlug, slug, authToken } = args;
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
revalidate: 0,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystem",
);
}
return (await res.json()).result as Ecosystem;
}
@@ -1,4 +1,4 @@
import type { Ecosystem, Partner } from "../../../../types";
import type { Ecosystem, Partner } from "@/api/ecosystems";
export async function fetchPartnerDetails(args: {
authToken: string;
@@ -1,4 +1,4 @@
import type { Ecosystem, Partner } from "../../../../types";
import type { Ecosystem, Partner } from "@/api/ecosystems";
/**
* Fetches partners for an ecosystem
@@ -3,7 +3,7 @@ import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { Ecosystem, Partner } from "../../../../types";
import type { Ecosystem, Partner } from "@/api/ecosystems";
type AddPartnerParams = {
ecosystem: Ecosystem;
@@ -3,7 +3,7 @@ import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { Ecosystem } from "../../../../types";
import type { Ecosystem } from "@/api/ecosystems";
type DeletePartnerParams = {
ecosystem: Ecosystem;
@@ -3,7 +3,7 @@ import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { Ecosystem } from "../../../../types";
import type { Ecosystem } from "@/api/ecosystems";
export function useUpdateEcosystem(
params: {
@@ -3,7 +3,7 @@ import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { Ecosystem, Partner } from "../../../../types";
import type { Ecosystem, Partner } from "@/api/ecosystems";
type UpdatePartnerParams = {
partnerId: string;
@@ -1,10 +1,10 @@
import { notFound } 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";
import { getAuthToken } from "../../../../../../../../../../../../../@/api/auth-token";
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";
import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client";
import { fetchEcosystem } from "../../../hooks/fetchEcosystem";
import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails";
export default async function EditPartnerPage({
@@ -36,11 +36,11 @@ export default async function EditPartnerPage({
});
try {
const ecosystem = await fetchEcosystem({
authToken,
slug: ecosystemSlug,
teamIdOrSlug: teamSlug,
});
const ecosystem = await fetchEcosystem(ecosystemSlug, teamSlug);
if (!ecosystem) {
throw new Error("Ecosystem not found");
}
try {
const partner = await fetchPartnerDetails({
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { apiServerProxy } from "@/actions/proxies";
import type { Ecosystem } from "../../../types";
import type { Ecosystem } from "@/api/ecosystems";
export function useEcosystem({
teamIdOrSlug,
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import type { Ecosystem, Partner } from "../../../types";
import type { Ecosystem, Partner } from "@/api/ecosystems";
import { fetchPartners } from "../configuration/hooks/fetchPartners";
export function usePartners({
@@ -2,10 +2,10 @@
import "server-only";
import { redirect } from "next/navigation";
import { upload } from "thirdweb/storage";
import { getAuthToken } from "@/api/auth-token";
import { BASE_URL } from "@/constants/env-utils";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../../../../../../@/api/auth-token";
export async function createEcosystem(options: {
teamSlug: string;
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystemList } from "@/api/ecosystems";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { fetchEcosystemList } from "./utils/fetchEcosystemList";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
@@ -15,12 +15,10 @@ export default async function Page(props: {
loginRedirect(ecosystemLayoutPath);
}
const ecosystems = await fetchEcosystemList(authToken, team_slug).catch(
(err) => {
console.error("failed to fetch ecosystems", err);
return [];
},
);
const ecosystems = await fetchEcosystemList(team_slug).catch((err) => {
console.error("failed to fetch ecosystems", err);
return [];
});
if (ecosystems[0]) {
redirect(`${ecosystemLayoutPath}/${ecosystems[0].slug}`);
@@ -1,25 +0,0 @@
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { Ecosystem } from "../types";
export async function fetchEcosystem(
slug: string,
authToken: string,
teamIdOrSlug: string,
) {
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
return null;
}
const data = (await res.json()) as { result: Ecosystem };
return data.result;
}
@@ -1,25 +0,0 @@
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { Ecosystem } from "../types";
export async function fetchEcosystemList(
authToken: string,
teamIdOrSlug: string,
) {
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(data?.error?.message ?? "Failed to fetch ecosystems");
}
const data = (await res.json()) as { result: Ecosystem[] };
return data.result;
}
@@ -0,0 +1,111 @@
"use client";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Spinner } from "@/components/ui/Spinner/Spinner";
type ServiceStatus = "active" | "pending" | "inactive";
type InfraServiceCardProps = {
title: string;
status: ServiceStatus;
};
export function InfraServiceCard({ title, status }: InfraServiceCardProps) {
return (
<section className="flex flex-col gap-4">
{/* Header row with status and optional action */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{title}</h3>
<Badge
className="gap-2"
variant={
status === "active"
? "success"
: status === "pending"
? "default"
: "outline"
}
>
{status === "active"
? "Active"
: status === "pending"
? "Pending"
: "Inactive"}
{status === "pending" && <Spinner className="size-3" />}
</Badge>
</div>
</div>
<MetricPlaceholders serviceTitle={title} status={status} />
</section>
);
}
// --- Helper Components ---
function MetricPlaceholders({
status,
serviceTitle,
}: {
status: ServiceStatus;
serviceTitle: string;
}) {
const metrics = getMetricsForService(serviceTitle);
return (
<div className="grid gap-4 sm:grid-cols-2">
{metrics.map((metric) => (
<Card className="p-4 flex flex-col gap-2" key={metric.key}>
<span className="text-xs font-medium text-muted-foreground">
{metric.label}
</span>
<div className="h-32 w-full">
<EmptyChartState type="area">
{status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)}
</EmptyChartState>
</div>
</Card>
))}
</div>
);
}
type Metric = { key: string; label: string };
function getMetricsForService(title: string): Metric[] {
const normalized = title.toLowerCase();
if (normalized === "rpc") {
return [
{ key: "requests", label: "Requests" },
{ key: "monthly_active_developers", label: "Monthly Active Developers" },
];
}
if (normalized === "insight") {
return [
{ key: "requests", label: "Requests" },
{ key: "monthly_active_developers", label: "Monthly Active Developers" },
];
}
if (normalized === "account abstraction") {
return [
{ key: "transactions", label: "Transactions" },
{ key: "monthly_active_developers", label: "Monthly Active Developers" },
{ key: "gas_sponsored", label: "Gas Sponsored" },
];
}
// fallback empty
return [];
}
@@ -0,0 +1,178 @@
import { InfoIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getChainSubscriptionForChain } from "@/api/team-subscription";
import { formatToDollars } from "@/components/billing/formatToDollars";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { ChainIconClient } from "@/icons/ChainIcon";
import { ToolTipLabel } from "../../../../../../../../@/components/ui/tooltip";
import { getChain } from "../../../../../../(dashboard)/(chain)/utils";
import { InfraServiceCard } from "./_components/service-card";
const PRODUCTS = [
{
sku: "chain:infra:rpc",
title: "RPC",
},
{
sku: "chain:infra:insight",
title: "Insight",
},
{
sku: "chain:infra:account_abstraction",
title: "Account Abstraction",
},
] as const;
export default async function DeployInfrastructureOnChainPage(props: {
params: Promise<{ chain_id: string; team_slug: string }>;
}) {
const params = await props.params;
const chain = await getChain(params.chain_id);
if (!chain) {
notFound();
}
if (chain.slug !== params.chain_id) {
// redirect to the slug version of the page
redirect(`/team/${params.team_slug}/~/infrastructure/${chain.slug}`);
}
const chainSubscription = await getChainSubscriptionForChain(
params.team_slug,
chain.chainId,
);
if (!chainSubscription) {
notFound();
}
const client = getClientThirdwebClient();
// Format renewal date and amount due for the subscription summary section
const renewalDate = new Date(chainSubscription.currentPeriodEnd);
const formattedRenewalDate = renewalDate.toLocaleDateString(undefined, {
day: "numeric",
month: "long",
year: "numeric",
});
// upcomingInvoice.amount is stored in cents – format to dollars if available
const formattedAmountDue =
chainSubscription.upcomingInvoice.amount !== null
? formatToDollars(chainSubscription.upcomingInvoice.amount)
: "N/A";
return (
<div className="flex flex-col gap-8">
{/* Chain header */}
<div className="flex flex-col items-center gap-4 md:flex-row">
<h2 className="text-2xl font-bold flex items-center gap-2">
Infrastructure for
</h2>
<Card>
<CardContent className="flex gap-4 items-center p-4">
<span className="flex gap-2 truncate text-left items-center">
{chain.icon && (
<ChainIconClient
className="size-6"
client={client}
loading="lazy"
src={chain.icon?.url}
/>
)}
{cleanChainName(chain.name)}
</span>
<Badge className="gap-2" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
</CardContent>
</Card>
</div>
{PRODUCTS.map((product) => {
const hasSku = chainSubscription.skus.includes(product.sku);
// Map sku to chain service key
const skuToServiceKey: Record<string, string> = {
"chain:infra:account_abstraction": "account-abstraction",
"chain:infra:insight": "insight",
"chain:infra:rpc": "rpc-edge",
};
const serviceKey = skuToServiceKey[product.sku];
const chainService = chain.services.find(
(s) => s.service === serviceKey,
);
const serviceEnabled =
chainService?.enabled ?? chainService?.status === "enabled";
let status: "active" | "pending" | "inactive";
if (hasSku && serviceEnabled) {
status = "active";
} else if (hasSku && !serviceEnabled) {
status = "pending";
} else {
status = "inactive";
}
return (
<InfraServiceCard
key={product.sku}
status={status}
title={product.title}
/>
);
})}
<Separator />
{/* Subscription summary */}
<Card>
<CardContent className="flex flex-col gap-4 p-6 md:flex-row md:items-center md:justify-between">
{/* Left: header + info */}
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<h3 className="text-lg font-semibold">Subscription details </h3>
{chainSubscription.isLegacy && (
<Badge className="gap-0.5" variant="outline">
<span>Enterprise</span>
<ToolTipLabel
label={
<span className="text-xs font-normal">
This subscription is part of an enterprise agreement and
cannot be modified through the dashboard. Please contact
your account executive for any modifications.
</span>
}
>
<InfoIcon className="size-3 ml-1 cursor-help" />
</ToolTipLabel>
</Badge>
)}
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-8">
<div className="flex gap-2 items-center">
<span className="text-muted-foreground">Renews on</span>
<span>{formattedRenewalDate}</span>
</div>
<div className="flex gap-2 items-center">
<span className="text-muted-foreground">Amount due</span>
<span>{formattedAmountDue}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
}
@@ -0,0 +1,95 @@
import { ArrowUpDownIcon } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getMembers } from "@/api/team-members";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { ChainIconClient } from "@/icons/ChainIcon";
import { getChain } from "../../../../../../../(dashboard)/(chain)/utils";
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
import { DeployInfrastructureForm } from "../_components/deploy-infrastructure-form.client";
export default async function DeployInfrastructureOnChainPage(props: {
params: Promise<{ chain_id: string; team_slug: string }>;
}) {
const params = await props.params;
const pagePath = `/team/${params.team_slug}/~/infrastructure/deploy/${params.chain_id}`;
const [account, chain, members] = await Promise.all([
getValidAccount(pagePath),
getChain(params.chain_id),
getMembers(params.team_slug),
]);
if (!chain) {
notFound();
}
if (chain.slug !== params.chain_id) {
// redirect to the slug version of the page
redirect(`/team/${params.team_slug}/~/infrastructure/deploy/${chain.slug}`);
}
if (!members) {
notFound();
}
const accountMemberInfo = members.find((m) => m.accountId === account.id);
if (!accountMemberInfo) {
notFound();
}
const client = getClientThirdwebClient();
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4 md:flex-row">
<h2 className="text-2xl font-bold flex items-center gap-2">
Deploy Infrastructure on
</h2>
<Card>
<CardContent className="flex gap-4 items-center p-4">
<span className="flex gap-2 truncate text-left items-center">
{chain.icon && (
<ChainIconClient
className="size-6"
client={client}
loading="lazy"
src={chain.icon?.url}
/>
)}
{cleanChainName(chain.name)}
</span>
<Badge className="gap-2" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
<Button
asChild
className="text-muted-foreground p-0 hover:text-foreground size-4"
size="icon"
variant="link"
>
<Link href={`/team/${params.team_slug}/~/infrastructure/deploy`}>
<ArrowUpDownIcon className="size-4" />
</Link>
</Button>
</CardContent>
</Card>
</div>
<DeployInfrastructureForm
chain={chain}
isOwner={accountMemberInfo.role === "OWNER"}
teamSlug={params.team_slug}
/>
</div>
);
}
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
}
@@ -0,0 +1,459 @@
"use client";
import { useQueryState } from "nuqs";
import { useMemo, useTransition } from "react";
import { toast } from "sonner";
import { getChainInfraCheckoutURL } from "@/actions/billing";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { InsightIcon } from "@/icons/InsightIcon";
import { RPCIcon } from "@/icons/RPCIcon";
import { SmartAccountIcon } from "@/icons/SmartAccountIcon";
import { cn } from "@/lib/utils";
import type { ChainInfraSKU } from "@/types/billing";
import type { ChainMetadataWithServices } from "@/types/chain";
import { searchParams } from "../search-params";
// Pricing constants (USD)
const SERVICE_CONFIG = {
accountAbstraction: {
annualPrice: 6120,
description:
"Let developers offer gasless transactions and programmable smart accounts out-of-the-box. Powered by ERC-4337 & ERC-7702 for wallet-less onboarding and custom account logic.",
icon: "SmartAccountIcon",
label: "Account Abstraction",
monthlyPrice: 600,
required: false,
sku: "chain:infra:account_abstraction" as const,
},
insight: {
annualPrice: 15300,
description:
"Arm developers with real-time, indexed data via a turnkey REST API & Webhooks. Query any event, transaction, or token in milliseconds—no subgraph setup or indexer maintenance required.",
icon: "InsightIcon",
label: "Insight",
monthlyPrice: 1500,
required: false,
sku: "chain:infra:insight" as const,
},
rpc: {
annualPrice: 15300,
description:
"Deliver blazing-fast, reliable RPC endpoints through our global edge network so developers enjoy low-latency reads & writes that seamlessly scale with their traffic.",
icon: "RPCIcon",
label: "RPC",
monthlyPrice: 1500,
required: true,
sku: "chain:infra:rpc" as const,
},
} satisfies Record<
string,
{
label: string;
description: string;
sku: ChainInfraSKU;
monthlyPrice: number;
annualPrice: number;
required: boolean;
icon: "RPCIcon" | "InsightIcon" | "SmartAccountIcon";
}
>;
const formatUSD = (amount: number) => `$${amount.toLocaleString()}`;
export function DeployInfrastructureForm(props: {
chain: ChainMetadataWithServices;
teamSlug: string;
isOwner: boolean;
className?: string;
}) {
const [isTransitionPending, startTransition] = useTransition();
const [frequency, setFrequency] = useQueryState(
"freq",
searchParams.freq.withOptions({ history: "replace", startTransition }),
);
const [addonsStr, setAddonsStr] = useQueryState(
"addons",
searchParams.addons.withOptions({ history: "replace", startTransition }),
);
const addons = useMemo(() => {
return addonsStr ? addonsStr.split(",").filter(Boolean) : [];
}, [addonsStr]);
const includeInsight = addons.includes("insight");
const includeAA = addons.includes("aa");
const selectedOrder = useMemo(() => {
const arr: (keyof typeof SERVICE_CONFIG)[] = ["rpc"];
if (includeInsight) arr.push("insight");
if (includeAA) arr.push("accountAbstraction");
return arr;
}, [includeInsight, includeAA]);
// NEW: count selected services and prepare bundle discount hint
const selectedCount = selectedOrder.length;
const bundleHint = useMemo(() => {
if (selectedCount === 1) {
return "Add one more add-on to unlock a 10% bundle discount.";
} else if (selectedCount === 2) {
return "Add another add-on to increase your bundle discount to 15%.";
} else if (selectedCount >= 3) {
return "🎉 Congrats! You unlocked the maximum 15% bundle discount.";
}
return null;
}, [selectedCount]);
const selectedServices = useMemo(() => {
return {
accountAbstraction: includeAA,
insight: includeInsight,
rpc: true,
} as const;
}, [includeInsight, includeAA]);
const pricePerService = useMemo(() => {
const isAnnual = frequency === "annual";
const mapping: Record<keyof typeof selectedServices, number> = {
accountAbstraction:
SERVICE_CONFIG.accountAbstraction[
isAnnual ? "annualPrice" : "monthlyPrice"
],
insight:
SERVICE_CONFIG.insight[isAnnual ? "annualPrice" : "monthlyPrice"],
rpc: SERVICE_CONFIG.rpc[isAnnual ? "annualPrice" : "monthlyPrice"],
};
return mapping;
}, [frequency]);
// Calculate totals and savings correctly
const { subtotal, bundleDiscount, total, totalSavings, originalTotal } =
useMemo(() => {
let subtotal = 0; // price after annual discount but before bundle
let originalTotal = 0; // monthly price * months (12 if annual) with no discounts
let count = 0;
(
Object.keys(selectedServices) as Array<keyof typeof selectedServices>
).forEach((key) => {
if (selectedServices[key]) {
subtotal += pricePerService[key];
originalTotal +=
SERVICE_CONFIG[key].monthlyPrice *
(frequency === "annual" ? 12 : 1);
count += 1;
}
});
let discountRate = 0;
if (count === 2) {
discountRate = 0.1;
} else if (count >= 3) {
discountRate = 0.15;
}
const annualDiscount =
frequency === "annual" ? originalTotal - subtotal : 0;
const bundleDiscount = subtotal * discountRate;
const total = subtotal - bundleDiscount;
const totalSavings = annualDiscount + bundleDiscount;
return {
annualDiscount,
bundleDiscount,
originalTotal,
subtotal,
total,
totalSavings,
};
}, [selectedServices, pricePerService, frequency]);
const chainId = props.chain.chainId;
const checkout = () => {
startTransition(async () => {
try {
const skus: ChainInfraSKU[] = [SERVICE_CONFIG.rpc.sku];
if (includeInsight) skus.push(SERVICE_CONFIG.insight.sku);
if (includeAA) skus.push(SERVICE_CONFIG.accountAbstraction.sku);
const res = await getChainInfraCheckoutURL({
annual: frequency === "annual",
chainId,
skus,
teamSlug: props.teamSlug,
});
// If the action returns, it means redirect did not happen and we have an error
if (res.status === "error") {
toast.error(res.error);
} else if (res.status === "success") {
// replace the current page with the checkout page (which will then redirect back to us)
window.location.href = res.data;
}
} catch (err) {
console.error(err);
toast.error(
"Failed to create checkout session. Please try again later.",
);
}
});
};
const periodLabel = frequency === "annual" ? "/yr" : "/mo";
const isAnnual = frequency === "annual";
return (
<div className="flex flex-col gap-8 lg:flex-row">
{/* Left column: service selection + frequency */}
<div className="flex flex-col gap-4">
<h3 className="text-lg font-semibold">Select Services</h3>
{/* Required service */}
<div className="flex flex-col gap-2 mb-6">
<ServiceCard
description={SERVICE_CONFIG.rpc.description}
disabled
icon={SERVICE_CONFIG.rpc.icon}
label={SERVICE_CONFIG.rpc.label}
onToggle={() => {}}
originalPrice={
isAnnual ? SERVICE_CONFIG.rpc.monthlyPrice * 12 : undefined
}
periodLabel={periodLabel}
price={pricePerService.rpc}
required
selected
/>
</div>
{/* Optional add-ons */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">Add-ons</p>
{bundleHint && (
<p className="text-xs font-medium text-primary">{bundleHint}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Insight */}
<ServiceCard
description={SERVICE_CONFIG.insight.description}
icon={SERVICE_CONFIG.insight.icon}
label={SERVICE_CONFIG.insight.label}
onToggle={() => {
const newVal = !includeInsight;
const newAddons = addons.filter((a) => a !== "insight");
if (newVal) newAddons.push("insight");
setAddonsStr(newAddons.join(","));
}}
originalPrice={
isAnnual ? SERVICE_CONFIG.insight.monthlyPrice * 12 : undefined
}
periodLabel={periodLabel}
price={pricePerService.insight}
selected={includeInsight}
/>
{/* Account Abstraction */}
<ServiceCard
description={SERVICE_CONFIG.accountAbstraction.description}
icon={SERVICE_CONFIG.accountAbstraction.icon}
label={SERVICE_CONFIG.accountAbstraction.label}
onToggle={() => {
const newVal = !includeAA;
const newAddons = addons.filter((a) => a !== "aa");
if (newVal) newAddons.push("aa");
setAddonsStr(newAddons.join(","));
}}
originalPrice={
isAnnual
? SERVICE_CONFIG.accountAbstraction.monthlyPrice * 12
: undefined
}
periodLabel={periodLabel}
price={pricePerService.accountAbstraction}
selected={includeAA}
/>
</div>
</div>
</div>
{/* Right column: order summary */}
<div className="w-full lg:max-w-sm border rounded-md p-6 bg-muted/30 h-fit">
<h3 className="font-medium mb-4">Order Summary</h3>
<div className="space-y-2 text-sm">
{selectedOrder.map((key) => (
<div className="flex justify-between" key={key}>
<span>{SERVICE_CONFIG[key].label}</span>
<span className="flex gap-1 items-center">
{isAnnual && (
<span className="text-muted-foreground line-through text-xs">
{formatUSD(SERVICE_CONFIG[key].monthlyPrice * 12)}
</span>
)}
<span>
{formatUSD(pricePerService[key])}
{periodLabel}
</span>
</span>
</div>
))}
<div className="flex justify-between pt-2 border-t mt-2">
<span>Subtotal</span>
<span>
{formatUSD(subtotal)}
{periodLabel}
</span>
</div>
{bundleDiscount > 0 && (
<div className="flex justify-between text-green-600">
<span>
Bundle Discount (
{Object.values(selectedServices).filter(Boolean).length === 2
? "10%"
: "15%"}
off)
</span>
<span>-{formatUSD(bundleDiscount)}</span>
</div>
)}
{/* Billing Frequency Toggle */}
<div className="flex items-center justify-between pt-4 bg-muted/70 rounded-lg p-3">
<span className="text-sm font-medium">Pay annually & save 15%</span>
<Switch
checked={frequency === "annual"}
onCheckedChange={(checked) =>
setFrequency(checked ? "annual" : "monthly")
}
/>
</div>
{/* Total Row */}
<div className="flex justify-between font-semibold pt-4 border-t mt-4 mb-4">
<span>Total</span>
<p className="flex items-center gap-2">
{totalSavings > 0 && (
<span className="text-muted-foreground line-through text-xs">
{formatUSD(originalTotal)}
</span>
)}
<span>
{formatUSD(total)} {periodLabel}
</span>
</p>
</div>
<Button
className="w-full"
disabled={isTransitionPending || !props.isOwner}
onClick={checkout}
>
Proceed to Checkout
</Button>
{!props.isOwner && (
<p className="text-muted-foreground text-xs text-center">
Only team owners can deploy infrastructure.
</p>
)}
</div>
</div>
</div>
);
}
// --- Service Card Component ---
type IconKey = "RPCIcon" | "InsightIcon" | "SmartAccountIcon";
function getIcon(icon: IconKey) {
switch (icon) {
case "RPCIcon":
return RPCIcon;
case "InsightIcon":
return InsightIcon;
case "SmartAccountIcon":
return SmartAccountIcon;
default:
return RPCIcon;
}
}
function ServiceCard(props: {
label: string;
description: string;
price: number;
periodLabel: string;
originalPrice?: number;
selected?: boolean;
disabled?: boolean;
required?: boolean;
icon: IconKey;
onToggle: () => void;
}) {
const {
label,
description,
price,
periodLabel,
originalPrice,
selected,
disabled,
required,
icon,
onToggle,
} = props;
return (
<button
className={cn(
"flex flex-col items-start gap-3 rounded-lg p-4 text-left transition-colors border",
disabled
? "border-primary bg-primary/10"
: selected
? "border-primary bg-primary/10 hover:bg-primary/10 hover:border-primary/50"
: "hover:border-primary/50 hover:bg-muted/40",
)}
disabled={disabled}
onClick={() => !disabled && onToggle()}
type="button"
>
<div className="flex items-center justify-between w-full">
<h4 className="font-semibold text-lg leading-none flex gap-2 items-center">
{(() => {
const IconComp = getIcon(icon);
return <IconComp className="size-4" />;
})()}
{label}
{required && <Badge variant="outline">Always Included</Badge>}
</h4>
{!disabled && (
<span
className={cn(
"size-4 rounded-full border flex items-center justify-center transition-colors",
selected ? "bg-primary border-primary" : "",
)}
>
{selected && <span className="size-2 bg-card rounded-full" />}
</span>
)}
</div>
<p className="text-muted-foreground text-sm min-h-[48px]">
{description}
</p>
<p className="mt-auto font-medium flex items-center gap-2">
{originalPrice && (
<span className="text-muted-foreground line-through text-xs">
{formatUSD(originalPrice)}
</span>
)}
<span>
{formatUSD(price)}
{periodLabel}
</span>
</p>
</button>
);
}
@@ -0,0 +1,122 @@
"use client";
/**
* This page lets customers select a chain to deploy infrastructure on as step one of a 2 step process
* in order to do this customers select a chain from the dropdown and then they can continue to `/team/<team_slug>/~/infrastructure/deploy/[chain_id]`
*/
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
export default function DeployInfrastructurePage() {
const client = getClientThirdwebClient();
const [chainId, setChainId] = useState<number | undefined>(undefined);
const { team_slug } = useParams<{ team_slug: string }>();
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4 md:flex-row">
<h2 className="text-2xl font-bold flex items-center gap-2">
Deploy Infrastructure
</h2>
</div>
<div className="flex flex-col gap-10">
{/* Header */}
<div className="flex flex-col gap-4">
<h3 className="text-xl font-semibold">Choose your Chain</h3>
<p className="text-muted-foreground max-w-prose">
Select the chain you'd like to deploy infrastructure on. In the next
step you'll pick which services you want to enable for all
developers on this chain.
</p>
</div>
{/* Chain selector */}
<div className="max-w-md">
<SingleNetworkSelector
align="start"
chainId={chainId}
client={client}
disableDeprecated
onChange={setChainId}
placeholder="Select a chain"
side="bottom"
/>
{/* Alternative paths hidden inside popover */}
<Popover>
<PopoverTrigger asChild>
<Button
className="text-muted-foreground px-0 underline hover:text-foreground"
variant="link"
>
Can&apos;t find your chain?
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="max-w-md text-sm">
<ol className="space-y-4">
<li>
<b>Option 1:</b> Submit a PR to&nbsp;
<a
className="underline underline-offset-4"
href="https://github.com/ethereum-lists/chains"
rel="noopener noreferrer"
target="_blank"
>
ethereum-lists/chains
</a>{" "}
to add your chain.{" "}
<span className="text-muted-foreground text-xs">
(automatically added on PR merge)
</span>
</li>
<li>
<b>Option 2:</b> Share your chain details via&nbsp;
<a
className="underline underline-offset-4"
href="https://share.hsforms.com/1XDi-ieM9Rl6oIkn7ynK6Lgea58c"
rel="noopener noreferrer"
target="_blank"
>
this short form
</a>
.<br />
<span className="text-muted-foreground text-xs">
(multiple days for your chain to be included)
</span>
</li>
</ol>
</PopoverContent>
</Popover>
</div>
<div className="mt-auto">
{chainId === undefined ? (
<Button className="w-fit" disabled>
Continue <ArrowRightIcon className="ml-2 w-4 h-4" />
</Button>
) : (
<Button asChild className="w-fit">
<Link
href={`/team/${team_slug}/~/infrastructure/deploy/${chainId}`}
>
Continue <ArrowRightIcon className="ml-2 w-4 h-4" />
</Link>
</Button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,6 @@
import { parseAsString, parseAsStringEnum } from "nuqs/server";
export const searchParams = {
addons: parseAsString.withDefault(""),
freq: parseAsStringEnum(["monthly", "annual"]).withDefault("monthly"),
};
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { getTeamBySlug } from "@/api/team";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{
team_slug: string;
}>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
if (!team) {
redirect("/team");
}
return <div className="container py-12">{props.children}</div>;
}
@@ -85,10 +85,7 @@ export function InviteSection(props: {
let bottomSection: React.ReactNode = null;
const maxAllowedInvitesAtOnce = 10;
// invites are enabled if user has edit permission and team plan is not "free"
const inviteEnabled =
teamPlan !== "free" &&
teamPlan !== "starter" &&
props.userHasEditPermission;
const inviteEnabled = teamPlan !== "free" && props.userHasEditPermission;
const form = useForm<InviteFormValues>({
defaultValues: {
@@ -111,7 +108,7 @@ export function InviteSection(props: {
},
});
if (teamPlan === "free" || teamPlan === "starter") {
if (teamPlan === "free") {
bottomSection = (
<div className="lg:px6 flex items-center justify-between gap-4 border-border border-t px-4 py-4">
<p className="text-muted-foreground text-sm">
@@ -1,6 +1,6 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "../../../../../../../@/components/ui/tabs";
import { TabPathLinks } from "@/components/ui/tabs";
export default async function Layout(props: {
children: React.ReactNode;
@@ -459,7 +459,7 @@ function ProjectFilter(props: {
renderOption={(option) => {
const project = props.projects.find((p) => p.id === option.value);
if (!project) {
return <></>;
return null;
}
return (
<div className="flex items-center gap-2">
@@ -7,6 +7,7 @@ import {
CoinsIcon,
HomeIcon,
LockIcon,
RssIcon,
SettingsIcon,
WalletIcon,
} from "lucide-react";
@@ -103,6 +104,11 @@ export function ProjectSidebarLayout(props: {
icon: SmartAccountIcon,
label: "Account Abstraction",
},
{
href: `${layoutPath}/rpc`,
icon: RssIcon,
label: "RPC",
},
{
href: `${layoutPath}/vault`,
icon: LockIcon,
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import type { RpcMethodStats } from "@/api/analytics";
import { BadgeContainer } from "@/storybook/utils";
import type { RpcMethodStats } from "@/types/analytics";
import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI";
const meta = {
@@ -7,6 +7,7 @@ import {
BarChart as RechartsBarChart,
XAxis,
} from "recharts";
import type { RpcMethodStats } from "@/api/analytics";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
type ChartConfig,
@@ -14,7 +15,6 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { RpcMethodStats } from "@/types/analytics";
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
export function RpcMethodBarChartCardUI({
@@ -17,50 +17,48 @@ export const CircleCredentialFields: React.FC<CircleCredentialFieldsProps> = ({
const entitySecretId = useId();
return (
<>
<FormFieldSetup
errorMessage={
form.getFieldState("entitySecret", form.formState).error?.message
<FormFieldSetup
errorMessage={
form.getFieldState("entitySecret", form.formState).error?.message
}
helperText={
<>
Entity Secret is a 32-byte private key designed to secure your
Developer-Controlled wallets{" "}
<Link
className="text-link-foreground hover:text-foreground"
href="https://developers.circle.com/w3s/entity-secret-management"
rel="noopener noreferrer"
target="_blank"
>
Learn more about entity secret management
</Link>
</>
}
htmlFor={entitySecretId}
isRequired={!isUpdate}
label="Entity Secret"
tooltip={null}
>
<Input
className="bg-card"
id={entitySecretId}
placeholder={
isUpdate
? "Leave empty to keep existing secret"
: "Your Circle entity secret"
}
helperText={
<>
Entity Secret is a 32-byte private key designed to secure your
Developer-Controlled wallets{" "}
<Link
className="text-link-foreground hover:text-foreground"
href="https://developers.circle.com/w3s/entity-secret-management"
rel="noopener noreferrer"
target="_blank"
>
Learn more about entity secret management
</Link>
</>
}
htmlFor={entitySecretId}
isRequired={!isUpdate}
label="Entity Secret"
tooltip={null}
>
<Input
className="bg-card"
id={entitySecretId}
placeholder={
isUpdate
? "Leave empty to keep existing secret"
: "Your Circle entity secret"
}
type="password"
{...form.register("entitySecret", {
pattern: {
message:
"Entity secret must be a 32-byte hex string (64 characters)",
value: /^([0-9a-fA-F]{64})?$/,
},
required: !isUpdate,
setValueAs: (value: string) => (value === "" ? undefined : value),
})}
/>
</FormFieldSetup>
</>
type="password"
{...form.register("entitySecret", {
pattern: {
message:
"Entity secret must be a 32-byte hex string (64 characters)",
value: /^([0-9a-fA-F]{64})?$/,
},
required: !isUpdate,
setValueAs: (value: string) => (value === "" ? undefined : value),
})}
/>
</FormFieldSetup>
);
};

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