diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 6ae0e9e9fb6..7ab9fc2b4a6 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -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
diff --git a/apps/dashboard/.storybook/main.ts b/apps/dashboard/.storybook/main.ts
index d11f0b46808..33bed7699d2 100644
--- a/apps/dashboard/.storybook/main.ts
+++ b/apps/dashboard/.storybook/main.ts
@@ -9,13 +9,15 @@ function getAbsolutePath(value: string): string {
return dirname(require.resolve(join(value, "package.json")));
}
const config: StorybookConfig = {
- stories: ["../src/**/*.stories.tsx"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-docs"),
],
+ features: {
+ experimentalRSC: true,
+ },
framework: {
name: getAbsolutePath("@storybook/nextjs"),
options: {},
@@ -26,8 +28,6 @@ const config: StorybookConfig = {
},
},
staticDirs: ["../public"],
- features: {
- experimentalRSC: true,
- },
+ stories: ["../src/**/*.stories.tsx"],
};
export default config;
diff --git a/apps/dashboard/.storybook/preview.tsx b/apps/dashboard/.storybook/preview.tsx
index 7b1658d6275..0927fa3f863 100644
--- a/apps/dashboard/.storybook/preview.tsx
+++ b/apps/dashboard/.storybook/preview.tsx
@@ -2,11 +2,10 @@ import type { Preview } from "@storybook/nextjs";
import "../src/global.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MoonIcon, SunIcon } from "lucide-react";
-import { ThemeProvider, useTheme } from "next-themes";
import { Inter as interFont } from "next/font/google";
+import { ThemeProvider, useTheme } from "next-themes";
// biome-ignore lint/style/useImportType:
-import React from "react";
-import { useEffect } from "react";
+import React, { useEffect } from "react";
import { Toaster } from "sonner";
import { Button } from "../src/@/components/ui/button";
@@ -18,45 +17,33 @@ const fontSans = interFont({
});
const customViewports = {
- xs: {
- // Regular sized phones (iphone 15 / 15 pro)
- name: "iPhone",
- styles: {
- width: "390px",
- height: "844px",
- },
- },
sm: {
// Larger phones (iphone 15 plus / 15 pro max)
name: "iPhone Plus",
styles: {
- width: "430px",
height: "932px",
+ width: "430px",
},
},
-};
-
-const preview: Preview = {
- parameters: {
- viewport: {
- viewports: customViewports,
- },
- controls: {
- matchers: {
- color: /(background|color)$/i,
- date: /Date$/i,
- },
+ xs: {
+ // Regular sized phones (iphone 15 / 15 pro)
+ name: "iPhone",
+ styles: {
+ height: "844px",
+ width: "390px",
},
},
+};
+const preview: Preview = {
decorators: [
(Story) => {
return (
@@ -65,13 +52,22 @@ const preview: Preview = {
);
},
],
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ viewport: {
+ viewports: customViewports,
+ },
+ },
};
export default preview;
-function StoryLayout(props: {
- children: React.ReactNode;
-}) {
+function StoryLayout(props: { children: React.ReactNode }) {
const { setTheme, theme } = useTheme();
useEffect(() => {
@@ -83,10 +79,10 @@ function StoryLayout(props: {
setTheme(theme === "dark" ? "light" : "dark")}
size="sm"
variant="outline"
- className="h-auto w-auto shrink-0 rounded-full p-2"
>
{theme === "dark" ? (
diff --git a/apps/dashboard/biome.json b/apps/dashboard/biome.json
index cec0f72abd0..f9869db792f 100644
--- a/apps/dashboard/biome.json
+++ b/apps/dashboard/biome.json
@@ -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": "//"
}
diff --git a/apps/dashboard/framer-rewrites.js b/apps/dashboard/framer-rewrites.js
index a70a4a21f41..9016192b8ce 100644
--- a/apps/dashboard/framer-rewrites.js
+++ b/apps/dashboard/framer-rewrites.js
@@ -14,12 +14,15 @@ module.exports = [
"/in-app-wallets",
"/transactions",
// -- end build category
- // -- storage
+
+ // -- scale category
+ "/rpc",
+ "/insight",
"/storage",
+ // -- end scale category
+
// -- nebula
"/nebula",
- // --insight
- "/insight",
// -- contracts
"/contracts",
"/contracts/modular-contracts",
diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts
index 4baa4b86582..89c24b9a504 100644
--- a/apps/dashboard/next.config.ts
+++ b/apps/dashboard/next.config.ts
@@ -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,
),
);
}
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index b0f7508b53f..b777164cdf9 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -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,29 @@
"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",
+ "posthog-node": "^5.3.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 +72,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 +82,19 @@
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
- "zod": "3.25.67"
+ "zod": "3.25.75"
},
"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 +112,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"
},
diff --git a/apps/dashboard/public/assets/examples/example.json b/apps/dashboard/public/assets/examples/example.json
index c1e21a9ef18..a1eedde063e 100644
--- a/apps/dashboard/public/assets/examples/example.json
+++ b/apps/dashboard/public/assets/examples/example.json
@@ -1,8 +1,5 @@
[
{
- "name": "Your Collection #1",
- "description": "Remember to replace this description",
- "image": "ipfs://NewUriToReplace/1.png",
"attributes": [
{
"trait_type": "Background",
@@ -32,12 +29,12 @@
"trait_type": "Top lid",
"value": "High"
}
- ]
+ ],
+ "description": "Remember to replace this description",
+ "image": "ipfs://NewUriToReplace/1.png",
+ "name": "Your Collection #1"
},
{
- "name": "Your Collection #2",
- "description": "Remember to replace this description",
- "image": "ipfs://NewUriToReplace/2.png",
"attributes": [
{
"trait_type": "Background",
@@ -67,6 +64,9 @@
"trait_type": "Top lid",
"value": "Middle"
}
- ]
+ ],
+ "description": "Remember to replace this description",
+ "image": "ipfs://NewUriToReplace/2.png",
+ "name": "Your Collection #2"
}
]
diff --git a/apps/dashboard/redirects.js b/apps/dashboard/redirects.js
index 2ff384ca960..d6c1d7d9a6b 100644
--- a/apps/dashboard/redirects.js
+++ b/apps/dashboard/redirects.js
@@ -327,13 +327,6 @@ async function redirects() {
permanent: false,
source: "/solutions/chains",
},
- // redirect /rpc to portal
- {
- destination:
- "https://portal.thirdweb.com/infrastructure/rpc-edge/overview",
- permanent: false,
- source: "/rpc-edge",
- },
// redirect /sdk to portal
{
destination: "https://portal.thirdweb.com/connect/blockchain-api",
@@ -442,6 +435,11 @@ async function redirects() {
permanent: false,
source: "/engine",
},
+ {
+ destination: "/rpc",
+ permanent: false,
+ source: "/rpc-edge",
+ },
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
diff --git a/apps/dashboard/src/@/actions/billing.ts b/apps/dashboard/src/@/actions/billing.ts
index 7860f429e7d..1abca004b60 100644
--- a/apps/dashboard/src/@/actions/billing.ts
+++ b/apps/dashboard/src/@/actions/billing.ts
@@ -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;
+}
diff --git a/apps/dashboard/src/@/analytics/posthog-server.ts b/apps/dashboard/src/@/analytics/posthog-server.ts
new file mode 100644
index 00000000000..e53cecd1b0a
--- /dev/null
+++ b/apps/dashboard/src/@/analytics/posthog-server.ts
@@ -0,0 +1,40 @@
+import "server-only";
+import { PostHog } from "posthog-node";
+
+let posthogServer: PostHog | null = null;
+
+function getPostHogServer(): PostHog | null {
+ if (!posthogServer && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
+ posthogServer = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
+ host: "https://us.i.posthog.com",
+ });
+ }
+ return posthogServer;
+}
+
+/**
+ * Check if a feature flag is enabled for a specific user
+ * @param flagKey - The feature flag key
+ * @param userEmail - The user's email address for filtering
+ */
+export async function isFeatureFlagEnabled(
+ flagKey: string,
+ userEmail?: string,
+): Promise {
+ try {
+ const client = getPostHogServer();
+ if (client && userEmail) {
+ const isEnabled = await client.isFeatureEnabled(flagKey, userEmail, {
+ personProperties: {
+ email: userEmail,
+ },
+ });
+ if (isEnabled !== undefined) {
+ return isEnabled;
+ }
+ }
+ } catch (error) {
+ console.error(`Error checking feature flag ${flagKey}:`, error);
+ }
+ return false;
+}
diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts
index 022cc5fcaac..71a514d27d9 100644
--- a/apps/dashboard/src/@/analytics/report.ts
+++ b/apps/dashboard/src/@/analytics/report.ts
@@ -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;
+};
+
+/**
+ * ### 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);
+}
diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts
index 44ea3c8886d..90daeebea72 100644
--- a/apps/dashboard/src/@/api/analytics.ts
+++ b/apps/dashboard/src/@/api/analytics.ts
@@ -6,18 +6,54 @@ import type {
EcosystemWalletStats,
EngineCloudStats,
InAppWalletStats,
- RpcMethodStats,
TransactionStats,
UniversalBridgeStats,
UniversalBridgeWalletStats,
UserOpStats,
WalletStats,
WalletUserStats,
+ WebhookLatencyStats,
+ WebhookRequestStats,
WebhookSummaryStats,
} from "@/types/analytics";
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,
@@ -77,6 +113,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;
}
@@ -226,6 +265,26 @@ export async function getRpcMethodUsage(
return json.data as RpcMethodStats[];
}
+export async function getRpcUsageByType(
+ params: AnalyticsQueryParams,
+): Promise {
+ 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 {
@@ -426,44 +485,144 @@ export async function getEngineCloudMethodUsage(
return json.data as EngineCloudStats[];
}
-export async function getWebhookMetrics(params: {
- teamId: string;
- projectId: string;
- webhookId: string;
- period?: "day" | "week" | "month" | "year" | "all";
- from?: Date;
- to?: Date;
-}): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
- const searchParams = new URLSearchParams();
-
- // Required params
- searchParams.append("teamId", params.teamId);
- searchParams.append("projectId", params.projectId);
+export async function getWebhookSummary(
+ params: AnalyticsQueryParams & { webhookId: string },
+): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
+ const searchParams = buildSearchParams(params);
searchParams.append("webhookId", params.webhookId);
- // Optional params
- if (params.period) {
- searchParams.append("period", params.period);
+ const res = await fetchAnalytics(
+ `v2/webhook/summary?${searchParams.toString()}`,
+ );
+ if (!res.ok) {
+ const reason = await res.text();
+ return { error: reason };
}
- if (params.from) {
- searchParams.append("from", params.from.toISOString());
+
+ return (await res.json()) as { data: WebhookSummaryStats[] };
+}
+
+export async function getWebhookRequests(
+ params: AnalyticsQueryParams & { webhookId?: string },
+): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
+ const searchParams = buildSearchParams(params);
+ if (params.webhookId) {
+ searchParams.append("webhookId", params.webhookId);
}
- if (params.to) {
- searchParams.append("to", params.to.toISOString());
+
+ const res = await fetchAnalytics(
+ `v2/webhook/requests?${searchParams.toString()}`,
+ );
+ if (!res.ok) {
+ const reason = await res.text();
+ return { error: reason };
+ }
+
+ return (await res.json()) as { data: WebhookRequestStats[] };
+}
+
+export async function getWebhookLatency(
+ params: AnalyticsQueryParams & { webhookId?: string },
+): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
+ const searchParams = buildSearchParams(params);
+ if (params.webhookId) {
+ searchParams.append("webhookId", params.webhookId);
+ }
+ const res = await fetchAnalytics(
+ `v2/webhook/latency?${searchParams.toString()}`,
+ );
+ if (!res.ok) {
+ const reason = await res.text();
+ return { error: reason };
}
+ return (await res.json()) as { data: WebhookLatencyStats[] };
+}
+
+export async function getInsightChainUsage(
+ params: AnalyticsQueryParams,
+): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
+ const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
- `v2/webhook/summary?${searchParams.toString()}`,
+ `v2/insight/usage/by-chain?${searchParams.toString()}`,
{
method: "GET",
},
);
- if (!res.ok) {
+ if (res?.status !== 200) {
const reason = await res?.text();
- return { error: reason };
+ const errMsg = `Failed to fetch Insight chain usage: ${res?.status} - ${res.statusText} - ${reason}`;
+ console.error(errMsg);
+ return { errorMessage: errMsg };
}
- return (await res.json()) as {
- data: WebhookSummaryStats[];
- };
+
+ 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[] };
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/types.ts b/apps/dashboard/src/@/api/ecosystems.ts
similarity index 59%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/types.ts
rename to apps/dashboard/src/@/api/ecosystems.ts
index 5db9b603617..1f4793d004f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/types.ts
+++ b/apps/dashboard/src/@/api/ecosystems.ts
@@ -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;
diff --git a/apps/dashboard/src/@/api/team-subscription.ts b/apps/dashboard/src/@/api/team-subscription.ts
index 05858f9acef..ad183e8fe33 100644
--- a/apps/dashboard/src/@/api/team-subscription.ts
+++ b/apps/dashboard/src/@/api/team-subscription.ts
@@ -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 & {
+ 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
+ );
+}
diff --git a/apps/dashboard/src/@/api/webhook-configs.ts b/apps/dashboard/src/@/api/webhook-configs.ts
index 9aa7c56475c..0a5f3454808 100644
--- a/apps/dashboard/src/@/api/webhook-configs.ts
+++ b/apps/dashboard/src/@/api/webhook-configs.ts
@@ -24,10 +24,16 @@ export interface WebhookConfig {
}[];
}
-interface WebhookConfigsResponse {
- data: WebhookConfig[];
- error?: string;
-}
+type WebhookConfigsResponse =
+ | {
+ data: WebhookConfig[];
+ status: "success";
+ }
+ | {
+ body: string;
+ reason: string;
+ status: "error";
+ };
interface CreateWebhookConfigRequest {
topicIds: string[];
@@ -36,10 +42,16 @@ interface CreateWebhookConfigRequest {
isPaused?: boolean;
}
-interface CreateWebhookConfigResponse {
- data: WebhookConfig;
- error?: string;
-}
+type CreateWebhookConfigResponse =
+ | {
+ data: WebhookConfig;
+ status: "success";
+ }
+ | {
+ body: string;
+ reason: string;
+ status: "error";
+ };
export interface Topic {
id: string;
@@ -50,10 +62,16 @@ export interface Topic {
deletedAt: string | null;
}
-interface TopicsResponse {
- data: Topic[];
- error?: string;
-}
+type TopicsResponse =
+ | {
+ data: Topic[];
+ status: "success";
+ }
+ | {
+ body: string;
+ reason: string;
+ status: "error";
+ };
interface UpdateWebhookConfigRequest {
destinationUrl?: string;
@@ -62,15 +80,27 @@ interface UpdateWebhookConfigRequest {
isPaused?: boolean;
}
-interface UpdateWebhookConfigResponse {
- data: WebhookConfig;
- error?: string;
-}
+type UpdateWebhookConfigResponse =
+ | {
+ data: WebhookConfig;
+ status: "success";
+ }
+ | {
+ body: string;
+ reason: string;
+ status: "error";
+ };
-interface DeleteWebhookConfigResponse {
- data: WebhookConfig;
- error?: string;
-}
+type DeleteWebhookConfigResponse =
+ | {
+ data: WebhookConfig;
+ status: "success";
+ }
+ | {
+ body: string;
+ reason: string;
+ status: "error";
+ };
export async function getWebhookConfigs(props: {
teamIdOrSlug: string;
@@ -80,8 +110,9 @@ export async function getWebhookConfigs(props: {
if (!authToken) {
return {
- data: [],
- error: "Authentication required",
+ body: "Authentication required",
+ reason: "no_auth_token",
+ status: "error",
};
}
@@ -97,17 +128,18 @@ export async function getWebhookConfigs(props: {
);
if (!response.ok) {
- const errorText = await response.text();
+ const body = await response.text();
return {
- data: [],
- error: `Failed to fetch webhook configs: ${errorText}`,
+ body,
+ reason: "unknown",
+ status: "error",
};
}
const result = await response.json();
return {
data: result.data,
- error: undefined,
+ status: "success",
};
}
@@ -120,8 +152,9 @@ export async function createWebhookConfig(props: {
if (!authToken) {
return {
- data: {} as WebhookConfig,
- error: "Authentication required",
+ body: "Authentication required",
+ reason: "no_auth_token",
+ status: "error",
};
}
@@ -138,17 +171,18 @@ export async function createWebhookConfig(props: {
);
if (!response.ok) {
- const errorText = await response.text();
+ const body = await response.text();
return {
- data: {} as WebhookConfig,
- error: `Failed to create webhook config: ${errorText}`,
+ body,
+ reason: "unknown",
+ status: "error",
};
}
const result = await response.json();
return {
data: result.data,
- error: undefined,
+ status: "success",
};
}
@@ -157,8 +191,9 @@ export async function getAvailableTopics(): Promise {
if (!authToken) {
return {
- data: [],
- error: "Authentication required",
+ body: "Authentication required",
+ reason: "no_auth_token",
+ status: "error",
};
}
@@ -174,17 +209,18 @@ export async function getAvailableTopics(): Promise {
);
if (!response.ok) {
- const errorText = await response.text();
+ const body = await response.text();
return {
- data: [],
- error: `Failed to fetch topics: ${errorText}`,
+ body,
+ reason: "unknown",
+ status: "error",
};
}
const result = await response.json();
return {
data: result.data,
- error: undefined,
+ status: "success",
};
}
@@ -198,8 +234,9 @@ export async function updateWebhookConfig(props: {
if (!authToken) {
return {
- data: {} as WebhookConfig,
- error: "Authentication required",
+ body: "Authentication required",
+ reason: "no_auth_token",
+ status: "error",
};
}
@@ -216,17 +253,18 @@ export async function updateWebhookConfig(props: {
);
if (!response.ok) {
- const errorText = await response.text();
+ const body = await response.text();
return {
- data: {} as WebhookConfig,
- error: `Failed to update webhook config: ${errorText}`,
+ body,
+ reason: "unknown",
+ status: "error",
};
}
const result = await response.json();
return {
data: result.data,
- error: undefined,
+ status: "success",
};
}
@@ -239,8 +277,9 @@ export async function deleteWebhookConfig(props: {
if (!authToken) {
return {
- data: {} as WebhookConfig,
- error: "Authentication required",
+ body: "Authentication required",
+ reason: "no_auth_token",
+ status: "error",
};
}
@@ -256,16 +295,17 @@ export async function deleteWebhookConfig(props: {
);
if (!response.ok) {
- const errorText = await response.text();
+ const body = await response.text();
return {
- data: {} as WebhookConfig,
- error: `Failed to delete webhook config: ${errorText}`,
+ body,
+ reason: "unknown",
+ status: "error",
};
}
const result = await response.json();
return {
data: result.data,
- error: undefined,
+ status: "success",
};
}
diff --git a/apps/dashboard/src/@/api/webhook-metrics.ts b/apps/dashboard/src/@/api/webhook-metrics.ts
deleted file mode 100644
index 8aff3b45218..00000000000
--- a/apps/dashboard/src/@/api/webhook-metrics.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-"use server";
-
-import { getWebhookMetrics } from "@/api/analytics";
-import type { WebhookSummaryStats } from "@/types/analytics";
-
-export async function getWebhookMetricsAction(params: {
- teamId: string;
- projectId: string;
- webhookId: string;
- period?: "day" | "week" | "month" | "year" | "all";
- from?: Date;
- to?: Date;
-}): Promise {
- const metrics = await getWebhookMetrics(params);
- return metrics[0] || null;
-}
diff --git a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx
index 48091acc635..e6507f45d6d 100644
--- a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx
+++ b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx
@@ -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) => {
diff --git a/apps/dashboard/src/@/components/blocks/TWTable.tsx b/apps/dashboard/src/@/components/blocks/TWTable.tsx
index 678451a4862..3414cccae50 100644
--- a/apps/dashboard/src/@/components/blocks/TWTable.tsx
+++ b/apps/dashboard/src/@/components/blocks/TWTable.tsx
@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
import {
@@ -164,7 +165,6 @@ export function TWTable(tableProps: TWTableProps) {
return (
void;
+ };
accentColor?: keyof typeof ACCENT;
icon?: React.ReactNode;
};
@@ -93,25 +99,41 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) {
-
-
+
+ {props.cta.text}
+ {props.cta.icon && {props.cta.icon} }
+
+
+ ) : props.cta && "onClick" in props.cta ? (
+
{props.cta.text}
{props.cta.icon && {props.cta.icon} }
-
-
+
+ ) : null}
);
diff --git a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx
index 17958a9b3f9..91e13fac006 100644
--- a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx
+++ b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx
@@ -279,7 +279,14 @@ function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) {
// subnav
if ("subMenu" in link) {
return (
-
+
);
}
diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx
index fa37f0f1149..33b6002e809 100644
--- a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx
+++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx
@@ -18,7 +18,7 @@ export type MultiStepState = {
}
| {
type: "error";
- message: React.ReactNode;
+ message: string;
};
label: string;
description?: string;
@@ -27,6 +27,10 @@ export type MultiStepState = {
export function MultiStepStatus(props: {
steps: MultiStepState[];
onRetry: (step: MultiStepState) => void;
+ renderError?: (
+ step: MultiStepState,
+ errorMessage: string,
+ ) => React.ReactNode;
}) {
return (
@@ -66,22 +70,24 @@ export function MultiStepStatus(props: {
)}
- {step.status.type === "error" && (
-
-
- {step.status.message}
-
-
props.onRetry(step)}
- size="sm"
- variant="destructive"
- >
-
- Retry
-
-
- )}
+ {step.status.type === "error"
+ ? props.renderError?.(step, step.status.message) || (
+
+
+ {step.status.message}
+
+
props.onRetry(step)}
+ size="sm"
+ variant="destructive"
+ >
+
+ Retry
+
+
+ )
+ : null}
))}
diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx
index 4d3e6c91b64..56349f9982b 100644
--- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx
+++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx
@@ -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"
>
diff --git a/apps/dashboard/src/@/components/blocks/wallet-address.tsx b/apps/dashboard/src/@/components/blocks/wallet-address.tsx
index 1703458514c..94c90837c8d 100644
--- a/apps/dashboard/src/@/components/blocks/wallet-address.tsx
+++ b/apps/dashboard/src/@/components/blocks/wallet-address.tsx
@@ -59,7 +59,11 @@ export function WalletAddress(props: {
// special case for zero address
if (address === ZERO_ADDRESS) {
- return {shortenedAddress} ;
+ return (
+
+ {shortenedAddress}
+
+ );
}
return (
diff --git a/apps/dashboard/src/@/components/connect-wallet/index.tsx b/apps/dashboard/src/@/components/connect-wallet/index.tsx
index b1246e03732..308f5826723 100644
--- a/apps/dashboard/src/@/components/connect-wallet/index.tsx
+++ b/apps/dashboard/src/@/components/connect-wallet/index.tsx
@@ -120,20 +120,18 @@ export const CustomConnectWallet = (props: {
if ((!isLoggedIn || !account) && loginRequired) {
return (
- <>
-
+
-
- Connect Wallet
-
-
- >
+ Connect Wallet
+
+
);
}
diff --git a/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx b/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx
index 5d5dc08e15e..dd01631d2a7 100644
--- a/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx
+++ b/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx
@@ -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 = ({
);
}
diff --git a/apps/dashboard/src/@/components/contract-components/tables/contract-table.tsx b/apps/dashboard/src/@/components/contract-components/tables/contract-table.tsx
index 27ba135d3fd..84f8e6e8f02 100644
--- a/apps/dashboard/src/@/components/contract-components/tables/contract-table.tsx
+++ b/apps/dashboard/src/@/components/contract-components/tables/contract-table.tsx
@@ -314,7 +314,7 @@ const NetworkFilterCell = React.memo(function NetworkFilterCell({
client: ThirdwebClient;
}) {
if (chainIds.length < 2) {
- return <> NETWORK >;
+ return "NETWORK";
}
return (
diff --git a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx
index 850504a947a..01b23707ae3 100644
--- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx
+++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx
@@ -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 = {
diff --git a/apps/dashboard/src/@/constants/thirdweb-client.server.ts b/apps/dashboard/src/@/constants/thirdweb-client.server.ts
index 4c804ba4707..a474d286a07 100644
--- a/apps/dashboard/src/@/constants/thirdweb-client.server.ts
+++ b/apps/dashboard/src/@/constants/thirdweb-client.server.ts
@@ -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,
});
diff --git a/apps/dashboard/src/@/constants/thirdweb.server.ts b/apps/dashboard/src/@/constants/thirdweb.server.ts
index cfa24f269bf..cd7aab19f8b 100644
--- a/apps/dashboard/src/@/constants/thirdweb.server.ts
+++ b/apps/dashboard/src/@/constants/thirdweb.server.ts
@@ -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,
});
}
diff --git a/apps/dashboard/src/@/icons/ChainIcon.tsx b/apps/dashboard/src/@/icons/ChainIcon.tsx
index ae845a75915..fef285a6635 100644
--- a/apps/dashboard/src/@/icons/ChainIcon.tsx
+++ b/apps/dashboard/src/@/icons/ChainIcon.tsx
@@ -30,7 +30,7 @@ export const ChainIconClient = ({
fallback={ }
key={resolvedSrc}
loading={restProps.loading || "lazy"}
- skeleton={
}
+ skeleton={ }
src={resolvedSrc}
/>
);
diff --git a/apps/dashboard/src/@/storybook/stubs.ts b/apps/dashboard/src/@/storybook/stubs.ts
index 0d02432b5a8..1308233de4e 100644
--- a/apps/dashboard/src/@/storybook/stubs.ts
+++ b/apps/dashboard/src/@/storybook/stubs.ts
@@ -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",
},
],
diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts
index f1f2e743920..70015ea7cd5 100644
--- a/apps/dashboard/src/@/types/analytics.ts
+++ b/apps/dashboard/src/@/types/analytics.ts
@@ -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;
@@ -72,6 +66,21 @@ export interface UniversalBridgeWalletStats {
developerFeeUsdCents: number;
}
+export interface WebhookRequestStats {
+ date: string;
+ webhookId: string;
+ httpStatusCode: number;
+ totalRequests: number;
+}
+
+export interface WebhookLatencyStats {
+ date: string;
+ webhookId: string;
+ p50LatencyMs: number;
+ p90LatencyMs: number;
+ p99LatencyMs: number;
+}
+
export interface WebhookSummaryStats {
webhookId: string;
totalRequests: number;
@@ -79,7 +88,7 @@ export interface WebhookSummaryStats {
errorRequests: number;
successRate: number;
avgLatencyMs: number;
- errorBreakdown: Record;
+ errorBreakdown: Record;
}
export interface AnalyticsQueryParams {
@@ -88,4 +97,5 @@ export interface AnalyticsQueryParams {
from?: Date;
to?: Date;
period?: "day" | "week" | "month" | "year" | "all";
+ limit?: number;
}
diff --git a/apps/dashboard/src/@/types/billing.ts b/apps/dashboard/src/@/types/billing.ts
index 992af032853..cfa6f45df3d 100644
--- a/apps/dashboard/src/@/types/billing.ts
+++ b/apps/dashboard/src/@/types/billing.ts
@@ -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";
diff --git a/apps/dashboard/src/@/utils/pricing.tsx b/apps/dashboard/src/@/utils/pricing.tsx
index c961ec4b867..400b38560d7 100644
--- a/apps/dashboard/src/@/utils/pricing.tsx
+++ b/apps/dashboard/src/@/utils/pricing.tsx
@@ -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,
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx
index 9e0627c9bb3..2cb32f6689a 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx
@@ -1,5 +1,7 @@
-import { defineChain } from "thirdweb";
-import { getChainMetadata } from "thirdweb/chains";
+import { ExternalLinkIcon } from "lucide-react";
+import Link from "next/link";
+import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
+import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
@@ -53,7 +55,7 @@ export async function RouteListCard({
return (
-
+
{resolvedOriginTokenIconUri ? (
@@ -80,32 +82,60 @@ export async function RouteListCard({
-
-
-
-
- {originTokenName === "ETH"
- ? originChain.nativeCurrency.name
- : originTokenName}
-
-
- {originChain.name}
-
-
-
-
- {destinationTokenName === "ETH"
- ? destinationChain.nativeCurrency.name
- : destinationTokenName}
-
-
- {destinationChain.name}
-
-
-
-
+
+
+
+
+ {originChain.name}
+
+
+
+
+
+
+ {destinationChain.name}
+
+
+
);
}
+
+const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
+
+function TokenName(props: {
+ tokenAddress: string;
+ tokenName: string;
+ chainMetadata: ChainMetadata;
+}) {
+ const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
+
+ if (isERC20) {
+ return (
+
+ {props.tokenName}
+
+
+ );
+ }
+
+ return (
+
+ {props.chainMetadata.nativeCurrency.name}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx
index 3eb209216a7..86a975c5eac 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx
@@ -1,5 +1,14 @@
-import { defineChain, getChainMetadata } from "thirdweb/chains";
-import { CopyTextButton } from "@/components/ui/CopyTextButton";
+import { ExternalLinkIcon } from "lucide-react";
+import Link from "next/link";
+import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
+import {
+ type ChainMetadata,
+ defineChain,
+ getChainMetadata,
+} from "thirdweb/chains";
+import { shortenAddress } from "thirdweb/utils";
+import { Img } from "@/components/blocks/Img";
+import { Button } from "@/components/ui/button";
import { TableCell, TableRow } from "@/components/ui/table";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
@@ -52,37 +61,14 @@ export async function RouteListRow({
]);
return (
-
+
-
-
- {resolvedOriginTokenIconUri ? (
- // For now we're using a normal img tag because the domain for these images is unknown
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- )}
- {originTokenSymbol && (
-
- )}
-
-
+
@@ -90,34 +76,12 @@ export async function RouteListRow({
-
-
- {resolvedDestinationTokenIconUri ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- )}
- {destinationTokenSymbol && (
-
- )}
-
-
+
@@ -126,3 +90,47 @@ export async function RouteListRow({
);
}
+
+const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
+
+function TokenInfo(props: {
+ tokenAddress: string;
+ tokenSymbol: string | undefined;
+ chainMetadata: ChainMetadata;
+ tokenIconUri: string | undefined;
+}) {
+ const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
+
+ return (
+
+ {props.tokenIconUri ? (
+
+ ) : (
+
+ )}
+ {isERC20 ? (
+
+
+ {props.tokenSymbol || shortenAddress(props.tokenAddress)}
+
+
+
+ ) : (
+
+ {props.chainMetadata.nativeCurrency.symbol}
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx
index 701da89643c..b8c2ce213a4 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx
@@ -86,22 +86,14 @@ export async function RoutesData(props: {
No Results found
) : props.activeView === "table" ? (
-
+
-
-
-
- Origin Token
-
-
- Origin Chain
-
-
- Destination Token
-
-
- Destination Chain
-
+
+
+ Origin Token
+ Origin Chain
+ Destination Token
+ Destination Chain
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx
index 95dfb9cfae2..3b7c24ac2e0 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx
@@ -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 && (
= ({
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={rowIndex}
onClick={() => setTokenRow(row.original)}
- // biome-ignore lint/a11y/useSemanticElements: FIXME
role="group"
style={{ cursor: "pointer" }}
>
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx
index 2f425175ea6..dd54ade9fc7 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx
@@ -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 = ({
}
return (
- <>
-
-
- {/* Show the reason why the form is disabled */}
- {!isAdmin && (
- Connect with admin wallet to edit claim conditions.
- )}
- {controlledFields.map((field, index) => {
- const dropType: DropType = field.snapshot
- ? field.maxClaimablePerWallet?.toString() === "0"
- ? "specific"
- : "overrides"
- : "any";
-
- const claimConditionType = getClaimConditionTypeFromPhase(field);
-
- 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",
- },
- );
-
- return (
-
- {
- setOpenSnapshotIndex(-1);
+
+
+ {/* Show the reason why the form is disabled */}
+ {!isAdmin && (
+ Connect with admin wallet to edit claim conditions.
+ )}
+ {controlledFields.map((field, index) => {
+ const dropType: DropType = field.snapshot
+ ? field.maxClaimablePerWallet?.toString() === "0"
+ ? "specific"
+ : "overrides"
+ : "any";
+
+ const claimConditionType = getClaimConditionTypeFromPhase(field);
+
+ 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",
+ },
+ );
+
+ return (
+
+ {
+ setOpenSnapshotIndex(-1);
+ }}
+ setSnapshot={(snapshot) =>
+ form.setValue(`phases.${index}.snapshot`, snapshot)
+ }
+ value={snapshotValue}
+ />
+
+
+ {
+ removePhase(index);
}}
- setSnapshot={(snapshot) =>
- form.setValue(`phases.${index}.snapshot`, snapshot)
- }
- value={snapshotValue}
/>
-
-
+
+ );
+ })}
+
+ {phases?.length === 0 && (
+
+
+
+
+ {isMultiPhase
+ ? "Missing Claim Phases"
+ : "Missing Claim Conditions"}
+
+
+ {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."}
+
+
+
+ )}
+
+
+
+
+
+ 0)
+ }
+ leftIcon={ }
+ size="sm"
+ variant={phases?.length > 0 ? "outline" : "solid"}
>
- {
- removePhase(index);
- }}
- />
-
-
- );
- })}
-
- {phases?.length === 0 && (
-
-
-
-
- {isMultiPhase
- ? "Missing Claim Phases"
- : "Missing Claim Conditions"}
-
-
- {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."}
-
-
-
- )}
-
-
-
-
-
- 0)
+ Add {isMultiPhase ? "Phase" : "Claim Conditions"}
+
+
+ {Object.keys(ClaimConditionTypeData).map((key) => {
+ const type = key as ClaimConditionType;
+
+ if (type === "custom") {
+ return null;
}
- leftIcon={ }
- size="sm"
- variant={phases?.length > 0 ? "outline" : "solid"}
- >
- Add {isMultiPhase ? "Phase" : "Claim Conditions"}
-
-
- {Object.keys(ClaimConditionTypeData).map((key) => {
- const type = key as ClaimConditionType;
-
- if (type === "custom") {
- return null;
- }
-
- return (
- {
- addPhase(type);
- // TODO: Automatically start editing the new phase after adding it
- }}
- >
-
- {ClaimConditionTypeData[type].name}
-
- {ClaimConditionTypeData[type].description}
-
- }
- />
-
-
- );
- })}
-
-
-
-
- {controlledFields.some((field) => field.fromSdk) && (
-
- )}
-
-
-
}>
-
- {(hasRemovedPhases || hasAddedPhases) && (
-
- You have unsaved changes
-
- )}
- {controlledFields.length > 0 ||
- hasRemovedPhases ||
- !isMultiPhase ? (
-
- {claimConditionsQuery.isPending
- ? "Saving Phases"
- : "Save Phases"}
-
- ) : null}
-
-
-
-
+ return (
+ {
+ addPhase(type);
+ // TODO: Automatically start editing the new phase after adding it
+ }}
+ >
+
+ {ClaimConditionTypeData[type].name}
+
+ {ClaimConditionTypeData[type].description}
+
+ }
+ />
+
+
+ );
+ })}
+
+
+
+
+ {controlledFields.some((field) => field.fromSdk) && (
+
+ )}
+
+
+
+
}>
+
+ {(hasRemovedPhases || hasAddedPhases) && (
+
+ You have unsaved changes
+
+ )}
+ {controlledFields.length > 0 ||
+ hasRemovedPhases ||
+ !isMultiPhase ? (
+
+ {claimConditionsQuery.isPending
+ ? "Saving Phases"
+ : "Save Phases"}
+
+ ) : null}
+
+
+
- >
+
);
};
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts
index c2102b9f139..e390eab95d5 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts
@@ -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({
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx
index db58bcc64cb..0440851b8ad 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx
@@ -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 = ({
}}
opacity={failedToLoad ? 0.3 : 1}
pointerEvents={failedToLoad ? "none" : "auto"}
- // biome-ignore lint/a11y/useSemanticElements: FIXME
role="group"
style={{ cursor: "pointer" }}
>
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx
index 9a4e67ed3b2..4b1a24a1464 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx
@@ -9,19 +9,23 @@ export async function getContractCreator(
contract: ThirdwebContract,
functionSelectors: string[],
) {
- if (isOwnerSupported(functionSelectors)) {
- return owner({
- contract,
- });
- }
+ try {
+ if (isOwnerSupported(functionSelectors)) {
+ return await owner({
+ contract,
+ });
+ }
- if (isGetRoleAdminSupported(functionSelectors)) {
- return getRoleMember({
- contract,
- index: BigInt(0),
- role: "admin",
- });
- }
+ if (isGetRoleAdminSupported(functionSelectors)) {
+ return await getRoleMember({
+ contract,
+ index: BigInt(0),
+ role: "admin",
+ });
+ }
- return null;
+ return null;
+ } catch {
+ return null;
+ }
}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx
index 8d2ac72f937..28bb90f2014 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx
@@ -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 */}
- {props.contractCreator && (
+ {props.contractCreator && props.contractCreator !== ZERO_ADDRESS && (
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx
index 6eec90fc0fe..29a25c98779 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx
@@ -43,94 +43,92 @@ export const TokenAirdropForm: React.FC = ({
);
return (
- <>
-
);
};
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx
index e73a1e0fa7d..0532102f79c 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx
@@ -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
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx
index d9d11c088f7..95b1ac200d6 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx
index dad810fd28e..bd17dc6536c 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx
@@ -182,19 +182,17 @@ function ContractTableRow(props: { row: Row }) {
const { row } = props;
const { key, ...rowProps } = row.getRowProps();
return (
- <>
-
- {row.cells.map((cell) => (
-
- {cell.render("Cell")}
-
- ))}
-
- >
+
+ {row.cells.map((cell) => (
+
+ {cell.render("Cell")}
+
+ ))}
+
);
}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx
index 33fa5734563..1939d42bcfd 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx
index c40d6423d44..403e99d931d 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx
@@ -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,
}),
]);
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx
index 96d1fbb0754..6da7c5e92e0 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx
@@ -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,
}),
]);
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx
index 37173a1b24c..9f1a86bae46 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx
@@ -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,
]);
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx
index 1144797cdb8..fdea3a49fbb 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx
@@ -21,37 +21,33 @@ export const SupportForm_SelectInput = (props: Props) => {
const { options, formLabel, name, required, promptText } = props;
return (
- <>
-
-
- {formLabel}
- {required && (
-
- •
-
- )}
-
+
+
+ {formLabel}
+ {required && (
+ •
+ )}
+
- {
- props.onValueChange(val);
- }}
- required={required}
- value={props.value}
- >
-
- {props.value}
-
-
- {options.map((option) => (
-
- {option}
-
- ))}
-
-
-
- >
+
{
+ props.onValueChange(val);
+ }}
+ required={required}
+ value={props.value}
+ >
+
+ {props.value}
+
+
+ {options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
);
};
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx
index 59420732905..32db19a1465 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx
@@ -27,34 +27,32 @@ export const SupportForm_TeamSelection = (props: Props) => {
const teamId = useId();
return (
- <>
-
-
- Select Team
- •
-
+
+
+ Select Team
+ •
+
- {
- props.onChange(selectedId);
- }}
- value={props.selectedTeamId}
- >
-
-
- {selectedTeamName}
-
-
-
- {props.teams.map((team) => (
-
- {team.name}
-
- ))}
-
-
-
- >
+
{
+ props.onChange(selectedId);
+ }}
+ value={props.selectedTeamId}
+ >
+
+
+ {selectedTeamName}
+
+
+
+ {props.teams.map((team) => (
+
+ {team.name}
+
+ ))}
+
+
+
);
};
diff --git a/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx
index 5da2e639ba0..645b4e8fd4c 100644
--- a/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx
+++ b/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx
@@ -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 && (
-
-
-
-
diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx
index 76af32a734b..bbd88fd1942 100644
--- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx
+++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx
@@ -87,8 +87,7 @@ export function InviteTeamMembersUI(props: {
client={props.client}
customCTASection={
- {(props.team.billingPlan === "free" ||
- props.team.billingPlan === "starter") && (
+ {props.team.billingPlan === "free" && (
{
diff --git a/apps/dashboard/src/app/(app)/project-showcase/[slug]/page.tsx b/apps/dashboard/src/app/(app)/project-showcase/[slug]/page.tsx
index f51d07d2d49..f8663a45d52 100644
--- a/apps/dashboard/src/app/(app)/project-showcase/[slug]/page.tsx
+++ b/apps/dashboard/src/app/(app)/project-showcase/[slug]/page.tsx
@@ -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")
diff --git a/apps/dashboard/src/app/(app)/project-showcase/page.tsx b/apps/dashboard/src/app/(app)/project-showcase/page.tsx
index e7a31432330..c134ffb9deb 100644
--- a/apps/dashboard/src/app/(app)/project-showcase/page.tsx
+++ b/apps/dashboard/src/app/(app)/project-showcase/page.tsx
@@ -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 ??
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx
index ce0e84d3184..d28df97442b 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx
@@ -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={[
{
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx
index 88810bcbf4d..02cf04df252 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx
@@ -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: {
a.chainId - b.chainId,
+ )}
ecosystems={ecosystems.map((ecosystem) => ({
name: ecosystem.name,
slug: ecosystem.slug,
@@ -90,20 +120,3 @@ export default async function TeamLayout(props: {
);
}
-
-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[];
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx
index 733259536e5..ac356034326 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx
@@ -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: {
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx
index a9300402db8..be107eeedf9 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx
index 22f9da796c7..fc8596a2ada 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx
@@ -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 & {
time: string; // human readable date
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx
index 80f30feac97..18beeb8a73b 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx
@@ -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),
]);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx
index 2eab1880cab..d8ce5386c2e 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx
@@ -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);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx
index a09416faeee..a5451019399 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx
index 1ddf351c36b..332cf2ea199 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx
@@ -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 (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx
index 921de8d3a61..c01d7404a12 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx
index c839ffb4628..c45a37c02e7 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx
index 3a0e2fa4d04..83a01b97aa5 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx
@@ -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) {
@@ -193,21 +232,23 @@ export function AuthOptionsForm({
let smartAccountOptions: Ecosystem["smartAccountOptions"] | null = null;
if (data.useSmartAccount) {
- let accountFactoryAddress: string;
- switch (data.accountFactoryType) {
- case "v0.6":
- accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_6;
- break;
- case "v0.7":
- accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_7;
- break;
- case "custom":
- if (!data.customAccountFactoryAddress) {
- toast.error("Please enter a custom account factory address");
- return;
- }
- accountFactoryAddress = data.customAccountFactoryAddress;
- break;
+ let accountFactoryAddress: string | undefined;
+ if (data.executionMode === "EIP4337") {
+ switch (data.accountFactoryType) {
+ case "v0.6":
+ accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_6;
+ break;
+ case "v0.7":
+ accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_7;
+ break;
+ case "custom":
+ if (!data.customAccountFactoryAddress) {
+ toast.error("Please enter a custom account factory address");
+ return;
+ }
+ accountFactoryAddress = data.customAccountFactoryAddress;
+ break;
+ }
}
smartAccountOptions = {
@@ -220,7 +261,7 @@ export function AuthOptionsForm({
updateEcosystem({
...ecosystem,
- authOptions: data.authOptions as (typeof authOptions)[number][],
+ authOptions: data.authOptions as AuthOption[],
customAuthOptions,
smartAccountOptions,
});
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx
index 4a626708e09..6273dcc84b9 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx
@@ -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({
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx
index 23e8a914761..87727417fa2 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx
index f49a92b5f0b..8c72fb5b111 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx
index 09ebd3196ec..81e6233265f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx
@@ -1,5 +1,5 @@
import type { ThirdwebClient } from "thirdweb";
-import type { Ecosystem } from "../../../../../types";
+import type { Ecosystem } from "@/api/ecosystems";
import {
AuthOptionsForm,
AuthOptionsFormSkeleton,
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx
index 6407febcb5e..462149db9cf 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx
index adf12a72f89..d359c1879f4 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx
@@ -1,4 +1,4 @@
-import type { Ecosystem } from "../../../../../types";
+import type { Ecosystem } from "@/api/ecosystems";
import {
IntegrationPermissionsToggle,
IntegrationPermissionsToggleSkeleton,
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx
index e65ac9ac787..d95866aa142 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx
@@ -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";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchEcosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchEcosystem.ts
deleted file mode 100644
index a01b9c5590f..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchEcosystem.ts
+++ /dev/null
@@ -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
{
- 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;
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts
index aa91df3eadb..44d10822864 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts
@@ -1,4 +1,4 @@
-import type { Ecosystem, Partner } from "../../../../types";
+import type { Ecosystem, Partner } from "@/api/ecosystems";
export async function fetchPartnerDetails(args: {
authToken: string;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts
index cbdbd94ac50..9784581e2bb 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts
@@ -1,4 +1,4 @@
-import type { Ecosystem, Partner } from "../../../../types";
+import type { Ecosystem, Partner } from "@/api/ecosystems";
/**
* Fetches partners for an ecosystem
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts
index 27bc0e0d739..ee308fc3dcb 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts
index 019934a7c11..e97ba222931 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts
index 2878eb42d6b..186f6d513e3 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts
@@ -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: {
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts
index 1e78500af1f..2973213dafe 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx
index 27d1ca9cb74..5d95622522d 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx
@@ -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({
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts
index f4f21bc3aa1..a53eec37ecc 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts
@@ -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,
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts
index d75b9721062..723d69b6c6e 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts
@@ -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({
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts
index 43b4609cd86..f9b24629c5e 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx
index a3858f9555d..e7b65f66286 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx
@@ -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}`);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystem.ts
deleted file mode 100644
index fc20152d2a8..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystem.ts
+++ /dev/null
@@ -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;
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystemList.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystemList.ts
deleted file mode 100644
index 94617169ff1..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystemList.ts
+++ /dev/null
@@ -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;
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/_components/service-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/_components/service-card.tsx
new file mode 100644
index 00000000000..fb85f10e96d
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/_components/service-card.tsx
@@ -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 (
+
+ {/* Header row with status and optional action */}
+
+
+
{title}
+
+ {status === "active"
+ ? "Active"
+ : status === "pending"
+ ? "Pending"
+ : "Inactive"}
+ {status === "pending" && }
+
+
+
+
+
+
+ );
+}
+
+// --- Helper Components ---
+
+function MetricPlaceholders({
+ status,
+ serviceTitle,
+}: {
+ status: ServiceStatus;
+ serviceTitle: string;
+}) {
+ const metrics = getMetricsForService(serviceTitle);
+
+ return (
+
+ {metrics.map((metric) => (
+
+
+ {metric.label}
+
+
+
+ {status === "active" ? (
+ Coming Soon
+ ) : status === "pending" ? (
+ Activation in progress.
+ ) : (
+ Activate service to view metrics.
+ )}
+
+
+
+ ))}
+
+ );
+}
+
+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 [];
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/page.tsx
new file mode 100644
index 00000000000..9aa249e8a35
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/page.tsx
@@ -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 (
+
+ {/* Chain header */}
+
+
+ Infrastructure for
+
+
+
+
+ {chain.icon && (
+
+ )}
+ {cleanChainName(chain.name)}
+
+
+
+ Chain ID
+ {chain.chainId}
+
+
+
+
+
+ {PRODUCTS.map((product) => {
+ const hasSku = chainSubscription.skus.includes(product.sku);
+
+ // Map sku to chain service key
+ const skuToServiceKey: Record
= {
+ "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 (
+
+ );
+ })}
+
+
+ {/* Subscription summary */}
+
+
+ {/* Left: header + info */}
+
+
+
Subscription details
+ {chainSubscription.isLegacy && (
+
+ Enterprise
+
+ This subscription is part of an enterprise agreement and
+ cannot be modified through the dashboard. Please contact
+ your account executive for any modifications.
+
+ }
+ >
+
+
+
+ )}
+
+
+
+
+ Renews on
+ {formattedRenewalDate}
+
+
+
+ Amount due
+ {formattedAmountDue}
+
+
+
+
+
+
+ );
+}
+
+function cleanChainName(chainName: string) {
+ return chainName.replace("Mainnet", "");
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/[chain_id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/[chain_id]/page.tsx
new file mode 100644
index 00000000000..9960caad2f2
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/[chain_id]/page.tsx
@@ -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 (
+
+
+
+ Deploy Infrastructure on
+
+
+
+
+ {chain.icon && (
+
+ )}
+ {cleanChainName(chain.name)}
+
+
+
+ Chain ID
+ {chain.chainId}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function cleanChainName(chainName: string) {
+ return chainName.replace("Mainnet", "");
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/_components/deploy-infrastructure-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/_components/deploy-infrastructure-form.client.tsx
new file mode 100644
index 00000000000..1fe930bb1f2
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/_components/deploy-infrastructure-form.client.tsx
@@ -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 = {
+ 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
+ ).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 (
+
+ {/* Left column: service selection + frequency */}
+
+
Select Services
+
+ {/* Required service */}
+
+ {}}
+ originalPrice={
+ isAnnual ? SERVICE_CONFIG.rpc.monthlyPrice * 12 : undefined
+ }
+ periodLabel={periodLabel}
+ price={pricePerService.rpc}
+ required
+ selected
+ />
+
+
+ {/* Optional add-ons */}
+
+
+
Add-ons
+ {bundleHint && (
+
{bundleHint}
+ )}
+
+
+ {/* Insight */}
+ {
+ 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 */}
+ {
+ 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}
+ />
+
+
+
+
+ {/* Right column: order summary */}
+
+
Order Summary
+
+ {selectedOrder.map((key) => (
+
+ {SERVICE_CONFIG[key].label}
+
+ {isAnnual && (
+
+ {formatUSD(SERVICE_CONFIG[key].monthlyPrice * 12)}
+
+ )}
+
+ {formatUSD(pricePerService[key])}
+ {periodLabel}
+
+
+
+ ))}
+
+
+ Subtotal
+
+ {formatUSD(subtotal)}
+ {periodLabel}
+
+
+ {bundleDiscount > 0 && (
+
+
+ Bundle Discount (
+ {Object.values(selectedServices).filter(Boolean).length === 2
+ ? "10%"
+ : "15%"}
+ off)
+
+ -{formatUSD(bundleDiscount)}
+
+ )}
+
+ {/* Billing Frequency Toggle */}
+
+ Pay annually & save 15%
+
+ setFrequency(checked ? "annual" : "monthly")
+ }
+ />
+
+
+ {/* Total Row */}
+
+
Total
+
+ {totalSavings > 0 && (
+
+ {formatUSD(originalTotal)}
+
+ )}
+
+ {formatUSD(total)} {periodLabel}
+
+
+
+
+
+ Proceed to Checkout
+
+ {!props.isOwner && (
+
+ Only team owners can deploy infrastructure.
+
+ )}
+
+
+
+ );
+}
+
+// --- 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 (
+ !disabled && onToggle()}
+ type="button"
+ >
+
+
+ {(() => {
+ const IconComp = getIcon(icon);
+ return ;
+ })()}
+ {label}
+ {required && Always Included }
+
+ {!disabled && (
+
+ {selected && }
+
+ )}
+
+
+ {description}
+
+
+ {originalPrice && (
+
+ {formatUSD(originalPrice)}
+
+ )}
+
+ {formatUSD(price)}
+ {periodLabel}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/page.tsx
new file mode 100644
index 00000000000..bcc7ddedc71
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/page.tsx
@@ -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//~/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(undefined);
+
+ const { team_slug } = useParams<{ team_slug: string }>();
+
+ return (
+
+
+
+ Deploy Infrastructure
+
+
+
+ {/* Header */}
+
+
Choose your Chain
+
+ 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.
+
+
+
+ {/* Chain selector */}
+
+
+ {/* Alternative paths hidden inside popover */}
+
+
+
+ Can't find your chain?
+
+
+
+
+
+ Option 1: Submit a PR to
+
+ ethereum-lists/chains
+ {" "}
+ to add your chain.{" "}
+
+ (automatically added on PR merge)
+
+
+
+ Option 2: Share your chain details via
+
+ this short form
+
+ .
+
+ (multiple days for your chain to be included)
+
+
+
+
+
+
+
+
+ {chainId === undefined ? (
+
+ Continue
+
+ ) : (
+
+
+ Continue
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/search-params.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/search-params.ts
new file mode 100644
index 00000000000..02dace3e9e6
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/search-params.ts
@@ -0,0 +1,6 @@
+import { parseAsString, parseAsStringEnum } from "nuqs/server";
+
+export const searchParams = {
+ addons: parseAsString.withDefault(""),
+ freq: parseAsStringEnum(["monthly", "annual"]).withDefault("monthly"),
+};
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/layout.tsx
new file mode 100644
index 00000000000..06730684257
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/layout.tsx
@@ -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 {props.children}
;
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx
index c8608aa95d1..99e99771fd7 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx
@@ -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({
defaultValues: {
@@ -111,7 +108,7 @@ export function InviteSection(props: {
},
});
- if (teamPlan === "free" || teamPlan === "starter") {
+ if (teamPlan === "free") {
bottomSection = (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx
index 5766dc73a04..1904215e5c3 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx
@@ -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;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx
index 1979044f62b..dd44175614a 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx
@@ -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 (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx
index 69c24ba4a42..1b8de7d71f8 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx
@@ -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,
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx
index 9d90c4569ec..0021be1fee5 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx
@@ -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 = {
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx
index 1b51463aaa4..0c43b5e2f08 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx
@@ -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({
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx
index 149dd46e4a4..b56e0bed5c9 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx
@@ -17,50 +17,48 @@ export const CircleCredentialFields: React.FC
= ({
const entitySecretId = useId();
return (
- <>
-
+ Entity Secret is a 32-byte private key designed to secure your
+ Developer-Controlled wallets{" "}
+
+ Learn more about entity secret management
+
+ >
+ }
+ htmlFor={entitySecretId}
+ isRequired={!isUpdate}
+ label="Entity Secret"
+ tooltip={null}
+ >
+
- Entity Secret is a 32-byte private key designed to secure your
- Developer-Controlled wallets{" "}
-
- Learn more about entity secret management
-
- >
- }
- htmlFor={entitySecretId}
- isRequired={!isUpdate}
- label="Entity Secret"
- tooltip={null}
- >
- (value === "" ? undefined : value),
- })}
- />
-
- >
+ 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),
+ })}
+ />
+
);
};
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/blueprint-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/blueprint-card.tsx
deleted file mode 100644
index 79d750da614..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/blueprint-card.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import {
- Code2Icon,
- DatabaseIcon,
- ExternalLinkIcon,
- ZapIcon,
-} from "lucide-react";
-import Link from "next/link";
-import { Button } from "@/components/ui/button";
-
-export function BlueprintCard() {
- const features = [
- {
- description: "RESTful endpoints for any application",
- icon: Code2Icon,
- title: "Easy-to-Use API",
- },
- {
- description:
- "No need to index blockchains yourself or manage infrastructure and RPC costs.",
- icon: DatabaseIcon,
- title: "Managed Infrastructure",
- },
- {
- description: "Access any transaction, event or token API data",
- icon: ZapIcon,
- title: "Lightning-Fast Queries",
- },
- ];
-
- return (
-
- {/* header */}
-
-
-
Blueprints
-
-
-
-
- Docs
-
-
-
-
-
-
- {/* Content */}
-
-
- Simple endpoints for querying rich blockchain data
-
-
- A blueprint is an API that provides access to on-chain data in a
- user-friendly format. No need for ABIs, decoding, RPC, or web3
- knowledge required to fetch blockchain data.
-
-
-
-
- {/* Features */}
-
- {features.map((feature) => (
-
-
-
-
-
-
{feature.title}
-
- {feature.description}
-
-
-
- ))}
-
-
-
- {/* Playground link */}
-
-
-
- Try Insight blueprints in the playground
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx
new file mode 100644
index 00000000000..2e905a0ca98
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx
@@ -0,0 +1,219 @@
+import "server-only";
+
+import { ActivityIcon, AlertCircleIcon, CloudAlertIcon } from "lucide-react";
+import { ResponsiveSuspense } from "responsive-rsc";
+import type { ThirdwebClient } from "thirdweb";
+import {
+ getInsightChainUsage,
+ getInsightEndpointUsage,
+ getInsightStatusCodeUsage,
+ getInsightUsage,
+} from "@/api/analytics";
+import type { Range } from "@/components/analytics/date-range-selector";
+import { StatCard } from "@/components/analytics/stat";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { InsightAnalyticsFilter } from "./InsightAnalyticsFilter";
+import { InsightFTUX } from "./insight-ftux";
+import { RequestsByStatusGraph } from "./RequestsByStatusGraph";
+import { TopInsightChainsTable } from "./TopChainsTable";
+import { TopInsightEndpointsTable } from "./TopEndpointsTable";
+
+// Error state component for analytics
+function AnalyticsErrorState({
+ title,
+ message,
+ className,
+}: {
+ title: string;
+ message: string;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export async function InsightAnalytics(props: {
+ projectClientId: string;
+ client: ThirdwebClient;
+ projectId: string;
+ teamId: string;
+ range: Range;
+ interval: "day" | "week";
+}) {
+ const { projectId, teamId, range, interval } = props;
+
+ const allTimeRequestsPromise = getInsightUsage({
+ from: range.from,
+ period: "all",
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ });
+ const chainsDataPromise = getInsightChainUsage({
+ from: range.from,
+ limit: 10,
+ period: "all",
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ });
+ const statusCodesDataPromise = getInsightStatusCodeUsage({
+ from: range.from,
+ period: interval,
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ });
+ const endpointsDataPromise = getInsightEndpointUsage({
+ from: range.from,
+ limit: 10,
+ period: "all",
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ });
+
+ const [allTimeRequestsData, statusCodesData, endpointsData, chainsData] =
+ await Promise.all([
+ allTimeRequestsPromise,
+ statusCodesDataPromise,
+ endpointsDataPromise,
+ chainsDataPromise,
+ ]);
+
+ const hasVolume =
+ "data" in allTimeRequestsData &&
+ allTimeRequestsData.data?.some((d) => d.totalRequests > 0);
+
+ const allTimeRequests =
+ "data" in allTimeRequestsData
+ ? allTimeRequestsData.data?.reduce(
+ (acc, curr) => acc + curr.totalRequests,
+ 0,
+ )
+ : 0;
+
+ let requestsInPeriod = 0;
+ let errorsInPeriod = 0;
+ if ("data" in statusCodesData) {
+ for (const request of statusCodesData.data) {
+ requestsInPeriod += request.totalRequests;
+ if (request.httpStatusCode >= 400) {
+ errorsInPeriod += request.totalRequests;
+ }
+ }
+ }
+ const errorRate = Number(
+ ((errorsInPeriod / (requestsInPeriod || 1)) * 100).toFixed(2),
+ );
+
+ if (!hasVolume) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ searchParamsUsed={["from", "to", "interval"]}
+ >
+
+
+
+ `${value}%`}
+ icon={CloudAlertIcon}
+ isPending={false}
+ label="Error rate"
+ value={errorRate}
+ />
+
+
+ {"errorMessage" in statusCodesData ? (
+
+ ) : (
+
+ )}
+
+
+
+ {"errorMessage" in endpointsData ? (
+
+ ) : (
+
+ )}
+
+ {"errorMessage" in chainsData ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+function GridWithSeparator(props: { children: React.ReactNode }) {
+ return (
+
+ {props.children}
+ {/* Desktop - horizontal middle */}
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalyticsFilter.tsx
new file mode 100644
index 00000000000..f62e5d43f90
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalyticsFilter.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import {
+ useResponsiveSearchParams,
+ useSetResponsiveSearchParams,
+} from "responsive-rsc";
+import { DateRangeSelector } from "@/components/analytics/date-range-selector";
+import { IntervalSelector } from "@/components/analytics/interval-selector";
+import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
+
+type SearchParams = {
+ from?: string;
+ to?: string;
+ interval?: "day" | "week";
+};
+
+export function InsightAnalyticsFilter() {
+ const responsiveSearchParams = useResponsiveSearchParams();
+ const setResponsiveSearchParams = useSetResponsiveSearchParams();
+
+ const { range, interval } = getFiltersFromSearchParams({
+ defaultRange: "last-30",
+ from: responsiveSearchParams.from,
+ interval: responsiveSearchParams.interval,
+ to: responsiveSearchParams.to,
+ });
+
+ return (
+
+ {
+ setResponsiveSearchParams((v: SearchParams) => {
+ const newParams = {
+ ...v,
+ from: normalizeTimeISOString(newRange.from),
+ to: normalizeTimeISOString(newRange.to),
+ };
+ return newParams;
+ });
+ }}
+ />
+
+ {
+ setResponsiveSearchParams((v: SearchParams) => {
+ const newParams = {
+ ...v,
+ interval: newInterval,
+ };
+ return newParams;
+ });
+ }}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/RequestsByStatusGraph.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/RequestsByStatusGraph.tsx
new file mode 100644
index 00000000000..1418b07043a
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/RequestsByStatusGraph.tsx
@@ -0,0 +1,121 @@
+"use client";
+import { format } from "date-fns";
+import { useMemo } from "react";
+import { shortenLargeNumber } from "thirdweb/utils";
+import type { InsightStatusCodeStats } from "@/api/analytics";
+import { EmptyChartState } from "@/components/analytics/empty-chart-state";
+import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
+import type { ChartConfig } from "@/components/ui/chart";
+
+type ChartData = Record
& {
+ time: string; // human readable date
+};
+const defaultLabel = 200;
+
+export function RequestsByStatusGraph(props: {
+ data: InsightStatusCodeStats[];
+ isPending: boolean;
+ title: string;
+ description: string;
+}) {
+ const topStatusCodesToShow = 10;
+
+ const { chartConfig, chartData } = useMemo(() => {
+ const _chartConfig: ChartConfig = {};
+ const _chartDataMap: Map = new Map();
+ const statusCodeToVolumeMap: Map = new Map();
+ // for each stat, add it in _chartDataMap
+ for (const stat of props.data) {
+ const chartData = _chartDataMap.get(stat.date);
+ const { httpStatusCode } = stat;
+
+ // if no data for current day - create new entry
+ if (!chartData && stat.totalRequests > 0) {
+ _chartDataMap.set(stat.date, {
+ time: stat.date,
+ [httpStatusCode || defaultLabel]: stat.totalRequests,
+ } as ChartData);
+ } else if (chartData) {
+ chartData[httpStatusCode || defaultLabel] =
+ (chartData[httpStatusCode || defaultLabel] || 0) + stat.totalRequests;
+ }
+
+ statusCodeToVolumeMap.set(
+ (httpStatusCode || defaultLabel).toString(),
+ stat.totalRequests +
+ (statusCodeToVolumeMap.get(
+ (httpStatusCode || defaultLabel).toString(),
+ ) || 0),
+ );
+ }
+
+ const statusCodesSorted = Array.from(statusCodeToVolumeMap.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map((w) => w[0]);
+
+ const statusCodesToShow = statusCodesSorted.slice(0, topStatusCodesToShow);
+ const statusCodesAsOther = statusCodesSorted.slice(topStatusCodesToShow);
+
+ // replace chainIdsToTagAsOther chainId with "other"
+ for (const data of _chartDataMap.values()) {
+ for (const statusCode in data) {
+ if (statusCodesAsOther.includes(statusCode)) {
+ data.others = (data.others || 0) + (data[statusCode] || 0);
+ delete data[statusCode];
+ }
+ }
+ }
+
+ statusCodesToShow.forEach((statusCode, i) => {
+ _chartConfig[statusCode] = {
+ color: `hsl(var(--chart-${(i % 10) + 1}))`,
+ label: statusCodesToShow[i],
+ };
+ });
+
+ if (statusCodesAsOther.length > 0) {
+ _chartConfig.others = {
+ color: "hsl(var(--muted-foreground))",
+ label: "Others",
+ };
+ }
+
+ return {
+ chartConfig: _chartConfig,
+ chartData: Array.from(_chartDataMap.values()).sort(
+ (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
+ ),
+ };
+ }, [props.data]);
+
+ return (
+
+
+ {props.title}
+
+
+ {props.description}
+
+
+ }
+ data={chartData}
+ emptyChartState={ }
+ hideLabel={false}
+ isPending={props.isPending}
+ showLegend
+ toolTipLabelFormatter={(_v, item) => {
+ if (Array.isArray(item)) {
+ const time = item[0].payload.time as number;
+ return format(new Date(time), "MMM d, yyyy");
+ }
+ return undefined;
+ }}
+ toolTipValueFormatter={(v) => shortenLargeNumber(v as number)}
+ variant="stacked"
+ />
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopChainsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopChainsTable.tsx
new file mode 100644
index 00000000000..79c661299ae
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopChainsTable.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { useMemo } from "react";
+import type { ThirdwebClient } from "thirdweb";
+import { shortenLargeNumber } from "thirdweb/utils";
+import type { InsightChainStats } from "@/api/analytics";
+import { SkeletonContainer } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { CardHeading } from "../../universal-bridge/components/common";
+
+export function TopInsightChainsTable(props: {
+ data: InsightChainStats[];
+ client: ThirdwebClient;
+}) {
+ const tableData = useMemo(() => {
+ return props.data.sort((a, b) => b.totalRequests - a.totalRequests);
+ }, [props.data]);
+ const isEmpty = useMemo(() => tableData.length === 0, [tableData]);
+
+ return (
+
+ {/* header */}
+
+ Top Chains
+
+
+
+
+
+
+
+ Chain ID
+ Requests
+
+
+
+ {tableData.map((chain, i) => {
+ return (
+
+ );
+ })}
+
+
+ {isEmpty && (
+
+ No data available
+
+ )}
+
+
+ );
+}
+
+function ChainTableRow(props: {
+ chain?: {
+ chainId: string;
+ totalRequests: number;
+ };
+ client: ThirdwebClient;
+ rowIndex: number;
+}) {
+ const delayAnim = {
+ animationDelay: `${props.rowIndex * 100}ms`,
+ };
+
+ return (
+
+
+ (
+
+ {v === "0" ? "Multichain" : v}
+
+ )}
+ skeletonData="..."
+ style={delayAnim}
+ />
+
+
+ {
+ return {shortenLargeNumber(v)}
;
+ }}
+ skeletonData={0}
+ style={delayAnim}
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopEndpointsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopEndpointsTable.tsx
new file mode 100644
index 00000000000..1ee0853f08b
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopEndpointsTable.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { useMemo } from "react";
+import type { ThirdwebClient } from "thirdweb";
+import { shortenLargeNumber } from "thirdweb/utils";
+import type { InsightEndpointStats } from "@/api/analytics";
+import { SkeletonContainer } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { CardHeading } from "../../universal-bridge/components/common";
+
+export function TopInsightEndpointsTable(props: {
+ data: InsightEndpointStats[];
+ client: ThirdwebClient;
+}) {
+ const tableData = useMemo(() => {
+ return props.data?.sort((a, b) => b.totalRequests - a.totalRequests);
+ }, [props.data]);
+ const isEmpty = useMemo(() => tableData.length === 0, [tableData]);
+
+ return (
+
+ {/* header */}
+
+ Top Endpoints
+
+
+
+
+
+
+
+ Endpoint
+ Requests
+
+
+
+ {tableData.map((endpoint, i) => {
+ return (
+
+ );
+ })}
+
+
+ {isEmpty && (
+
+ No data available
+
+ )}
+
+
+ );
+}
+
+function EndpointTableRow(props: {
+ endpoint?: {
+ endpoint: string;
+ totalRequests: number;
+ };
+ client: ThirdwebClient;
+ rowIndex: number;
+}) {
+ const delayAnim = {
+ animationDelay: `${props.rowIndex * 100}ms`,
+ };
+
+ return (
+
+
+ (
+
+ {v}
+
+ )}
+ skeletonData="..."
+ style={delayAnim}
+ />
+
+
+ {
+ return {shortenLargeNumber(v)}
;
+ }}
+ skeletonData={0}
+ style={delayAnim}
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/insight-ftux.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/insight-ftux.tsx
similarity index 91%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/insight-ftux.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/insight-ftux.tsx
index 12fc1cd34ce..22caabe269b 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/insight-ftux.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/insight-ftux.tsx
@@ -1,7 +1,7 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { isProd } from "@/constants/env-utils";
-import { ClientIDSection } from "../components/ProjectFTUX/ClientIDSection";
-import { WaitingForIntegrationCard } from "../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
+import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection";
+import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function InsightFTUX(props: { clientId: string }) {
return (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/layout.tsx
new file mode 100644
index 00000000000..6886c4b5bab
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/layout.tsx
@@ -0,0 +1,97 @@
+import { redirect } from "next/navigation";
+import { getProject } from "@/api/projects";
+import { UnderlineLink } from "@/components/ui/UnderlineLink";
+import { FooterLinksSection } from "../components/footer/FooterLinksSection";
+
+export default async function Layout(props: {
+ params: Promise<{
+ team_slug: string;
+ project_slug: string;
+ }>;
+ children: React.ReactNode;
+}) {
+ const params = await props.params;
+ const project = await getProject(params.team_slug, params.project_slug);
+
+ if (!project) {
+ redirect(`/team/${params.team_slug}`);
+ }
+
+ return (
+
+
+
+
+ Insight
+
+
+ APIs to retrieve blockchain data from any EVM chain, enrich it with
+ metadata, and transform it using custom logic.{" "}
+
+ Learn more
+
+
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+ );
+}
+
+function InsightFooter() {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx
index 3c55930a3d0..3e326952fa3 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx
@@ -1,109 +1,83 @@
-import { notFound } from "next/navigation";
-import { isProjectActive } from "@/api/analytics";
+import { loginRedirect } from "@app/login/loginRedirect";
+import { ArrowUpRightIcon } from "lucide-react";
+import { redirect } from "next/navigation";
+import { ResponsiveSearchParamsProvider } from "responsive-rsc";
+import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
-import { getTeamBySlug } from "@/api/team";
-import { FooterLinksSection } from "../components/footer/FooterLinksSection";
-import { BlueprintCard } from "./blueprint-card";
-import { InsightFTUX } from "./insight-ftux";
+import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
+import { getFiltersFromSearchParams } from "@/lib/time";
+import { InsightAnalytics } from "./components/InsightAnalytics";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
+ searchParams: Promise<{
+ from?: string | undefined | string[];
+ to?: string | undefined | string[];
+ interval?: string | undefined | string[];
+ }>;
}) {
- const params = await props.params;
+ const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
+
+ const project = await getProject(params.team_slug, params.project_slug);
- const [team, project] = await Promise.all([
- getTeamBySlug(params.team_slug),
- getProject(params.team_slug, params.project_slug),
- ]);
+ if (!authToken) {
+ loginRedirect(`/team/${params.team_slug}/${params.project_slug}/insight`);
+ }
- if (!team || !project) {
- notFound();
+ if (!project) {
+ redirect(`/team/${params.team_slug}`);
}
- const activeResponse = await isProjectActive({
- projectId: project.id,
- teamId: team.id,
+ const searchParams = await props.searchParams;
+ const { range, interval } = getFiltersFromSearchParams({
+ defaultRange: "last-30",
+ from: searchParams.from,
+ interval: searchParams.interval,
+ to: searchParams.to,
});
- const showFTUX = !activeResponse.insight;
+ const client = getClientThirdwebClient({
+ jwt: authToken,
+ teamId: project.teamId,
+ });
return (
-
- {/* header */}
-
-
-
- Insight
-
-
- APIs to retrieve blockchain data from any EVM chain, enrich it with
- metadata, and transform it using custom logic
-
-
-
-
-
+
+
+
-
- {showFTUX ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
+
+
+
+
+
+
Get Started with Insight
+
+ A cross-chain API for historic blockchain data.
+
+
+
+ Learn More
+
+
+
-
- );
-}
-
-function InsightFooter() {
- return (
-
+
);
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx
new file mode 100644
index 00000000000..a49f84b2fcd
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx
@@ -0,0 +1,132 @@
+"use client";
+import { useMemo, useState } from "react";
+import type { ThirdwebClient } from "thirdweb";
+import { shortenLargeNumber } from "thirdweb/utils";
+import type { RpcMethodStats } from "@/api/analytics";
+import { PaginationButtons } from "@/components/blocks/pagination-buttons";
+import { Card } from "@/components/ui/card";
+import { SkeletonContainer } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { CardHeading } from "../../universal-bridge/components/common";
+
+export function TopRPCMethodsTable(props: {
+ data: RpcMethodStats[];
+ client: ThirdwebClient;
+}) {
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 30;
+
+ const sortedData = useMemo(() => {
+ return props.data?.sort((a, b) => b.count - a.count) || [];
+ }, [props.data]);
+
+ const totalPages = useMemo(() => {
+ return Math.ceil(sortedData.length / itemsPerPage);
+ }, [sortedData.length]);
+
+ const tableData = useMemo(() => {
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ const endIndex = startIndex + itemsPerPage;
+ return sortedData.slice(startIndex, endIndex);
+ }, [sortedData, currentPage]);
+
+ const isEmpty = useMemo(() => sortedData.length === 0, [sortedData]);
+
+ return (
+
+ {/* header */}
+
+ Top EVM Methods Called
+
+
+
+
+
+
+
+ Method
+ Requests
+
+
+
+ {tableData.map((method, i) => {
+ return (
+
+ );
+ })}
+
+
+ {isEmpty && (
+
+ No data available
+
+ )}
+
+
+ {!isEmpty && totalPages > 1 && (
+
+ )}
+
+ );
+}
+
+function MethodTableRow(props: {
+ method?: {
+ evmMethod: string;
+ count: number;
+ };
+ client: ThirdwebClient;
+ rowIndex: number;
+}) {
+ const delayAnim = {
+ animationDelay: `${props.rowIndex * 100}ms`,
+ };
+
+ return (
+
+
+ (
+
+ {v}
+
+ )}
+ skeletonData="..."
+ style={delayAnim}
+ />
+
+
+ {
+ return {shortenLargeNumber(v)}
;
+ }}
+ skeletonData={0}
+ style={delayAnim}
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx
new file mode 100644
index 00000000000..c1964d2c2f9
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { format } from "date-fns";
+import { shortenLargeNumber } from "thirdweb/utils";
+import type { RpcUsageTypeStats } from "@/api/analytics";
+import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
+
+export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
+ return (
+
new Date(a.date).getTime() - new Date(b.date).getTime())
+ .reduce(
+ (acc, curr) => {
+ const existingEntry = acc.find((e) => e.time === curr.date);
+ if (existingEntry) {
+ existingEntry.requests += curr.count;
+ } else {
+ acc.push({
+ requests: curr.count,
+ time: curr.date,
+ });
+ }
+ return acc;
+ },
+ [] as { requests: number; time: string }[],
+ )}
+ header={{
+ description: "Requests over time.",
+ title: "RPC Requests",
+ }}
+ hideLabel={false}
+ isPending={false}
+ showLegend
+ toolTipLabelFormatter={(label) => {
+ return format(label, "MMM dd, HH:mm");
+ }}
+ toolTipValueFormatter={(value) => {
+ return shortenLargeNumber(value as number);
+ }}
+ xAxis={{
+ sameDay: true,
+ }}
+ yAxis
+ />
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx
new file mode 100644
index 00000000000..a3854c546f5
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx
@@ -0,0 +1,98 @@
+import { ActivityIcon } from "lucide-react";
+import { ResponsiveSuspense } from "responsive-rsc";
+import type { ThirdwebClient } from "thirdweb";
+import { getRpcMethodUsage, getRpcUsageByType } from "@/api/analytics";
+import type { Range } from "@/components/analytics/date-range-selector";
+import { StatCard } from "@/components/analytics/stat";
+import { Skeleton } from "@/components/ui/skeleton";
+import { TopRPCMethodsTable } from "./MethodsTable";
+import { RequestsGraph } from "./RequestsGraph";
+import { RpcAnalyticsFilter } from "./RpcAnalyticsFilter";
+import { RpcFTUX } from "./RpcFtux";
+
+export async function RPCAnalytics(props: {
+ projectClientId: string;
+ client: ThirdwebClient;
+ projectId: string;
+ teamId: string;
+ range: Range;
+ interval: "day" | "week";
+}) {
+ const { projectId, teamId, range, interval } = props;
+
+ // TODO: add requests by status code, but currently not performant enough
+ const allRequestsByUsageTypePromise = getRpcUsageByType({
+ from: range.from,
+ period: "all",
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ });
+ const requestsByUsageTypePromise = getRpcUsageByType({
+ from: range.from,
+ period: interval,
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ });
+ const evmMethodsPromise = getRpcMethodUsage({
+ from: range.from,
+ period: "all",
+ projectId: projectId,
+ teamId: teamId,
+ to: range.to,
+ }).catch((error) => {
+ console.error(error);
+ return [];
+ });
+
+ const [allUsageData, usageData, evmMethodsData] = await Promise.all([
+ allRequestsByUsageTypePromise,
+ requestsByUsageTypePromise,
+ evmMethodsPromise,
+ ]);
+
+ const totalRequests = allUsageData.reduce((acc, curr) => acc + curr.count, 0);
+
+ if (totalRequests < 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ searchParamsUsed={["from", "to", "interval"]}
+ >
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx
new file mode 100644
index 00000000000..2ebf01ba6c6
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import {
+ useResponsiveSearchParams,
+ useSetResponsiveSearchParams,
+} from "responsive-rsc";
+import { DateRangeSelector } from "@/components/analytics/date-range-selector";
+import { IntervalSelector } from "@/components/analytics/interval-selector";
+import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
+
+type SearchParams = {
+ from?: string;
+ to?: string;
+ interval?: "day" | "week";
+};
+
+export function RpcAnalyticsFilter() {
+ const responsiveSearchParams = useResponsiveSearchParams();
+ const setResponsiveSearchParams = useSetResponsiveSearchParams();
+
+ const { range, interval } = getFiltersFromSearchParams({
+ defaultRange: "last-30",
+ from: responsiveSearchParams.from,
+ interval: responsiveSearchParams.interval,
+ to: responsiveSearchParams.to,
+ });
+
+ return (
+
+ {
+ setResponsiveSearchParams((v: SearchParams) => {
+ const newParams = {
+ ...v,
+ from: normalizeTimeISOString(newRange.from),
+ to: normalizeTimeISOString(newRange.to),
+ };
+ return newParams;
+ });
+ }}
+ />
+
+ {
+ setResponsiveSearchParams((v: SearchParams) => {
+ const newParams = {
+ ...v,
+ interval: newInterval,
+ };
+ return newParams;
+ });
+ }}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx
new file mode 100644
index 00000000000..90d87f6bba7
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx
@@ -0,0 +1,104 @@
+import { CodeServer } from "@/components/ui/code/code.server";
+import { isProd } from "@/constants/env-utils";
+import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection";
+import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
+
+export function RpcFTUX(props: { clientId: string }) {
+ return (
+
+ ),
+ label: "JavaScript",
+ },
+ {
+ code: (
+
+ ),
+ label: "Python",
+ },
+ {
+ code: (
+
+ ),
+ label: "Curl",
+ },
+ ]}
+ ctas={[
+ {
+ href: "https://portal.thirdweb.com/rpc-edge",
+ label: "View Docs",
+ },
+ ]}
+ title="Start Using RPC"
+ >
+
+
+
+ );
+}
+
+const twDomain = isProd ? "thirdweb" : "thirdweb-dev";
+
+const jsCode = (clientId: string) => `\
+// Example: Get latest block number on Ethereum
+const res = await fetch("https://1.rpc.${twDomain}.com/${clientId}", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ method: "eth_blockNumber",
+ params: [],
+ id: 1,
+ }),
+});
+const data = await res.json();
+console.log("Latest block number:", parseInt(data.result, 16));
+`;
+
+const curlCode = (clientId: string) => `\
+# Example: Get latest block number on Ethereum
+curl -X POST "https://1.rpc.${twDomain}.com/${clientId}" \\
+ -H "Content-Type: application/json" \\
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "eth_blockNumber",
+ "params": [],
+ "id": 1
+ }'
+`;
+
+const pythonCode = (clientId: string) => `\
+# Example: Get latest block number on Ethereum
+import requests
+import json
+
+response = requests.post(
+ "https://1.rpc.${twDomain}.com/${clientId}",
+ headers={"Content-Type": "application/json"},
+ json={
+ "jsonrpc": "2.0",
+ "method": "eth_blockNumber",
+ "params": [],
+ "id": 1
+ }
+)
+data = response.json()
+print("Latest block number:", int(data["result"], 16))
+`;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx
new file mode 100644
index 00000000000..1fbfa67c9f2
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx
@@ -0,0 +1,46 @@
+import { redirect } from "next/navigation";
+import { getProject } from "@/api/projects";
+import { UnderlineLink } from "@/components/ui/UnderlineLink";
+
+export default async function Layout(props: {
+ params: Promise<{
+ team_slug: string;
+ project_slug: string;
+ }>;
+ children: React.ReactNode;
+}) {
+ const params = await props.params;
+ const project = await getProject(params.team_slug, params.project_slug);
+
+ if (!project) {
+ redirect(`/team/${params.team_slug}`);
+ }
+
+ return (
+
+
+
+
+ RPC
+
+
+ Remote Procedure Call (RPC) provides reliable access to querying
+ data and interacting with the blockchain through global edge RPCs.{" "}
+
+ Learn more
+
+
+
+
+
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx
new file mode 100644
index 00000000000..6730b6c1d22
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx
@@ -0,0 +1,61 @@
+import { loginRedirect } from "@app/login/loginRedirect";
+import { redirect } from "next/navigation";
+import { ResponsiveSearchParamsProvider } from "responsive-rsc";
+import { getAuthToken } from "@/api/auth-token";
+import { getProject } from "@/api/projects";
+import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
+import { getFiltersFromSearchParams } from "@/lib/time";
+import { RPCAnalytics } from "./components/RpcAnalytics";
+
+export default async function Page(props: {
+ params: Promise<{
+ team_slug: string;
+ project_slug: string;
+ }>;
+ searchParams: Promise<{
+ from?: string | undefined | string[];
+ to?: string | undefined | string[];
+ interval?: string | undefined | string[];
+ }>;
+}) {
+ const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
+
+ const project = await getProject(params.team_slug, params.project_slug);
+
+ if (!authToken) {
+ loginRedirect(`/team/${params.team_slug}/${params.project_slug}/rpc`);
+ }
+
+ if (!project) {
+ redirect(`/team/${params.team_slug}`);
+ }
+
+ const searchParams = await props.searchParams;
+ const { range, interval } = getFiltersFromSearchParams({
+ defaultRange: "last-30",
+ from: searchParams.from,
+ interval: searchParams.interval,
+ to: searchParams.to,
+ });
+
+ const client = getClientThirdwebClient({
+ jwt: authToken,
+ teamId: project.teamId,
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx
new file mode 100644
index 00000000000..bae8e63417d
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { ArrowRightIcon, RefreshCcwIcon } from "lucide-react";
+import Link from "next/link";
+import { useEffect, useRef, useState } from "react";
+import { apiServerProxy } from "@/actions/proxies";
+import { reportUpsellClicked, reportUpsellShown } from "@/analytics/report";
+import type { Team } from "@/api/team";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/Spinner/Spinner";
+import { ToolTipLabel } from "@/components/ui/tooltip";
+import { useStripeRedirectEvent } from "@/hooks/stripe/redirect-event";
+import { pollWithTimeout } from "@/utils/pollWithTimeout";
+import { tryCatch } from "@/utils/try-catch";
+
+export function StorageErrorPlanUpsell(props: {
+ teamSlug: string;
+ trackingCampaign: "create-coin" | "create-nft";
+ onRetry: () => void;
+}) {
+ const [isPlanUpdated, setIsPlanUpdated] = useState(false);
+ const [isPollingTeam, setIsPollingTeam] = useState(false);
+
+ useStripeRedirectEvent(async () => {
+ setIsPollingTeam(true);
+ await tryCatch(
+ pollWithTimeout({
+ shouldStop: async () => {
+ const team = await getTeam(props.teamSlug);
+ if (team.billingPlan !== "free") {
+ setIsPlanUpdated(true);
+ return true;
+ }
+ return false;
+ },
+ timeoutMs: 10000,
+ }),
+ );
+
+ setIsPollingTeam(false);
+ });
+
+ const isEventSent = useRef(false);
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (isEventSent.current) {
+ return;
+ }
+
+ isEventSent.current = true;
+ reportUpsellShown({
+ campaign: props.trackingCampaign,
+ content: "storage-limit",
+ sku: "plan:starter",
+ });
+ }, [props.trackingCampaign]);
+
+ return (
+
+ {isPlanUpdated ? (
+
+
Plan upgraded successfully
+
+
+
+ Retry
+
+
+
+ ) : (
+
+
+ You have reached the storage limit on the free plan
+
+
+ Upgrade now to unlock unlimited storage with any paid plan
+
+
+
+
+ {
+ reportUpsellClicked({
+ campaign: props.trackingCampaign,
+ content: "storage-limit",
+ sku: "plan:starter",
+ });
+ }}
+ target="_blank"
+ >
+ Upgrade Plan{" "}
+ {isPollingTeam ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ View Pricing
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+async function getTeam(teamSlug: string) {
+ const res = await apiServerProxy<{
+ result: Team;
+ }>({
+ method: "GET",
+ pathname: `/v1/teams/${teamSlug}`,
+ });
+
+ if (!res.ok) {
+ throw new Error(res.error);
+ }
+
+ return res.data.result;
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx
index a4cf72010b9..c3a40d3c18f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx
@@ -10,6 +10,7 @@ import {
} from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { reportAssetCreationStepConfigured } from "@/analytics/report";
+import type { Team } from "@/api/team";
import {
type CreateNFTCollectionFunctions,
type NFTCollectionInfoFormValues,
@@ -30,6 +31,7 @@ export function CreateNFTPageUI(props: {
onLaunchSuccess: () => void;
teamSlug: string;
projectSlug: string;
+ teamPlan: Team["billingPlan"];
}) {
const [step, setStep] =
useState("collection-info");
@@ -140,6 +142,7 @@ export function CreateNFTPageUI(props: {
setStep(nftCreationPages["sales-settings"]);
}}
projectSlug={props.projectSlug}
+ teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
values={{
collectionInfo: nftCollectionInfoForm.watch(),
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx
index ece2365437d..7db1b21e18b 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx
@@ -25,6 +25,7 @@ import {
reportAssetCreationFailed,
reportContractDeployed,
} from "@/analytics/report";
+import type { Team } from "@/api/team";
import { useAddContractToProject } from "@/hooks/project-contracts";
import { parseError } from "@/utils/errorParser";
import type { CreateNFTCollectionAllValues } from "./_common/form";
@@ -37,6 +38,7 @@ export function CreateNFTPage(props: {
projectSlug: string;
teamId: string;
projectId: string;
+ teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx
index 40acfb50e38..7010f4c0e39 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx
@@ -9,7 +9,11 @@ import Link from "next/link";
import { useMemo, useRef, useState } from "react";
import { defineChain, type ThirdwebClient } from "thirdweb";
import { TokenProvider, TokenSymbol, useActiveWallet } from "thirdweb/react";
-import { reportAssetCreationFailed } from "@/analytics/report";
+import {
+ reportAssetCreationFailed,
+ reportAssetCreationSuccessful,
+} from "@/analytics/report";
+import type { Team } from "@/api/team";
import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -28,6 +32,7 @@ import { parseError } from "@/utils/errorParser";
import { ChainOverview } from "../../_common/chain-overview";
import { FilePreview } from "../../_common/file-preview";
import { StepCard } from "../../_common/step-card";
+import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell";
import type {
CreateNFTCollectionAllValues,
CreateNFTCollectionFunctions,
@@ -49,6 +54,7 @@ export function LaunchNFT(props: {
onLaunchSuccess: () => void;
teamSlug: string;
projectSlug: string;
+ teamPlan: Team["billingPlan"];
}) {
const formValues = props.values;
const [steps, setSteps] = useState[]>([]);
@@ -222,6 +228,11 @@ export function LaunchNFT(props: {
}
}
+ reportAssetCreationSuccessful({
+ assetType: "nft",
+ contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
+ });
+
props.onLaunchSuccess();
batchesProcessedRef.current = 0;
}
@@ -304,7 +315,26 @@ export function LaunchNFT(props: {
)}
-
+ {
+ if (
+ props.teamPlan === "free" &&
+ errorMessage.toLowerCase().includes("storage limit")
+ ) {
+ return (
+ handleRetry(step)}
+ teamSlug={props.teamSlug}
+ trackingCampaign="create-nft"
+ />
+ );
+ }
+
+ return null;
+ }}
+ steps={steps}
+ />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx
index d79e1f365ff..bb059cc64bf 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx
@@ -54,6 +54,7 @@ export default async function Page(props: {
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
+ teamPlan={team.billingPlan}
teamSlug={params.team_slug}
/>
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx
index 9115bcdd083..01ebb6d2bad 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx
@@ -2,8 +2,8 @@ import type { UseFormReturn } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { BasisPointsInput } from "@/components/blocks/BasisPointsInput";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
+import { SolidityInput } from "@/components/solidity-inputs";
import { Form } from "@/components/ui/form";
-import { SolidityInput } from "../../../../../../../../../../@/components/solidity-inputs";
import { StepCard } from "../../_common/step-card";
import type { NFTSalesSettingsFormValues } from "../_common/form";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx
index 28eee054f9d..40d170391df 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx
@@ -23,6 +23,7 @@ import {
reportAssetCreationFailed,
reportContractDeployed,
} from "@/analytics/report";
+import type { Team } from "@/api/team";
import {
DEFAULT_FEE_BPS_NEW,
DEFAULT_FEE_RECIPIENT,
@@ -42,6 +43,7 @@ export function CreateTokenAssetPage(props: {
projectId: string;
teamSlug: string;
projectSlug: string;
+ teamPlan: Team["billingPlan"];
}) {
const account = useActiveAccount();
const { idToChain } = useAllChainsData();
@@ -347,6 +349,7 @@ export function CreateTokenAssetPage(props: {
);
}}
projectSlug={props.projectSlug}
+ teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx
index 10699b2dfb6..f97b4e06df4 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx
@@ -9,6 +9,7 @@ import {
type ThirdwebClient,
} from "thirdweb";
import { reportAssetCreationStepConfigured } from "@/analytics/report";
+import type { Team } from "@/api/team";
import {
type CreateAssetFormValues,
type TokenDistributionFormValues,
@@ -38,6 +39,7 @@ export function CreateTokenAssetPageUI(props: {
onLaunchSuccess: () => void;
teamSlug: string;
projectSlug: string;
+ teamPlan: Team["billingPlan"];
}) {
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
"token-info",
@@ -133,6 +135,7 @@ export function CreateTokenAssetPageUI(props: {
setStep("distribution");
}}
projectSlug={props.projectSlug}
+ teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
values={{
...tokenInfoForm.getValues(),
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx
index 185efd02438..5053343245f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx
@@ -45,6 +45,7 @@ export const Default: Story = {
createTokenFunctions: mockCreateTokenFunctions,
onLaunchSuccess: () => {},
projectSlug: "test-project",
+ teamPlan: "free",
teamSlug: "test-team",
},
};
@@ -62,6 +63,27 @@ export const ErrorOnDeploy: Story = {
},
onLaunchSuccess: () => {},
projectSlug: "test-project",
+ teamPlan: "free",
+ teamSlug: "test-team",
+ },
+};
+
+export const StorageErrorOnDeploy: Story = {
+ args: {
+ accountAddress: "0x1234567890123456789012345678901234567890",
+ client: storybookThirdwebClient,
+ createTokenFunctions: {
+ ...mockCreateTokenFunctions,
+ deployContract: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ throw new Error(
+ "You have reached your storage limit. Please add a valid payment method to continue using the service.",
+ );
+ },
+ },
+ onLaunchSuccess: () => {},
+ projectSlug: "test-project",
+ teamPlan: "free",
teamSlug: "test-team",
},
};
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx
index ef0ad460c20..fd3aa75cddd 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx
@@ -12,6 +12,7 @@ import {
reportAssetCreationFailed,
reportAssetCreationSuccessful,
} from "@/analytics/report";
+import type { Team } from "@/api/team";
import {
type MultiStepState,
MultiStepStatus,
@@ -29,6 +30,7 @@ import { parseError } from "@/utils/errorParser";
import { ChainOverview } from "../../_common/chain-overview";
import { FilePreview } from "../../_common/file-preview";
import { StepCard } from "../../_common/step-card";
+import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell";
import type { CreateAssetFormValues } from "../_common/form";
import type { CreateTokenFunctions } from "../create-token-page.client";
import { TokenDistributionBarChart } from "../distribution/token-distribution";
@@ -50,6 +52,7 @@ export function LaunchTokenStatus(props: {
onLaunchSuccess: () => void;
teamSlug: string;
projectSlug: string;
+ teamPlan: Team["billingPlan"];
}) {
const formValues = props.values;
const { createTokenFunctions } = props;
@@ -177,7 +180,6 @@ export function LaunchTokenStatus(props: {
await executeSteps(steps, startIndex);
}
-
return (
-
+ {
+ if (
+ props.teamPlan === "free" &&
+ errorMessage.toLowerCase().includes("storage limit")
+ ) {
+ return (
+ handleRetry(step)}
+ teamSlug={props.teamSlug}
+ trackingCampaign="create-coin"
+ />
+ );
+ }
+
+ return null;
+ }}
+ steps={steps}
+ />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx
index 2e4b938550e..a6e63df8be1 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx
@@ -54,6 +54,7 @@ export default async function Page(props: {
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
+ teamPlan={team.billingPlan}
teamSlug={params.team_slug}
/>
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx
index 52b1b1bd950..01435e37191 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/projects";
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
-import { Button } from "../../../../../../../../@/components/ui/button";
+import { Button } from "@/components/ui/button";
import { CreateVaultAccountButton } from "../../vault/components/create-vault-account.client";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx
index a373463c99d..2f7c7aba132 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx
@@ -46,7 +46,10 @@ import type {
// TODO - add Status selector dropdown here
export function TransactionsTableUI(props: {
- getData: (params: { page: number }) => Promise;
+ getData: (params: {
+ page: number;
+ status: TransactionStatus | undefined;
+ }) => Promise;
project: Project;
teamSlug: string;
wallets?: Wallet[];
@@ -63,8 +66,8 @@ export function TransactionsTableUI(props: {
const transactionsQuery = useQuery({
enabled: !!props.wallets && props.wallets.length > 0,
placeholderData: keepPreviousData,
- queryFn: () => props.getData({ page }),
- queryKey: ["transactions", props.project.id, page],
+ queryFn: () => props.getData({ page, status }),
+ queryKey: ["transactions", props.project.id, page, status],
refetchInterval: autoUpdate ? 4_000 : false,
});
@@ -222,10 +225,6 @@ export const statusDetails = {
name: "Queued",
type: "warning",
},
- REVERTED: {
- name: "Reverted",
- type: "destructive",
- },
SUBMITTED: {
name: "Submitted",
type: "warning",
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx
index 3f4dfc3e0b6..4d212e44aa9 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx
@@ -5,7 +5,7 @@ import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionsTableUI } from "./tx-table-ui";
-import type { TransactionsResponse } from "./types";
+import type { TransactionStatus, TransactionsResponse } from "./types";
export function TransactionsTable(props: {
project: Project;
@@ -16,10 +16,11 @@ export function TransactionsTable(props: {
return (
{
+ getData={async ({ page, status }) => {
return await getTransactions({
page,
project: props.project,
+ status,
});
}}
project={props.project}
@@ -32,23 +33,26 @@ export function TransactionsTable(props: {
async function getTransactions({
project,
page,
+ status,
}: {
project: Project;
page: number;
+ status: TransactionStatus | undefined;
}) {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
- body: JSON.stringify({
- limit: 20,
- page,
- }),
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
},
- method: "POST",
- pathname: "/v1/transactions/search",
+ method: "GET",
+ pathname: `/v1/transactions`,
+ searchParams: {
+ limit: "20",
+ page: page.toString(),
+ status: status ?? undefined,
+ },
},
);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts
index f176f30c35b..7ea643a251f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts
@@ -85,12 +85,7 @@ export type Transaction = {
cancelledAt: Date | null;
};
-export type TransactionStatus =
- | "QUEUED"
- | "SUBMITTED"
- | "CONFIRMED"
- | "REVERTED"
- | "FAILED";
+export type TransactionStatus = "QUEUED" | "SUBMITTED" | "CONFIRMED" | "FAILED";
type Pagination = {
totalCount: number;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts
index de83a7a8dcb..2fec73c25d6 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts
@@ -122,7 +122,9 @@ export async function getTransactionsChart({
// TODO - need to handle this error state, like we do with the connect charts
throw new Error(
- `Error fetching transactions chart data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
+ `Error fetching transactions chart data: ${response.status} ${
+ response.statusText
+ } - ${await response.text().catch(() => "Unknown error")}`,
);
}
@@ -192,7 +194,9 @@ export async function getSingleTransaction({
// TODO - need to handle this error state, like we do with the connect charts
throw new Error(
- `Error fetching single transaction data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
+ `Error fetching single transaction data: ${response.status} ${
+ response.statusText
+ } - ${await response.text().catch(() => "Unknown error")}`,
);
}
@@ -200,3 +204,77 @@ export async function getSingleTransaction({
return data.transactions[0];
}
+
+// Activity log types
+export type ActivityLogEntry = {
+ id: string;
+ transactionId: string;
+ batchIndex: number;
+ eventType: string;
+ stageName: string;
+ executorName: string;
+ notificationId: string;
+ payload: Record | string | number | boolean | null;
+ timestamp: string;
+ createdAt: string;
+};
+
+type ActivityLogsResponse = {
+ result: {
+ activityLogs: ActivityLogEntry[];
+ transaction: {
+ id: string;
+ batchIndex: number;
+ clientId: string;
+ };
+ pagination: {
+ totalCount: number;
+ page: number;
+ limit: number;
+ };
+ };
+};
+
+export async function getTransactionActivityLogs({
+ teamId,
+ clientId,
+ transactionId,
+}: {
+ teamId: string;
+ clientId: string;
+ transactionId: string;
+}): Promise {
+ const authToken = await getAuthToken();
+
+ const response = await fetch(
+ `${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/activity-logs?transactionId=${transactionId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ "x-client-id": clientId,
+ "x-team-id": teamId,
+ },
+ method: "GET",
+ },
+ );
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return [];
+ }
+
+ // Don't throw on 404 - activity logs might not exist for all transactions
+ if (response.status === 404) {
+ return [];
+ }
+
+ console.error(
+ `Error fetching activity logs: ${response.status} ${response.statusText}`,
+ );
+ return [];
+ }
+
+ const data = (await response.json()) as ActivityLogsResponse;
+ return data.result.activityLogs;
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx
index 957e8de6642..f76e1cea6e4 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx
@@ -1,11 +1,180 @@
import { loginRedirect } from "@app/login/loginRedirect";
+import type { AbiFunction } from "abitype";
import { notFound, redirect } from "next/navigation";
+import { getContract, toTokens } from "thirdweb";
+import { defineChain, getChainMetadata } from "thirdweb/chains";
+import { getCompilerMetadata } from "thirdweb/contract";
+import {
+ decodeFunctionData,
+ shortenAddress,
+ toFunctionSelector,
+} from "thirdweb/utils";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
-import { getSingleTransaction } from "../../lib/analytics";
+import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
+import type { Transaction } from "../../analytics/tx-table/types";
+import {
+ getSingleTransaction,
+ getTransactionActivityLogs,
+} from "../../lib/analytics";
import { TransactionDetailsUI } from "./transaction-details-ui";
+type AbiItem =
+ | AbiFunction
+ | {
+ type: string;
+ name?: string;
+ };
+
+export type DecodedTransactionData = {
+ chainId: number;
+ contractAddress: string;
+ value: string;
+ contractName: string;
+ functionName: string;
+ functionArgs: Record;
+} | null;
+
+export type DecodedTransactionResult = DecodedTransactionData[];
+
+async function decodeSingleTransactionParam(
+ txParam: {
+ to: string;
+ data: `0x${string}`;
+ value: string;
+ },
+ chainId: number,
+): Promise {
+ try {
+ if (!txParam || !txParam.to || !txParam.data) {
+ return null;
+ }
+
+ // eslint-disable-next-line no-restricted-syntax
+ const chain = defineChain(chainId);
+
+ // Create contract instance
+ const contract = getContract({
+ address: txParam.to,
+ chain,
+ client: serverThirdwebClient,
+ });
+
+ // Fetch compiler metadata
+ const chainMetadata = await getChainMetadata(chain);
+
+ const txValue = `${txParam.value ? toTokens(BigInt(txParam.value), chainMetadata.nativeCurrency.decimals) : "0"} ${chainMetadata.nativeCurrency.symbol}`;
+
+ if (txParam.data === "0x") {
+ return {
+ chainId,
+ contractAddress: txParam.to,
+ contractName: shortenAddress(txParam.to),
+ functionArgs: {},
+ functionName: "Transfer",
+ value: txValue,
+ };
+ }
+
+ const compilerMetadata = await getCompilerMetadata(contract);
+
+ if (!compilerMetadata || !compilerMetadata.abi) {
+ return null;
+ }
+
+ const contractName = compilerMetadata.name || "Unknown Contract";
+ const abi = compilerMetadata.abi;
+
+ // Extract function selector from transaction data (first 4 bytes)
+ const functionSelector = txParam.data.slice(0, 10) as `0x${string}`;
+
+ // Find matching function in ABI
+ const functions = (abi as readonly AbiItem[]).filter(
+ (item): item is AbiFunction => item.type === "function",
+ );
+ let matchingFunction: AbiFunction | null = null;
+
+ for (const func of functions) {
+ const selector = toFunctionSelector(func);
+ if (selector === functionSelector) {
+ matchingFunction = func;
+ break;
+ }
+ }
+
+ if (!matchingFunction) {
+ return null;
+ }
+
+ const functionName = matchingFunction.name;
+
+ // Decode function data
+ const decodedArgs = (await decodeFunctionData({
+ contract: getContract({
+ ...contract,
+ abi: [matchingFunction],
+ }),
+ data: txParam.data,
+ })) as readonly unknown[];
+
+ // Create a clean object for display
+ const functionArgs: Record = {};
+ if (matchingFunction.inputs && decodedArgs) {
+ for (let index = 0; index < matchingFunction.inputs.length; index++) {
+ const input = matchingFunction.inputs[index];
+ if (input) {
+ functionArgs[input.name || `arg${index}`] = decodedArgs[index];
+ }
+ }
+ }
+
+ return {
+ chainId,
+ contractAddress: txParam.to,
+ contractName,
+ functionArgs,
+ functionName,
+ value: txValue,
+ };
+ } catch (error) {
+ console.error("Error decoding transaction param:", error);
+ return null;
+ }
+}
+
+async function decodeTransactionData(
+ transaction: Transaction,
+): Promise {
+ try {
+ // Check if we have transaction parameters
+ if (
+ !transaction.transactionParams ||
+ transaction.transactionParams.length === 0
+ ) {
+ return [];
+ }
+
+ // Ensure we have a chainId
+ if (!transaction.chainId) {
+ return [];
+ }
+
+ const chainId = parseInt(transaction.chainId);
+
+ // Decode all transaction parameters in parallel
+ const decodingPromises = transaction.transactionParams.map((txParam) =>
+ decodeSingleTransactionParam(txParam, chainId),
+ );
+
+ const results = await Promise.all(decodingPromises);
+ return results;
+ } catch (error) {
+ console.error("Error decoding transaction:", error);
+ return [];
+ }
+}
+
export default async function TransactionPage({
params,
}: {
@@ -26,11 +195,18 @@ export default async function TransactionPage({
redirect(`/team/${team_slug}`);
}
- const transactionData = await getSingleTransaction({
- clientId: project.publishableKey,
- teamId: project.teamId,
- transactionId: id,
- });
+ const [transactionData, activityLogs] = await Promise.all([
+ getSingleTransaction({
+ clientId: project.publishableKey,
+ teamId: project.teamId,
+ transactionId: id,
+ }),
+ getTransactionActivityLogs({
+ clientId: project.publishableKey,
+ teamId: project.teamId,
+ transactionId: id,
+ }),
+ ]);
const client = getClientThirdwebClient({
jwt: authToken,
@@ -41,10 +217,15 @@ export default async function TransactionPage({
notFound();
}
+ // Decode transaction data on the server
+ const decodedTransactionData = await decodeTransactionData(transactionData);
+
return (
- {`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "}
+ {`${transactionHash.slice(
+ 0,
+ 8,
+ )}...${transactionHash.slice(-6)}`}{" "}
@@ -165,7 +188,10 @@ export function TransactionDetailsUI({
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={transactionHash}
- textToShow={`${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`}
+ textToShow={`${transactionHash.slice(
+ 0,
+ 6,
+ )}...${transactionHash.slice(-4)}`}
tooltip="Copy transaction hash"
variant="ghost"
/>
@@ -189,7 +215,7 @@ export function TransactionDetailsUI({
client={client}
src={chain.icon?.url}
/>
- {chain.name}
+ {chain.name || "Unknown"}
) : (
Chain ID: {chainId || "Unknown"}
@@ -222,24 +248,10 @@ export function TransactionDetailsUI({
-
-
- Transaction Parameters
-
-
- {transaction.transactionParams &&
- transaction.transactionParams.length > 0 ? (
-
- ) : (
-
- No transaction parameters available
-
- )}
-
-
+
{errorMessage && (
@@ -250,7 +262,7 @@ export function TransactionDetailsUI({
{errorDetails ? (
) : (
@@ -347,7 +359,348 @@ export function TransactionDetailsUI({
)}
+
+ {/* Activity Log Card */}
+
>
);
}
+
+// Transaction Parameters Card with Tabs
+function TransactionParametersCard({
+ transaction,
+ decodedTransactionData,
+}: {
+ transaction: Transaction;
+ decodedTransactionData: DecodedTransactionResult;
+}) {
+ const [activeTab, setActiveTab] = useState<"decoded" | "raw">("decoded");
+
+ return (
+
+
+ Transaction Parameters
+
+
+ setActiveTab("decoded"),
+ },
+ {
+ isActive: activeTab === "raw",
+ name: "Raw",
+ onClick: () => setActiveTab("raw"),
+ },
+ ]}
+ />
+
+ {activeTab === "decoded" ? (
+ setActiveTab("raw")}
+ />
+ ) : (
+
+ {transaction.transactionParams &&
+ transaction.transactionParams.length > 0 ? (
+
+ ) : (
+
+ No transaction parameters available
+
+ )}
+
+ )}
+
+
+ );
+}
+
+// Client component to display list of decoded transaction data
+function DecodedTransactionListDisplay({
+ decodedDataList,
+ onSwitchToRaw,
+}: {
+ decodedDataList: DecodedTransactionResult;
+ onSwitchToRaw: () => void;
+}) {
+ if (decodedDataList.length === 0) {
+ return (
+
+ Unable to decode transaction data. The contract may not have verified
+ metadata available.{" "}
+
+ View raw transaction data
+
+ .
+
+ );
+ }
+
+ return (
+
+ {decodedDataList.map(
+ (decodedData: DecodedTransactionData, index: number) => {
+ return (
+
+ );
+ },
+ )}
+
+ );
+}
+
+// Client component to display decoded transaction data
+function DecodedTransactionDisplay({
+ decodedData,
+ onSwitchToRaw,
+}: {
+ decodedData: DecodedTransactionData;
+ onSwitchToRaw: () => void;
+}) {
+ if (!decodedData) {
+ return (
+
+
+ Unable to decode transaction data. The contract may not have verified
+ metadata available.{" "}
+
+ View raw transaction data
+
+ .
+
+
+ );
+ }
+
+ return (
+
+
+
+
Target
+
+
+ {decodedData.contractName}
+
+
+
+
+
Function
+
{decodedData.functionName}
+
+
+
Value
+
{decodedData.value}
+
+
+
+
+ );
+}
+
+// Activity Log Timeline Component
+function ActivityLogCard({
+ activityLogs,
+}: {
+ activityLogs: ActivityLogEntry[];
+}) {
+ // Sort activity logs and prepare JSX elements using for...of loop
+ const renderActivityLogs = () => {
+ if (activityLogs.length === 0) {
+ return (
+
+ No activity logs available for this transaction
+
+ );
+ }
+
+ // Sort logs chronologically using for...of loop (manual sorting)
+ const sortedLogs: ActivityLogEntry[] = [];
+
+ // Copy all logs to sortedLogs first
+ for (const log of activityLogs) {
+ sortedLogs[sortedLogs.length] = log;
+ }
+
+ // Manual bubble sort using for...of loops
+ for (let i = 0; i < sortedLogs.length; i++) {
+ for (let j = 0; j < sortedLogs.length - 1 - i; j++) {
+ const currentLog = sortedLogs[j];
+ const nextLog = sortedLogs[j + 1];
+
+ if (
+ currentLog &&
+ nextLog &&
+ new Date(currentLog.createdAt).getTime() >
+ new Date(nextLog.createdAt).getTime()
+ ) {
+ // Swap elements
+ sortedLogs[j] = nextLog;
+ sortedLogs[j + 1] = currentLog;
+ }
+ }
+ }
+
+ const logElements: React.ReactElement[] = [];
+ let index = 0;
+
+ for (const log of sortedLogs) {
+ const isLast = index === sortedLogs.length - 1;
+ logElements.push(
+ ,
+ );
+ index++;
+ }
+
+ return {logElements}
;
+ };
+
+ return (
+
+
+ Activity Log
+
+ {renderActivityLogs()}
+
+ );
+}
+
+function ActivityLogEntryItem({
+ log,
+ isLast,
+}: {
+ log: ActivityLogEntry;
+ isLast: boolean;
+}) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Get display info based on event type
+ const getEventTypeInfo = (eventType: string) => {
+ const type = eventType.toLowerCase();
+ if (type.includes("success"))
+ return {
+ dot: "bg-green-500",
+ label: "Success",
+ variant: "success" as const,
+ };
+ if (type.includes("nack"))
+ return {
+ dot: "bg-yellow-500",
+ label: "Retry",
+ variant: "warning" as const,
+ };
+ if (type.includes("failure"))
+ return {
+ dot: "bg-red-500",
+ label: "Error",
+ variant: "destructive" as const,
+ };
+ return {
+ dot: "bg-primary",
+ label: eventType,
+ variant: "secondary" as const,
+ };
+ };
+
+ const eventInfo = getEventTypeInfo(log.eventType);
+
+ return (
+
+ {/* Timeline line */}
+ {!isLast && (
+
+ )}
+
+
+ {/* Timeline dot */}
+
+
+ {/* Content */}
+
+
setIsExpanded(!isExpanded)}
+ type="button"
+ >
+
+ {log.stageName}
+ {eventInfo.label}
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isExpanded && (
+
+
+
+
Executor
+
{log.executorName}
+
+
+
Created At
+
+ {format(new Date(log.createdAt), "PP pp z")}
+
+
+
+
+ {log.payload && (
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx
index 7c1a0342fd4..375d77fa65f 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx
@@ -14,6 +14,7 @@ import { upload } from "thirdweb/storage";
import type { Project } from "@/api/projects";
import type { SMSCountryTiers } from "@/api/sms";
import type { Team } from "@/api/team";
+import { FileInput } from "@/components/blocks/FileInput";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -41,7 +42,6 @@ import {
} from "@/schema/validations";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { toArrFromList } from "@/utils/string";
-import { FileInput } from "../../../../../../../../../@/components/blocks/FileInput";
import CountrySelector from "./sms-country-select/country-selector";
type InAppWalletSettingsPageProps = {
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx
index 756d80db769..9b52ab8d644 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx
@@ -1,3 +1,5 @@
+/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
+
import { CheckIcon, MinusIcon } from "lucide-react";
import type { SMSCountryTiers } from "@/api/sms";
import { Checkbox } from "@/components/ui/checkbox";
@@ -149,7 +151,6 @@ export default function CountrySelector({
toggleCountry(country);
}
}}
- // biome-ignore lint/a11y/useSemanticElements: FIXME
role="button"
tabIndex={0}
title={countryNames[country] || country}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx
new file mode 100644
index 00000000000..c9a86b20aed
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import { format } from "date-fns";
+import { useMemo } from "react";
+import { ResponsiveSuspense } from "responsive-rsc";
+import type { WebhookConfig } from "@/api/webhook-configs";
+import type { Range } from "@/components/analytics/date-range-selector";
+import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
+import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
+import { CopyTextButton } from "@/components/ui/CopyTextButton";
+import type { ChartConfig } from "@/components/ui/chart";
+import { Skeleton } from "@/components/ui/skeleton";
+import type {
+ WebhookLatencyStats,
+ WebhookRequestStats,
+} from "@/types/analytics";
+import { DateRangeControls, WebhookPicker } from "./WebhookAnalyticsFilter";
+
+interface WebhookAnalyticsChartsProps {
+ webhookConfigs: WebhookConfig[];
+ range: Range;
+ interval: "day" | "week";
+ teamId: string;
+ projectId: string;
+ requestsData: WebhookRequestStats[];
+ latencyData: WebhookLatencyStats[];
+ selectedWebhookId: string;
+}
+
+export function WebhookAnalyticsCharts({
+ webhookConfigs,
+ range,
+ requestsData,
+ latencyData,
+ selectedWebhookId,
+}: WebhookAnalyticsChartsProps) {
+ return (
+
+ );
+}
+
+function WebhookAnalyticsChartsUI({
+ webhookConfigs,
+ requestsData,
+ latencyData,
+ range,
+ selectedWebhookId,
+}: {
+ webhookConfigs: WebhookConfig[];
+ requestsData: WebhookRequestStats[];
+ latencyData: WebhookLatencyStats[];
+ range: Range;
+ selectedWebhookId: string;
+}) {
+ // Filter data based on selected webhook and date range
+ const filteredRequestsData = useMemo(() => {
+ let data = requestsData;
+
+ // Note: webhook filtering is already done server-side,
+ // but we still apply date range filtering for consistency
+ data = data.filter((item) => {
+ const itemDate = new Date(item.date);
+ return itemDate >= range.from && itemDate <= range.to;
+ });
+
+ return data;
+ }, [requestsData, range.from, range.to]);
+
+ const filteredLatencyData = useMemo(() => {
+ let data = latencyData;
+
+ // Note: webhook filtering is already done server-side,
+ // but we still apply date range filtering for consistency
+ data = data.filter((item) => {
+ const itemDate = new Date(item.date);
+ return itemDate >= range.from && itemDate <= range.to;
+ });
+
+ return data;
+ }, [latencyData, range.from, range.to]);
+
+ // Process status code distribution data by individual status codes
+ const statusCodeData = useMemo(() => {
+ if (!filteredRequestsData.length) return [];
+
+ const groupedData = filteredRequestsData.reduce(
+ (acc, item) => {
+ const date = new Date(item.date).getTime();
+ if (!acc[date]) {
+ acc[date] = { time: date };
+ }
+
+ // Only include valid status codes (not 0) with actual request counts
+ if (item.httpStatusCode > 0 && item.totalRequests > 0) {
+ const statusKey = item.httpStatusCode.toString();
+ acc[date][statusKey] =
+ (acc[date][statusKey] || 0) + item.totalRequests;
+ }
+ return acc;
+ },
+ {} as Record & { time: number }>,
+ );
+
+ return Object.values(groupedData).sort(
+ (a, b) => (a.time || 0) - (b.time || 0),
+ );
+ }, [filteredRequestsData]);
+
+ // Process latency data for charts
+ const latencyChartData = useMemo(() => {
+ if (!filteredLatencyData.length) return [];
+
+ return filteredLatencyData
+ .map((item) => ({
+ p50: item.p50LatencyMs,
+ p90: item.p90LatencyMs,
+ p99: item.p99LatencyMs,
+ time: new Date(item.date).getTime(),
+ }))
+ .sort((a, b) => a.time - b.time);
+ }, [filteredLatencyData]);
+
+ // Chart configurations
+ const latencyChartConfig: ChartConfig = {
+ p50: {
+ color: "hsl(var(--chart-1))",
+ label: "P50 Latency",
+ },
+ p90: {
+ color: "hsl(var(--chart-2))",
+ label: "P90 Latency",
+ },
+ p99: {
+ color: "hsl(var(--chart-3))",
+ label: "P99 Latency",
+ },
+ };
+
+ // Generate status code chart config dynamically with class-based colors
+ const statusCodeConfig: ChartConfig = useMemo(() => {
+ const statusCodes = new Set();
+ statusCodeData.forEach((item) => {
+ Object.keys(item).forEach((key) => {
+ if (key !== "time" && !Number.isNaN(Number.parseInt(key))) {
+ statusCodes.add(key);
+ }
+ });
+ });
+
+ const getColorForStatusCode = (statusCode: number): string => {
+ if (statusCode >= 200 && statusCode < 300) {
+ return "hsl(142, 76%, 36%)"; // Green for 2xx
+ } else if (statusCode >= 300 && statusCode < 400) {
+ return "hsl(48, 96%, 53%)"; // Yellow for 3xx
+ } else if (statusCode >= 400 && statusCode < 500) {
+ return "hsl(25, 95%, 53%)"; // Orange for 4xx
+ } else {
+ return "hsl(0, 84%, 60%)"; // Red for 5xx
+ }
+ };
+
+ const config: ChartConfig = {};
+ Array.from(statusCodes)
+ .sort((a, b) => {
+ const codeA = Number.parseInt(a);
+ const codeB = Number.parseInt(b);
+ return codeA - codeB;
+ })
+ .forEach((statusKey) => {
+ const statusCode = Number.parseInt(statusKey);
+ config[statusKey] = {
+ color: getColorForStatusCode(statusCode),
+ label: statusCode.toString(),
+ };
+ });
+
+ return config;
+ }, [statusCodeData]);
+
+ const hasData = statusCodeData.length > 0 || latencyChartData.length > 0;
+ const selectedWebhookConfig = webhookConfigs.find(
+ (w) => w.id === selectedWebhookId,
+ );
+
+ return (
+
+
+
+
+
+
+ {/* Selected webhook URL */}
+ {selectedWebhookConfig && selectedWebhookId !== "all" && (
+
+
+
+ )}
+
+ {!hasData ? (
+
+
+
+ No webhook data available
+
+
+ Webhook analytics will appear here once you start receiving
+ webhook events.
+
+
+
+ ) : (
+
+
+
+
+ }
+ searchParamsUsed={["from", "to", "interval", "webhook"]}
+ >
+
+ {/* Status Code Distribution Chart */}
+
+ format(
+ new Date(Number.parseInt(label as string)),
+ "MMM dd, yyyy HH:mm",
+ )
+ }
+ toolTipValueFormatter={(value) => `${value} requests`}
+ variant="stacked"
+ />
+
+ {/* Latency Chart */}
+
+ format(
+ new Date(Number.parseInt(label as string)),
+ "MMM dd, yyyy HH:mm",
+ )
+ }
+ toolTipValueFormatter={(value) => `${value}ms`}
+ />
+
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx
new file mode 100644
index 00000000000..53da6e6417f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import {
+ useResponsiveSearchParams,
+ useSetResponsiveSearchParams,
+} from "responsive-rsc";
+import { DateRangeSelector } from "@/components/analytics/date-range-selector";
+import { IntervalSelector } from "@/components/analytics/interval-selector";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
+
+type SearchParams = {
+ from?: string;
+ to?: string;
+ interval?: "day" | "week";
+};
+
+interface WebhookAnalyticsFilterProps {
+ webhookConfigs: Array<{
+ id: string;
+ description: string | null;
+ }>;
+ selectedWebhookId: string;
+}
+
+export function WebhookPicker({
+ webhookConfigs,
+ selectedWebhookId,
+}: WebhookAnalyticsFilterProps) {
+ const setResponsiveSearchParams = useSetResponsiveSearchParams();
+
+ return (
+ {
+ setResponsiveSearchParams((prev) => ({
+ ...prev,
+ webhook: value,
+ }));
+ }}
+ value={selectedWebhookId}
+ >
+
+
+
+
+ All Webhooks
+ {webhookConfigs.map((config) => (
+
+ {config.description || "Unnamed webhook"}
+
+ ))}
+
+
+ );
+}
+
+export function DateRangeControls() {
+ const responsiveSearchParams = useResponsiveSearchParams();
+ const setResponsiveSearchParams = useSetResponsiveSearchParams();
+
+ const { range, interval } = getFiltersFromSearchParams({
+ defaultRange: "last-30",
+ from: responsiveSearchParams.from,
+ interval: responsiveSearchParams.interval,
+ to: responsiveSearchParams.to,
+ });
+
+ return (
+
+ {
+ setResponsiveSearchParams((v: SearchParams) => {
+ const newParams = {
+ ...v,
+ from: normalizeTimeISOString(newRange.from),
+ to: normalizeTimeISOString(newRange.to),
+ };
+ return newParams;
+ });
+ }}
+ />
+
+ {
+ setResponsiveSearchParams((v: SearchParams) => {
+ const newParams = {
+ ...v,
+ interval: newInterval,
+ };
+ return newParams;
+ });
+ }}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx
new file mode 100644
index 00000000000..3d83b0a319f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx
@@ -0,0 +1,42 @@
+import type { WebhookConfig } from "@/api/webhook-configs";
+import type { Range } from "@/components/analytics/date-range-selector";
+import type {
+ WebhookLatencyStats,
+ WebhookRequestStats,
+} from "@/types/analytics";
+import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts";
+
+interface WebhookAnalyticsServerProps {
+ teamId: string;
+ projectId: string;
+ webhookConfigs: WebhookConfig[];
+ range: Range;
+ interval: "day" | "week";
+ requestsData: WebhookRequestStats[];
+ latencyData: WebhookLatencyStats[];
+ selectedWebhookId: string;
+}
+
+export function WebhookAnalyticsServer({
+ teamId,
+ projectId,
+ webhookConfigs,
+ range,
+ interval,
+ requestsData,
+ latencyData,
+ selectedWebhookId,
+}: WebhookAnalyticsServerProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx
new file mode 100644
index 00000000000..a11971b1450
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx
@@ -0,0 +1,44 @@
+import type { WebhookConfig } from "@/api/webhook-configs";
+import type { Range } from "@/components/analytics/date-range-selector";
+import type {
+ WebhookLatencyStats,
+ WebhookRequestStats,
+} from "@/types/analytics";
+import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer";
+
+interface WebhooksAnalyticsProps {
+ teamId: string;
+ teamSlug: string;
+ projectId: string;
+ projectSlug: string;
+ range: Range;
+ interval: "day" | "week";
+ webhookConfigs: WebhookConfig[];
+ requestsData: WebhookRequestStats[];
+ latencyData: WebhookLatencyStats[];
+ selectedWebhookId: string;
+}
+
+export function WebhooksAnalytics({
+ teamId,
+ projectId,
+ range,
+ interval,
+ webhookConfigs,
+ requestsData,
+ latencyData,
+ selectedWebhookId,
+}: WebhooksAnalyticsProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx
new file mode 100644
index 00000000000..fa0af1af7ac
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx
@@ -0,0 +1,117 @@
+import { notFound } from "next/navigation";
+import { ResponsiveSearchParamsProvider } from "responsive-rsc";
+import { getWebhookLatency, getWebhookRequests } from "@/api/analytics";
+import { getAuthToken } from "@/api/auth-token";
+import { getProject } from "@/api/projects";
+import { getWebhookConfigs } from "@/api/webhook-configs";
+import { getFiltersFromSearchParams } from "@/lib/time";
+import { WebhooksAnalytics } from "./components/WebhooksAnalytics";
+
+export default async function WebhooksAnalyticsPage(props: {
+ params: Promise<{ team_slug: string; project_slug: string }>;
+ searchParams: Promise<{
+ from?: string | undefined | string[];
+ to?: string | undefined | string[];
+ interval?: string | undefined | string[];
+ webhook?: string | undefined | string[];
+ }>;
+}) {
+ const [authToken, params] = await Promise.all([getAuthToken(), props.params]);
+
+ const project = await getProject(params.team_slug, params.project_slug);
+
+ if (!project || !authToken) {
+ notFound();
+ }
+
+ const searchParams = await props.searchParams;
+ const { range, interval } = getFiltersFromSearchParams({
+ defaultRange: "last-7",
+ from: searchParams.from,
+ interval: searchParams.interval,
+ to: searchParams.to,
+ });
+
+ // Get webhook configs
+ const webhookConfigsResponse = await getWebhookConfigs({
+ projectIdOrSlug: params.project_slug,
+ teamIdOrSlug: params.team_slug,
+ }).catch(() => ({
+ body: "",
+ data: [],
+ reason: "Failed to fetch webhook configs",
+ status: "error" as const,
+ }));
+
+ if (
+ webhookConfigsResponse.status === "error" ||
+ webhookConfigsResponse.data.length === 0
+ ) {
+ return (
+
+
+
+ No webhook configurations found.
+
+
+
+ );
+ }
+
+ // Get selected webhook ID from search params
+ const selectedWebhookId = Array.isArray(searchParams.webhook)
+ ? searchParams.webhook[0] || "all"
+ : searchParams.webhook || "all";
+
+ // Fetch webhook analytics data
+ const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId;
+ const [requestsData, latencyData] = await Promise.all([
+ (async () => {
+ const res = await getWebhookRequests({
+ from: range.from,
+ period: interval,
+ projectId: project.id,
+ teamId: project.teamId,
+ to: range.to,
+ webhookId,
+ });
+ if ("error" in res) {
+ console.error("Failed to fetch webhook requests:", res.error);
+ return [];
+ }
+ return res.data;
+ })(),
+ (async () => {
+ const res = await getWebhookLatency({
+ from: range.from,
+ period: interval,
+ projectId: project.id,
+ teamId: project.teamId,
+ to: range.to,
+ webhookId,
+ });
+ if ("error" in res) {
+ console.error("Failed to fetch webhook latency:", res.error);
+ return [];
+ }
+ return res.data;
+ })(),
+ ]);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx
index a9289d6c802..8092c394640 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx
@@ -1,6 +1,5 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { AlertTriangleIcon } from "lucide-react";
-import type { WebhookConfig } from "@/api/webhook-configs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
@@ -11,12 +10,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/Spinner/Spinner";
-import { useWebhookMetrics } from "../hooks/use-webhook-metrics";
+import type { WebhookSummaryStats } from "@/types/analytics";
+import type { WebhookConfig } from "../../../../../../../../@/api/webhook-configs";
interface DeleteWebhookModalProps {
webhookConfig: WebhookConfig | null;
- teamId: string;
- projectId: string;
+ metrics: WebhookSummaryStats | null;
onConfirm: () => void;
isPending: boolean;
open: boolean;
@@ -24,19 +23,12 @@ interface DeleteWebhookModalProps {
}
export function DeleteWebhookModal(props: DeleteWebhookModalProps) {
- const { data: metrics } = useWebhookMetrics({
- enabled: props.open && !!props.webhookConfig?.id,
- projectId: props.projectId,
- teamId: props.teamId,
- webhookId: props.webhookConfig?.id || "",
- });
-
if (!props.webhookConfig) {
return null;
}
// Use real metrics data
- const requests24h = metrics?.totalRequests ?? 0;
+ const requests24h = props.metrics?.totalRequests ?? 0;
const hasRecentActivity = requests24h > 0;
return (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx
index a10f8dff04d..8580e384aa4 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx
@@ -1,10 +1,11 @@
"use client";
import { redirect } from "next/navigation";
-import posthog from "posthog-js";
-import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
-import { useAvailableTopics } from "../hooks/use-available-topics";
-import { useWebhookConfigs } from "../hooks/use-webhook-configs";
+import type { WebhookSummaryStats } from "@/types/analytics";
+import type {
+ Topic,
+ WebhookConfig,
+} from "../../../../../../../../@/api/webhook-configs";
import { WebhookConfigsTable } from "./webhook-configs-table";
interface WebhooksOverviewProps {
@@ -12,6 +13,9 @@ interface WebhooksOverviewProps {
teamSlug: string;
projectId: string;
projectSlug: string;
+ webhookConfigs: WebhookConfig[];
+ topics: Topic[];
+ metricsMap: Map;
}
export function WebhooksOverview({
@@ -19,48 +23,28 @@ export function WebhooksOverview({
teamSlug,
projectId,
projectSlug,
+ webhookConfigs,
+ topics,
+ metricsMap,
}: WebhooksOverviewProps) {
- // Enabled on dev or if FF is enabled.
- const isFeatureEnabled =
- !posthog.__loaded || posthog.isFeatureEnabled("centralized-webhooks");
-
- const webhookConfigsQuery = useWebhookConfigs({
- enabled: isFeatureEnabled,
- projectSlug,
- teamSlug,
- });
- const topicsQuery = useAvailableTopics({ enabled: isFeatureEnabled });
+ // Feature is enabled (matches server component behavior)
+ const isFeatureEnabled = true;
// Redirect to contracts tab if feature is disabled
if (!isFeatureEnabled) {
redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`);
}
- // Show loading while data is loading
- if (webhookConfigsQuery.isPending || topicsQuery.isPending) {
- return ;
- }
-
- // Show error state
- if (webhookConfigsQuery.error || topicsQuery.error) {
- return (
-
-
- Failed to load webhook data. Please try again.
-
-
- );
- }
-
// Show full webhook functionality
return (
);
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx
index 07eaf6b5722..fae532bd0ca 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx
@@ -91,8 +91,8 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
webhookConfigId: webhookConfig.id,
});
- if (result.error) {
- throw new Error(result.error);
+ if (result.status === "error") {
+ throw new Error(result.body);
}
return result.data;
@@ -103,8 +103,8 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
teamIdOrSlug: props.teamSlug,
});
- if (result.error) {
- throw new Error(result.error);
+ if (result.status === "error") {
+ throw new Error(result.body);
}
return result.data;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx
index d2f75b67576..e456c258a79 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx
@@ -15,8 +15,6 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
-import type { Topic, WebhookConfig } from "@/api/webhook-configs";
-import { deleteWebhookConfig } from "@/api/webhook-configs";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Button } from "@/components/ui/button";
import {
@@ -37,6 +35,12 @@ import {
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
+import type { WebhookSummaryStats } from "@/types/analytics";
+import type {
+ Topic,
+ WebhookConfig,
+} from "../../../../../../../../@/api/webhook-configs";
+import { deleteWebhookConfig } from "../../../../../../../../@/api/webhook-configs";
import { CreateWebhookConfigModal } from "./create-webhook-config-modal";
import { DeleteWebhookModal } from "./delete-webhook-modal";
import { EditWebhookConfigModal } from "./edit-webhook-config-modal";
@@ -51,6 +55,7 @@ export function WebhookConfigsTable(props: {
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
+ metricsMap: Map;
}) {
const { webhookConfigs } = props;
const [sortBy, setSortBy] = useState("createdAt");
@@ -71,8 +76,8 @@ export function WebhookConfigsTable(props: {
webhookConfigId: webhookId,
});
- if (result.error) {
- throw new Error(result.error);
+ if (result.status === "error") {
+ throw new Error(result.body);
}
return result.data;
@@ -228,9 +233,7 @@ export function WebhookConfigsTable(props: {
@@ -309,6 +312,11 @@ export function WebhookConfigsTable(props: {
{
if (deletingWebhook) {
deleteMutation.mutate(deletingWebhook.id);
@@ -318,8 +326,6 @@ export function WebhookConfigsTable(props: {
if (!open) setDeletingWebhook(null);
}}
open={!!deletingWebhook}
- projectId={props.projectId}
- teamId={props.teamId}
webhookConfig={deletingWebhook}
/>
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx
index f1e17746c37..00523fdc996 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx
@@ -1,31 +1,13 @@
"use client";
-import { Spinner } from "@/components/ui/Spinner/Spinner";
-import { useWebhookMetrics } from "../hooks/use-webhook-metrics";
+import type { WebhookSummaryStats } from "@/types/analytics";
interface WebhookMetricsProps {
- webhookId: string;
- teamId: string;
- projectId: string;
+ metrics: WebhookSummaryStats | null;
isPaused: boolean;
}
-export function WebhookMetrics({
- webhookId,
- teamId,
- projectId,
- isPaused,
-}: WebhookMetricsProps) {
- const {
- data: metrics,
- isLoading,
- error,
- } = useWebhookMetrics({
- projectId,
- teamId,
- webhookId,
- });
-
+export function WebhookMetrics({ metrics, isPaused }: WebhookMetricsProps) {
if (isPaused) {
return (
@@ -34,25 +16,14 @@ export function WebhookMetrics({
);
}
- if (isLoading) {
+ if (!metrics) {
return (
-
-
- Loading...
-
- );
- }
-
- if (error) {
- return (
-
- Failed to load metrics
-
+ No metrics available
);
}
- const totalRequests = metrics?.totalRequests ?? 0;
- const errorRequests = metrics?.errorRequests ?? 0;
+ const totalRequests = metrics.totalRequests ?? 0;
+ const errorRequests = metrics.errorRequests ?? 0;
const errorRate =
totalRequests > 0 ? (errorRequests / totalRequests) * 100 : 0;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts
deleted file mode 100644
index 4799ac53783..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { getAvailableTopics } from "@/api/webhook-configs";
-
-export function useAvailableTopics({
- enabled = true,
-}: {
- enabled?: boolean;
-} = {}) {
- return useQuery({
- enabled,
- queryFn: async () => {
- const result = await getAvailableTopics();
-
- if (result.error) {
- throw new Error(result.error);
- }
-
- return result.data || [];
- },
- queryKey: ["webhook-topics"],
- });
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts
deleted file mode 100644
index 44c54c4d4b0..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { getWebhookConfigs } from "@/api/webhook-configs";
-
-interface UseWebhookConfigsParams {
- teamSlug: string;
- projectSlug: string;
- enabled?: boolean;
-}
-
-export function useWebhookConfigs({
- teamSlug,
- projectSlug,
- enabled = true,
-}: UseWebhookConfigsParams) {
- return useQuery({
- enabled,
- queryFn: async () => {
- const result = await getWebhookConfigs({
- projectIdOrSlug: projectSlug,
- teamIdOrSlug: teamSlug,
- });
-
- if (result.error) {
- throw new Error(result.error);
- }
-
- return result.data || [];
- },
- queryKey: ["webhook-configs", teamSlug, projectSlug],
- });
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts
deleted file mode 100644
index 813c1d5f146..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { getWebhookMetricsAction } from "@/api/webhook-metrics";
-import type { WebhookSummaryStats } from "@/types/analytics";
-
-interface UseWebhookMetricsParams {
- webhookId: string;
- teamId: string;
- projectId: string;
- enabled?: boolean;
-}
-
-export function useWebhookMetrics({
- webhookId,
- teamId,
- projectId,
- enabled = true,
-}: UseWebhookMetricsParams) {
- return useQuery({
- enabled: enabled && !!webhookId,
- queryFn: async (): Promise => {
- return await getWebhookMetricsAction({
- from: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
- period: "day",
- projectId,
- teamId,
- to: new Date(),
- webhookId,
- });
- },
- queryKey: ["webhook-metrics", teamId, projectId, webhookId],
- retry: 1,
- staleTime: 5 * 60 * 1000,
- });
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx
index bc6c3280fb3..93925420b59 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx
@@ -1,4 +1,5 @@
-import posthog from "posthog-js";
+import { getValidAccount } from "@app/account/settings/getAccount";
+import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { TabPathLinks } from "@/components/ui/tabs";
export default async function WebhooksLayout(props: {
@@ -8,9 +9,11 @@ export default async function WebhooksLayout(props: {
project_slug: string;
}>;
}) {
- // Enabled on dev or if FF is enabled.
- const isFeatureEnabled =
- !posthog.__loaded || posthog.isFeatureEnabled("centralized-webhooks");
+ const account = await getValidAccount();
+ const isFeatureEnabled = await isFeatureFlagEnabled(
+ "webhook-analytics-tab",
+ account.email,
+ );
const params = await props.params;
return (
@@ -35,6 +38,11 @@ export default async function WebhooksLayout(props: {
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
},
+ {
+ exactMatch: true,
+ name: "Analytics",
+ path: `/team/${params.team_slug}/${params.project_slug}/webhooks/analytics`,
+ },
]
: []),
{
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx
index 99d50240bd7..653389e2c14 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx
@@ -1,6 +1,11 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
+import { getWebhookSummary } from "../../../../../../../@/api/analytics";
+import {
+ getAvailableTopics,
+ getWebhookConfigs,
+} from "../../../../../../../@/api/webhook-configs";
import { WebhooksOverview } from "./components/overview";
export default async function WebhooksPage({
@@ -22,12 +27,59 @@ export default async function WebhooksPage({
notFound();
}
+ // Fetch webhook configs and topics in parallel
+ const [webhookConfigsResult, topicsResult] = await Promise.all([
+ getWebhookConfigs({
+ projectIdOrSlug: resolvedParams.project_slug,
+ teamIdOrSlug: resolvedParams.team_slug,
+ }),
+ getAvailableTopics(),
+ ]);
+
+ if (
+ webhookConfigsResult.status === "error" ||
+ topicsResult.status === "error"
+ ) {
+ notFound();
+ }
+
+ const webhookConfigs = webhookConfigsResult.data || [];
+ const topics = topicsResult.data || [];
+
+ // Fetch metrics for all webhooks in parallel
+ const webhookMetrics = await Promise.all(
+ webhookConfigs.map(async (config) => {
+ const metricsResult = await getWebhookSummary({
+ from: new Date(Date.now() - 24 * 60 * 60 * 1000),
+ period: "day",
+ projectId: project.id,
+ teamId: project.teamId, // 24 hours ago
+ to: new Date(),
+ webhookId: config.id,
+ });
+
+ return {
+ metrics:
+ "error" in metricsResult ? null : (metricsResult.data[0] ?? null),
+ webhookId: config.id,
+ };
+ }),
+ );
+
+ // Create a map for easy lookup
+ const metricsMap = new Map(
+ webhookMetrics.map((item) => [item.webhookId, item.metrics]),
+ );
+
return (
);
}
diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx
index 006cd302118..a2f5af346f0 100644
--- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx
+++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx
@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
"use client";
import { ChevronsUpDownIcon } from "lucide-react";
@@ -64,7 +65,6 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
aria-expanded={open}
aria-label={`Select a ${props.focus === "project-selection" ? "project" : "team"}`}
className="!h-auto w-auto rounded-xl px-1 py-2"
- // biome-ignore lint/a11y/useSemanticElements: EXPECTED
role="combobox"
size="icon"
variant="ghost"
diff --git a/apps/dashboard/src/app/bridge/constants.ts b/apps/dashboard/src/app/bridge/constants.ts
index 95b7fdbc0ed..f5bd30febe5 100644
--- a/apps/dashboard/src/app/bridge/constants.ts
+++ b/apps/dashboard/src/app/bridge/constants.ts
@@ -31,6 +31,20 @@ function getBridgeThirdwebClient() {
});
}
+ // During build time, client ID might not be available
+ if (!NEXT_PUBLIC_DASHBOARD_CLIENT_ID) {
+ // Return a minimal client that will fail gracefully at runtime if needed
+ return createThirdwebClient({
+ clientId: "dummy-build-time-client",
+ config: {
+ storage: {
+ gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
+ },
+ },
+ secretKey: undefined,
+ });
+ }
+
return createThirdwebClient({
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
config: {
diff --git a/apps/dashboard/src/app/pay/constants.ts b/apps/dashboard/src/app/pay/constants.ts
index 6000afb43c1..5d9a0fc13df 100644
--- a/apps/dashboard/src/app/pay/constants.ts
+++ b/apps/dashboard/src/app/pay/constants.ts
@@ -31,6 +31,20 @@ function getPayThirdwebClient() {
});
}
+ // During build time, client ID might not be available
+ if (!NEXT_PUBLIC_DASHBOARD_CLIENT_ID) {
+ // Return a minimal client that will fail gracefully at runtime if needed
+ return createThirdwebClient({
+ clientId: "dummy-build-time-client",
+ config: {
+ storage: {
+ gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
+ },
+ },
+ secretKey: undefined,
+ });
+ }
+
return createThirdwebClient({
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
config: {
diff --git a/apps/login/package.json b/apps/login/package.json
new file mode 100644
index 00000000000..8a2d4fc1c11
--- /dev/null
+++ b/apps/login/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "login",
+ "version": "0.0.0",
+ "private": true
+}
\ No newline at end of file
diff --git a/apps/nebula/biome.json b/apps/nebula/biome.json
index cec0f72abd0..f9869db792f 100644
--- a/apps/nebula/biome.json
+++ b/apps/nebula/biome.json
@@ -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": "//"
}
diff --git a/apps/nebula/package.json b/apps/nebula/package.json
index 2fc8a6e3d13..9a59c849a5b 100644
--- a/apps/nebula/package.json
+++ b/apps/nebula/package.json
@@ -12,19 +12,19 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
- "@tanstack/react-query": "5.80.7",
+ "@tanstack/react-query": "5.81.5",
"@vercel/functions": "2.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
- "lucide-react": "0.514.0",
- "next": "15.3.3",
+ "lucide-react": "0.525.0",
+ "next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
- "posthog-js": "1.252.0",
- "prettier": "3.5.3",
+ "posthog-js": "1.256.1",
+ "prettier": "3.6.2",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-dom": "19.1.0",
@@ -33,20 +33,20 @@
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "1.27.0",
- "sonner": "2.0.5",
+ "sonner": "2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
- "zod": "3.25.67"
+ "zod": "3.25.75"
},
"devDependencies": {
- "@biomejs/biome": "2.0.4",
- "@chromatic-com/storybook": "4.0.0",
- "@next/eslint-plugin-next": "15.3.3",
- "@storybook/addon-docs": "9.0.8",
- "@storybook/addon-links": "9.0.8",
- "@storybook/addon-onboarding": "9.0.8",
- "@storybook/nextjs": "9.0.8",
+ "@biomejs/biome": "2.0.6",
+ "@chromatic-com/storybook": "4.0.1",
+ "@next/eslint-plugin-next": "15.3.5",
+ "@storybook/addon-docs": "9.0.15",
+ "@storybook/addon-links": "9.0.15",
+ "@storybook/addon-onboarding": "9.0.15",
+ "@storybook/nextjs": "9.0.15",
"@types/node": "22.14.1",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
@@ -56,11 +56,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"
},
diff --git a/apps/nebula/src/@/components/blocks/select-with-search.tsx b/apps/nebula/src/@/components/blocks/select-with-search.tsx
index d9ee03ffbb3..b940c325973 100644
--- a/apps/nebula/src/@/components/blocks/select-with-search.tsx
+++ b/apps/nebula/src/@/components/blocks/select-with-search.tsx
@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/a11y/useSemanticElements: TODO */
"use client";
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
@@ -193,7 +194,6 @@ export const SelectWithSearch = React.forwardRef<
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
- // biome-ignore lint/a11y/useSemanticElements: TODO
role="option"
variant="ghost"
>
diff --git a/apps/nebula/src/@/components/blocks/wallet-address.tsx b/apps/nebula/src/@/components/blocks/wallet-address.tsx
index 3ee3086c92b..e985a079f42 100644
--- a/apps/nebula/src/@/components/blocks/wallet-address.tsx
+++ b/apps/nebula/src/@/components/blocks/wallet-address.tsx
@@ -59,7 +59,11 @@ export function WalletAddress(props: {
// special case for zero address
if (address === ZERO_ADDRESS) {
- return {shortenedAddress} ;
+ return (
+
+ {shortenedAddress}
+
+ );
}
return (
diff --git a/apps/nebula/src/app/(app)/components/ChatBar.tsx b/apps/nebula/src/app/(app)/components/ChatBar.tsx
index c67257b324a..2d079c37db8 100644
--- a/apps/nebula/src/app/(app)/components/ChatBar.tsx
+++ b/apps/nebula/src/app/(app)/components/ChatBar.tsx
@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/a11y/useSemanticElements: TODO */
"use client";
import { useMutation } from "@tanstack/react-query";
@@ -617,7 +618,6 @@ function WalletSelector(props: {
props.onClick(wallet);
}
}}
- // biome-ignore lint/a11y/useSemanticElements: TODO
role="button"
tabIndex={0}
>
diff --git a/apps/playground-web/biome.json b/apps/playground-web/biome.json
index cec0f72abd0..f9869db792f 100644
--- a/apps/playground-web/biome.json
+++ b/apps/playground-web/biome.json
@@ -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": "//"
}
diff --git a/apps/playground-web/package.json b/apps/playground-web/package.json
index c8c8cd40f2e..4e490615e85 100644
--- a/apps/playground-web/package.json
+++ b/apps/playground-web/package.json
@@ -12,17 +12,17 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
- "@tanstack/react-query": "5.80.7",
+ "@tanstack/react-query": "5.81.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
- "lucide-react": "0.514.0",
- "next": "15.3.3",
+ "lucide-react": "0.525.0",
+ "next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
- "posthog-js": "1.252.0",
- "prettier": "3.5.3",
+ "posthog-js": "1.256.1",
+ "prettier": "3.6.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "7.55.0",
@@ -32,20 +32,20 @@
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
- "zod": "3.25.67"
+ "zod": "3.25.75"
},
"devDependencies": {
- "@biomejs/biome": "2.0.4",
+ "@biomejs/biome": "2.0.6",
"@types/node": "22.14.1",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
- "eslint-config-next": "15.3.3",
+ "eslint-config-next": "15.3.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"knip": "5.60.2",
- "postcss": "8.5.5",
+ "postcss": "8.5.6",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.8.3"
@@ -57,6 +57,7 @@
"dev": "rm -rf .next && next dev --turbopack",
"fix": "eslint ./src --fix",
"format": "biome format ./src --write",
+ "knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"prefix": "biome check ./src --fix",
"prelint": "biome check ./src",
diff --git a/apps/playground-web/src/app/connect/in-app-wallet/page.tsx b/apps/playground-web/src/app/connect/in-app-wallet/page.tsx
index 6e51d5db370..0cde2f5a711 100644
--- a/apps/playground-web/src/app/connect/in-app-wallet/page.tsx
+++ b/apps/playground-web/src/app/connect/in-app-wallet/page.tsx
@@ -41,43 +41,44 @@ function UIIntegration() {
return (
);
-};`}
+function App() {
+ return
;
+}`}
header={{
description:
"Instant out of the box authentication with a prebuilt UI.",
diff --git a/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx b/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx
index 5fb7f46ecad..156792ec7e5 100644
--- a/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx
+++ b/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx
@@ -12,11 +12,14 @@ import type React from "react";
import { useId, useState } from "react";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb/chains";
+import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { cn } from "../../../../lib/utils";
+import { TokenSelector } from "@/components/ui/TokenSelector";
+import { THIRDWEB_CLIENT } from "@/lib/client";
+import type { TokenMetadata } from "@/lib/types";
import { CollapsibleSection } from "../../sign-in/components/CollapsibleSection";
import { ColorFormGroup } from "../../sign-in/components/ColorFormGroup";
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
@@ -39,17 +42,27 @@ export function LeftSection(props: {
}));
};
- const [tokenAddress, setTokenAddress] = useState
(
- payOptions.buyTokenAddress || "",
- );
+ // Shared state for chain and token selection (used by both Buy and Checkout modes)
+ const [selectedChain, setSelectedChain] = useState(() => {
+ return payOptions.buyTokenChain?.id;
+ });
+
+ const [selectedToken, setSelectedToken] = useState<
+ { chainId: number; address: string } | undefined
+ >(() => {
+ if (payOptions.buyTokenAddress && payOptions.buyTokenChain?.id) {
+ return {
+ address: payOptions.buyTokenAddress,
+ chainId: payOptions.buyTokenChain.id,
+ };
+ }
+ return undefined;
+ });
const payModeId = useId();
const buyTokenAmountId = useId();
- const buyTokenChainId = useId();
- const tokenAddressId = useId();
const sellerAddressId = useId();
const paymentAmountId = useId();
- const directPaymentChainId = useId();
const modalTitleId = useId();
const modalTitleIconId = useId();
const modalDescriptionId = useId();
@@ -57,6 +70,37 @@ export function LeftSection(props: {
const cryptoPaymentId = useId();
const cardPaymentId = useId();
+ const handleChainChange = (chainId: number) => {
+ setSelectedChain(chainId);
+ // Clear token selection when chain changes
+ setSelectedToken(undefined);
+
+ setOptions((v) => ({
+ ...v,
+ payOptions: {
+ ...v.payOptions,
+ buyTokenAddress: undefined,
+ buyTokenChain: defineChain(chainId), // Clear token when chain changes
+ },
+ }));
+ };
+
+ const handleTokenChange = (token: TokenMetadata) => {
+ const newSelectedToken = {
+ address: token.address,
+ chainId: token.chainId,
+ };
+ setSelectedToken(newSelectedToken);
+
+ setOptions((v) => ({
+ ...v,
+ payOptions: {
+ ...v.payOptions,
+ buyTokenAddress: token.address as Address,
+ },
+ }));
+ };
+
return (
- {/* Conditional form fields based on selected mode */}
-
- {/* Fund Wallet Mode Options */}
+ {/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
+ {(!payOptions.widget ||
+ payOptions.widget === "buy" ||
+ payOptions.widget === "checkout") && (
+
+ {/* Chain selection */}
+
+ Chain
+
+
+
+ {/* Token selection - only show if chain is selected */}
+ {selectedChain && (
+
+ Token
+
+
+ )}
+
+ )}
+
+ {/* Mode-specific form fields */}
+
+ {/* Buy Mode - Amount and Payment Methods */}
{(!payOptions.widget || payOptions.widget === "buy") && (
-
-
- Amount
-
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- buyTokenAmount: e.target.value,
- },
- }))
- }
- placeholder="0.01"
- value={payOptions.buyTokenAmount || ""}
- />
-
+
+ Amount
+
+ setOptions((v) => ({
+ ...v,
+ payOptions: {
+ ...v.payOptions,
+ buyTokenAmount: e.target.value,
+ },
+ }))
+ }
+ placeholder="0.01"
+ value={payOptions.buyTokenAmount || ""}
+ />
+
- {/* Chain selection */}
-
-
Chain ID
-
{
- const chainId = Number.parseInt(e.target.value);
- if (!Number.isNaN(chainId)) {
- const chain = defineChain(chainId);
+ {/* Payment Methods */}
+
+
Payment Methods
+
+
+ {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
- buyTokenChain: chain,
+ paymentMethods: checked
+ ? [
+ ...v.payOptions.paymentMethods.filter(
+ (m) => m !== "crypto",
+ ),
+ "crypto",
+ ]
+ : v.payOptions.paymentMethods.filter(
+ (m) => m !== "crypto",
+ ),
},
}));
- }
- }}
- placeholder="1 (Ethereum)"
- type="text"
- value={payOptions.buyTokenChain?.id || ""}
- />
-
-
-
- {/* Token selection for fund_wallet mode */}
-
-
-
-
- Token Address
- {
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- buyTokenAddress: e.target.value as Address,
- },
- }));
- }}
- placeholder="0x..."
- value={payOptions.buyTokenAddress}
- />
-
+ }}
+ />
+
Crypto
-
- {/* Payment Methods */}
-
-
Payment Methods
-
-
- {
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- paymentMethods: checked
- ? [
- ...v.payOptions.paymentMethods.filter(
- (m) => m !== "crypto",
- ),
- "crypto",
- ]
- : v.payOptions.paymentMethods.filter(
- (m) => m !== "crypto",
- ),
- },
- }));
- }}
- />
- Crypto
-
-
- {
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- paymentMethods: checked
- ? [
- ...v.payOptions.paymentMethods.filter(
- (m) => m !== "card",
- ),
- "card",
- ]
- : v.payOptions.paymentMethods.filter(
- (m) => m !== "card",
- ),
- },
- }));
- }}
- />
- Card
-
-
+
+ {
+ setOptions((v) => ({
+ ...v,
+ payOptions: {
+ ...v.payOptions,
+ paymentMethods: checked
+ ? [
+ ...v.payOptions.paymentMethods.filter(
+ (m) => m !== "card",
+ ),
+ "card",
+ ]
+ : v.payOptions.paymentMethods.filter(
+ (m) => m !== "card",
+ ),
+ },
+ }));
+ }}
+ />
+ Card
)}
- {/* Direct Payment Mode Options */}
+ {/* Checkout Mode - Seller Address, Price and Payment Methods */}
{payOptions.widget === "checkout" && (
@@ -250,125 +273,80 @@ export function LeftSection(props: {
/>
-
-
- Price
-
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- buyTokenAmount: e.target.value,
- },
- }))
- }
- placeholder="0.01"
- value={payOptions.buyTokenAmount || ""}
- />
-
+
+ Price
+
+ setOptions((v) => ({
+ ...v,
+ payOptions: {
+ ...v.payOptions,
+ buyTokenAmount: e.target.value,
+ },
+ }))
+ }
+ placeholder="0.01"
+ value={payOptions.buyTokenAmount || ""}
+ />
+
- {/* Chain selection */}
-
-
Chain ID
-
{
- const chainId = Number.parseInt(e.target.value);
- if (!Number.isNaN(chainId)) {
- const chain = defineChain(chainId);
+ {/* Payment Methods */}
+
+
Payment Methods
+
+
+ {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
- buyTokenChain: chain,
+ paymentMethods: checked
+ ? [
+ ...v.payOptions.paymentMethods.filter(
+ (m) => m !== "crypto",
+ ),
+ "crypto",
+ ]
+ : v.payOptions.paymentMethods.filter(
+ (m) => m !== "crypto",
+ ),
},
}));
- }
- }}
- placeholder="1 (Ethereum)"
- type="number"
- value={payOptions.buyTokenChain?.id || ""}
- />
-
-
-
- {/* Token selection for direct_payment mode - shares state with fund_wallet mode */}
-
-
-
-
- Token Address
- setTokenAddress(e.target.value)}
- placeholder="0x..."
- value={tokenAddress}
- />
-
+ }}
+ />
+
+ Crypto
+
-
- {/* Payment Methods */}
-
-
Payment Methods
-
-
- {
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- paymentMethods: checked
- ? [
- ...v.payOptions.paymentMethods.filter(
- (m) => m !== "crypto",
- ),
- "crypto",
- ]
- : v.payOptions.paymentMethods.filter(
- (m) => m !== "crypto",
- ),
- },
- }));
- }}
- />
- Crypto
-
-
- {
- setOptions((v) => ({
- ...v,
- payOptions: {
- ...v.payOptions,
- paymentMethods: checked
- ? [
- ...v.payOptions.paymentMethods.filter(
- (m) => m !== "card",
- ),
- "card",
- ]
- : v.payOptions.paymentMethods.filter(
- (m) => m !== "card",
- ),
- },
- }));
- }}
- />
- Card
-
-
+
+ {
+ setOptions((v) => ({
+ ...v,
+ payOptions: {
+ ...v.payOptions,
+ paymentMethods: checked
+ ? [
+ ...v.payOptions.paymentMethods.filter(
+ (m) => m !== "card",
+ ),
+ "card",
+ ]
+ : v.payOptions.paymentMethods.filter(
+ (m) => m !== "card",
+ ),
+ },
+ }));
+ }}
+ />
+ Card
diff --git a/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx b/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx
index e95d746c0de..a8267404768 100644
--- a/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx
+++ b/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx
@@ -3,10 +3,15 @@ import { XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
+ abstract,
+ arbitrum,
arbitrumSepolia,
+ base,
baseSepolia,
- defineChain,
+ ethereum,
+ optimism,
optimismSepolia,
+ polygon,
sepolia,
} from "thirdweb/chains";
import {
@@ -115,7 +120,12 @@ export function RightSection(props: {
auth={connectOptions.enableAuth ? playgroundAuth : undefined}
autoConnect={false}
chains={[
- defineChain(578),
+ base,
+ ethereum,
+ polygon,
+ optimism,
+ arbitrum,
+ abstract,
sepolia,
baseSepolia,
optimismSepolia,
diff --git a/apps/playground-web/src/app/layout.tsx b/apps/playground-web/src/app/layout.tsx
index 4cb73e93b26..aafb518d396 100644
--- a/apps/playground-web/src/app/layout.tsx
+++ b/apps/playground-web/src/app/layout.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Fira_Code, Inter } from "next/font/google";
-import Script from "next/script";
import { metadataBase } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { AppSidebar } from "./AppSidebar";
@@ -36,15 +35,6 @@ export default async function RootLayout({
const sidebarLinks = getSidebarLinks();
return (
-
-
-
-
(undefined);
+
+ const [selectedChain, setSelectedChain] = useState
(ethereum.id);
+
+ const chains = [
+ { id: ethereum.id, name: "Ethereum" },
+ { id: base.id, name: "Base" },
+ { id: arbitrum.id, name: "Arbitrum" },
+ ];
+
+ return (
+
+
+
+
+
Select a Chain
+ {
+ setSelectedChain(Number(e.target.value));
+ setSelectedToken(undefined); // Reset token when chain changes
+ }}
+ value={selectedChain}
+ >
+ {chains.map((chain) => (
+
+ {chain.name}
+
+ ))}
+
+
+
+
+
Select a Token
+
+ {
+ setSelectedToken({
+ address: token.address,
+ chainId: token.chainId,
+ });
+ }}
+ placeholder="Select a token"
+ selectedToken={selectedToken}
+ />
+
+
+
+ {selectedToken && (
+
+
Selected Token
+
+
+ Chain ID: {selectedToken.chainId}
+
+
+ Address: {selectedToken.address}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/playground-web/src/components/blockchain-api/write-contract-raw.tsx b/apps/playground-web/src/components/blockchain-api/write-contract-raw.tsx
index 4bd8e3a8160..12a004094b7 100644
--- a/apps/playground-web/src/components/blockchain-api/write-contract-raw.tsx
+++ b/apps/playground-web/src/components/blockchain-api/write-contract-raw.tsx
@@ -121,19 +121,18 @@ export function WriteContractRawPreview() {
{error ? (
{error}
) : (
- <>
- {txHash && sepolia.blockExplorers && (
-
- Tx sent:{" "}
- {shortenAddress(txHash, 6)}
-
- )}
- >
+ txHash &&
+ sepolia.blockExplorers && (
+
+ Tx sent:{" "}
+ {shortenAddress(txHash, 6)}
+
+ )
)}
);
diff --git a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx
index 828034f16ce..ce5c77ec17f 100644
--- a/apps/playground-web/src/components/blocks/NetworkSelectors.tsx
+++ b/apps/playground-web/src/components/blocks/NetworkSelectors.tsx
@@ -3,6 +3,7 @@
import { useCallback, useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { useAllChainsData } from "../../app/hooks/chains";
+import { SelectWithSearch } from "../ui/select-with-search";
import { ChainIcon } from "./ChainIcon";
import { MultiSelect } from "./multi-select";
@@ -127,3 +128,115 @@ export function MultiNetworkSelector(props: {
/>
);
}
+
+export function SingleNetworkSelector(props: {
+ chainId: number | undefined;
+ onChange: (chainId: number) => void;
+ className?: string;
+ popoverContentClassName?: string;
+ // if specified - only these chains will be shown
+ chainIds?: number[];
+ side?: "left" | "right" | "top" | "bottom";
+ disableChainId?: boolean;
+ align?: "center" | "start" | "end";
+ disableTestnets?: boolean;
+ placeholder?: string;
+}) {
+ const { allChains, idToChain } = useAllChainsData().data;
+
+ const chainsToShow = useMemo(() => {
+ let chains = allChains;
+
+ if (props.disableTestnets) {
+ chains = chains.filter((chain) => !chain.testnet);
+ }
+
+ if (props.chainIds) {
+ const chainIdSet = new Set(props.chainIds);
+ chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
+ }
+
+ return chains;
+ }, [allChains, props.chainIds, props.disableTestnets]);
+
+ const options = useMemo(() => {
+ return chainsToShow.map((chain) => {
+ return {
+ label: cleanChainName(chain.name),
+ value: String(chain.chainId),
+ };
+ });
+ }, [chainsToShow]);
+
+ const searchFn = useCallback(
+ (option: Option, searchValue: string) => {
+ const chain = idToChain.get(Number(option.value));
+ if (!chain) {
+ return false;
+ }
+
+ if (Number.isInteger(Number.parseInt(searchValue))) {
+ return String(chain.chainId).startsWith(searchValue);
+ }
+ return chain.name.toLowerCase().includes(searchValue.toLowerCase());
+ },
+ [idToChain],
+ );
+
+ const renderOption = useCallback(
+ (option: Option) => {
+ const chain = idToChain.get(Number(option.value));
+ if (!chain) {
+ return option.label;
+ }
+
+ return (
+
+
+
+ {cleanChainName(chain.name)}
+
+
+ {!props.disableChainId && (
+
+ Chain ID
+ {chain.chainId}
+
+ )}
+
+ );
+ },
+ [idToChain, props.disableChainId],
+ );
+
+ const isLoadingChains = allChains.length === 0;
+
+ return (
+
{
+ props.onChange(Number(chainId));
+ }}
+ options={options}
+ overrideSearchFn={searchFn}
+ placeholder={
+ isLoadingChains
+ ? "Loading Chains..."
+ : props.placeholder || "Select Chain"
+ }
+ popoverContentClassName={props.popoverContentClassName}
+ renderOption={renderOption}
+ searchPlaceholder="Search by Name or Chain ID"
+ showCheck={false}
+ side={props.side}
+ value={props.chainId?.toString()}
+ />
+ );
+}
diff --git a/apps/playground-web/src/components/styled-connect-button.tsx b/apps/playground-web/src/components/styled-connect-button.tsx
index 235b9c7960d..85e3eac20c2 100644
--- a/apps/playground-web/src/components/styled-connect-button.tsx
+++ b/apps/playground-web/src/components/styled-connect-button.tsx
@@ -3,9 +3,14 @@
import { useTheme } from "next-themes";
import {
abstract,
+ arbitrum,
arbitrumSepolia,
+ base,
baseSepolia,
+ ethereum,
+ optimism,
optimismSepolia,
+ polygon,
polygonAmoy,
sepolia,
} from "thirdweb/chains";
@@ -28,6 +33,11 @@ export function StyledConnectButton(
polygonAmoy,
arbitrumSepolia,
abstract,
+ base,
+ ethereum,
+ polygon,
+ optimism,
+ arbitrum,
]}
client={THIRDWEB_CLIENT}
theme={theme === "light" ? "light" : "dark"}
diff --git a/apps/playground-web/src/components/styled-connect-embed.tsx b/apps/playground-web/src/components/styled-connect-embed.tsx
index 37f0703c84d..986e9bcbc49 100644
--- a/apps/playground-web/src/components/styled-connect-embed.tsx
+++ b/apps/playground-web/src/components/styled-connect-embed.tsx
@@ -2,9 +2,15 @@
import { useTheme } from "next-themes";
import {
+ abstract,
+ arbitrum,
arbitrumSepolia,
+ base,
baseSepolia,
+ ethereum,
+ optimism,
optimismSepolia,
+ polygon,
polygonAmoy,
sepolia,
} from "thirdweb/chains";
@@ -30,11 +36,17 @@ export function StyledConnectEmbed(
) : (
void;
+ className?: string;
+ chainId?: number;
+ disableAddress?: boolean;
+ placeholder?: string;
+ client: ThirdwebClient;
+ disabled?: boolean;
+ enabled?: boolean;
+ addNativeTokenIfMissing: boolean;
+}) {
+ const tokensQuery = useTokensData({
+ chainId: props.chainId,
+ enabled: props.enabled,
+ });
+
+ const { idToChain } = useAllChainsData().data;
+
+ const tokens = useMemo(() => {
+ if (!tokensQuery.data) {
+ return [];
+ }
+
+ if (props.addNativeTokenIfMissing) {
+ const hasNativeToken = tokensQuery.data.some(
+ (token) => token.address === checksummedNativeTokenAddress,
+ );
+
+ if (!hasNativeToken && props.chainId) {
+ return [
+ {
+ address: checksummedNativeTokenAddress,
+ chainId: props.chainId,
+ decimals: 18,
+ name:
+ idToChain.get(props.chainId)?.nativeCurrency.name ??
+ "Native Token",
+ symbol:
+ idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
+ } satisfies TokenMetadata,
+ ...tokensQuery.data,
+ ];
+ }
+ }
+ return tokensQuery.data;
+ }, [
+ tokensQuery.data,
+ props.chainId,
+ props.addNativeTokenIfMissing,
+ idToChain,
+ ]);
+
+ const addressChainToToken = useMemo(() => {
+ const value = new Map();
+ for (const token of tokens) {
+ value.set(`${token.chainId}:${token.address}`, token);
+ }
+ return value;
+ }, [tokens]);
+
+ const selectedValue = props.selectedToken
+ ? `${props.selectedToken.chainId}:${props.selectedToken.address}`
+ : undefined;
+
+ const renderTokenOption = useCallback(
+ (token: TokenMetadata) => {
+ const resolvedSrc = token.iconUri
+ ? replaceIpfsUrl(token.iconUri, props.client)
+ : fallbackChainIcon;
+
+ return (
+
+
+ }
+ key={resolvedSrc}
+ loading="lazy"
+ skeleton={
+
+ }
+ src={resolvedSrc}
+ />
+ {token.symbol}
+
+
+ {!props.disableAddress && (
+
+ Address
+ {shortenAddress(token.address, 4)}
+
+ )}
+
+ );
+ },
+ [props.disableAddress, props.client],
+ );
+
+ return (
+ {
+ const token = addressChainToToken.get(tokenAddress);
+ if (!token) {
+ return;
+ }
+ props.onChange(token);
+ }}
+ value={selectedValue}
+ >
+
+
+
+
+ {tokens.map((token) => {
+ const value = `${token.chainId}:${token.address}`;
+ return (
+
+ {renderTokenOption(token)}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/playground-web/src/components/ui/select-with-search.tsx b/apps/playground-web/src/components/ui/select-with-search.tsx
new file mode 100644
index 00000000000..c4182109e5a
--- /dev/null
+++ b/apps/playground-web/src/components/ui/select-with-search.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
+import React, { useMemo, useRef } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
+import { cn } from "@/lib/utils";
+
+interface SelectWithSearchProps
+ extends React.ButtonHTMLAttributes {
+ options: {
+ label: string;
+ value: string;
+ }[];
+ value: string | undefined;
+ onValueChange: (value: string) => void;
+ placeholder: string;
+ searchPlaceholder?: string;
+ className?: string;
+ overrideSearchFn?: (
+ option: { value: string; label: string },
+ searchTerm: string,
+ ) => boolean;
+ renderOption?: (option: { value: string; label: string }) => React.ReactNode;
+ popoverContentClassName?: string;
+ side?: "left" | "right" | "top" | "bottom";
+ align?: "center" | "start" | "end";
+ closeOnSelect?: boolean;
+ showCheck?: boolean;
+}
+
+export const SelectWithSearch = React.forwardRef<
+ HTMLButtonElement,
+ SelectWithSearchProps
+>(
+ (
+ {
+ options,
+ onValueChange,
+ placeholder,
+ className,
+ value,
+ renderOption,
+ overrideSearchFn,
+ popoverContentClassName,
+ searchPlaceholder,
+ closeOnSelect,
+ showCheck = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
+ const [searchValue, setSearchValue] = React.useState("");
+ const selectedOption = useMemo(
+ () => options.find((option) => option.value === value),
+ [options, value],
+ );
+
+ const optionsToShow = useMemo(() => {
+ const filteredOptions: {
+ label: string;
+ value: string;
+ }[] = [];
+
+ const searchValLowercase = searchValue.toLowerCase();
+
+ for (const option of options) {
+ if (overrideSearchFn) {
+ if (overrideSearchFn(option, searchValLowercase)) {
+ filteredOptions.push(option);
+ }
+ } else {
+ if (option.label.toLowerCase().includes(searchValLowercase)) {
+ filteredOptions.push(option);
+ }
+ }
+ }
+
+ return filteredOptions;
+ }, [options, searchValue, overrideSearchFn]);
+
+ const popoverElRef = useRef(null);
+
+ return (
+
+
+ setIsPopoverOpen(true)}
+ >
+
+
+ {renderOption && selectedOption
+ ? renderOption(selectedOption)
+ : selectedOption?.label || placeholder}
+
+
+
+
+
+
+ setIsPopoverOpen(false)}
+ ref={popoverElRef}
+ side={props.side}
+ sideOffset={10}
+ style={{
+ maxHeight: "var(--radix-popover-content-available-height)",
+ width: "var(--radix-popover-trigger-width)",
+ }}
+ >
+
+ {/* Search */}
+
+ {
+ setSearchValue(e.target.value);
+ const scrollContainer =
+ popoverElRef.current?.querySelector("[data-scrollable]");
+ if (scrollContainer) {
+ scrollContainer.scrollTo({
+ top: 0,
+ });
+ }
+ }}
+ placeholder={searchPlaceholder || "Search"}
+ value={searchValue}
+ />
+
+
+
+
+ {/* List */}
+
+ {optionsToShow.length === 0 && (
+
+ No results found
+
+ )}
+
+ {optionsToShow.map((option) => {
+ const isSelected = value === option.value;
+ return (
+
{
+ onValueChange(option.value);
+ if (closeOnSelect) {
+ setIsPopoverOpen(false);
+ }
+ }}
+ variant="ghost"
+ >
+ {showCheck && (
+
+ {isSelected && }
+
+ )}
+
+
+ {renderOption ? renderOption(option) : option.label}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+ },
+);
+
+SelectWithSearch.displayName = "SelectWithSearch";
diff --git a/apps/playground-web/src/components/ui/select.tsx b/apps/playground-web/src/components/ui/select.tsx
index 61bced4bad5..d2587e781c3 100644
--- a/apps/playground-web/src/components/ui/select.tsx
+++ b/apps/playground-web/src/components/ui/select.tsx
@@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
>(({ className, children, chevronClassName, ...props }, ref) => (
span]:line-clamp-1",
+ "flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:w-full",
className,
)}
ref={ref}
@@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
>(({ className, children, ...props }, ref) => (
span]:w-full",
className,
)}
ref={ref}
diff --git a/apps/playground-web/src/hooks/useTokensData.ts b/apps/playground-web/src/hooks/useTokensData.ts
new file mode 100644
index 00000000000..4cab3dec344
--- /dev/null
+++ b/apps/playground-web/src/hooks/useTokensData.ts
@@ -0,0 +1,47 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import type { TokenMetadata } from "@/lib/types";
+
+async function fetchTokensFromApi(chainId?: number) {
+ const domain = process.env.NEXT_PUBLIC_BRIDGE_URL;
+ const url = new URL(`https://${domain}/v1/tokens`);
+
+ if (chainId) {
+ url.searchParams.append("chainId", String(chainId));
+ }
+ url.searchParams.append("limit", "1000");
+
+ const res = await fetch(url.toString(), {
+ headers: {
+ "Content-Type": "application/json",
+ "x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "",
+ },
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to fetch tokens");
+ }
+
+ const json = await res.json();
+ if (json.error) {
+ throw new Error(json.error.message);
+ }
+
+ return json.data as Array;
+}
+
+export function useTokensData({
+ chainId,
+ enabled = true,
+}: {
+ chainId?: number;
+ enabled?: boolean;
+}) {
+ return useQuery({
+ enabled,
+ queryFn: () => fetchTokensFromApi(chainId),
+ queryKey: ["tokens", chainId], // 2 minutes
+ staleTime: 1000 * 60 * 2,
+ });
+}
diff --git a/apps/playground-web/src/lib/types.ts b/apps/playground-web/src/lib/types.ts
new file mode 100644
index 00000000000..608f81ce14d
--- /dev/null
+++ b/apps/playground-web/src/lib/types.ts
@@ -0,0 +1,8 @@
+export type TokenMetadata = {
+ name: string;
+ symbol: string;
+ address: string;
+ decimals: number;
+ chainId: number;
+ iconUri?: string;
+};
diff --git a/apps/playground-web/src/lib/utils.ts b/apps/playground-web/src/lib/utils.ts
index 365058cebd7..3e622502982 100644
--- a/apps/playground-web/src/lib/utils.ts
+++ b/apps/playground-web/src/lib/utils.ts
@@ -1,6 +1,30 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
+import type { ThirdwebClient } from "thirdweb";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+export function replaceIpfsUrl(url: string, client: ThirdwebClient): string {
+ if (!url) return "";
+
+ // Use thirdweb's IPFS gateway if the URL is an IPFS URL
+ if (url.startsWith("ipfs://")) {
+ const hash = url.replace("ipfs://", "");
+ return `${
+ client.config?.storage?.gatewayUrl || "https://ipfs.io/ipfs/"
+ }${hash}`;
+ }
+
+ // If it's already an HTTP URL, return as-is
+ if (url.startsWith("http")) {
+ return url;
+ }
+
+ // Fallback to original URL
+ return url;
+}
+
+export const fallbackChainIcon =
+ "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iMTYiIGZpbGw9IiM5Q0EzQUYiLz4KPHN2ZyB4PSI2IiB5PSI2IiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IiNGRkZGRkYiLz4KPHN2Zz4KPHN2Zz4K";
diff --git a/apps/portal/biome.json b/apps/portal/biome.json
index cec0f72abd0..f9869db792f 100644
--- a/apps/portal/biome.json
+++ b/apps/portal/biome.json
@@ -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": "//"
}
diff --git a/apps/portal/package.json b/apps/portal/package.json
index 0d99ad017b9..40a45336830 100644
--- a/apps/portal/package.json
+++ b/apps/portal/package.json
@@ -3,29 +3,29 @@
"@dirtycajunrice/klee": "^1.0.6",
"@mdx-js/loader": "3.1.0",
"@mdx-js/react": "3.1.0",
- "@next/mdx": "15.3.3",
+ "@next/mdx": "15.3.5",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "1.2.7",
- "@tanstack/react-query": "5.80.7",
- "@tryghost/content-api": "^1.11.26",
+ "@tanstack/react-query": "5.81.5",
+ "@tryghost/content-api": "^1.11.28",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"flexsearch": "^0.7.43",
"github-slugger": "^2.0.0",
"he": "^1.2.0",
- "lucide-react": "0.514.0",
- "next": "15.3.3",
+ "lucide-react": "0.525.0",
+ "next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"node-html-markdown": "^1.3.0",
"node-html-parser": "^6.1.13",
- "posthog-js": "1.252.0",
- "prettier": "3.5.3",
+ "posthog-js": "1.256.1",
+ "prettier": "3.6.2",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-dom": "19.1.0",
@@ -41,8 +41,8 @@
"typedoc-better-json": "0.9.4"
},
"devDependencies": {
- "@biomejs/biome": "2.0.4",
- "@next/eslint-plugin-next": "15.3.3",
+ "@biomejs/biome": "2.0.6",
+ "@next/eslint-plugin-next": "15.3.5",
"@types/flexsearch": "^0.7.42",
"@types/he": "^1.2.3",
"@types/mdx": "^2.0.13",
@@ -61,9 +61,9 @@
"eslint-plugin-tailwindcss": "^3.18.0",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
- "postcss": "8.5.5",
+ "postcss": "8.5.6",
"tailwindcss": "3.4.17",
- "tsx": "4.20.1",
+ "tsx": "4.20.3",
"typescript": "5.8.3"
},
"name": "portal",
diff --git a/apps/portal/public/llms-full.txt b/apps/portal/public/llms-full.txt
index 0b2c8a3df62..aa114f28cbe 100644
--- a/apps/portal/public/llms-full.txt
+++ b/apps/portal/public/llms-full.txt
@@ -5,6 +5,366 @@
---
**UI Components**
---
+# Universal Bridge
+---
+## Widgets
+---
+
+## Widgets.BuyWidget
+
+Widget is a prebuilt UI for purchasing a specific token.
+
+### Example
+
+#### Basic usage
+
+The `BuyWidget` component requires `client`, `chain`, and `amount` props to function.
+
+```tsx
+import { ethereum } from "thirdweb/chains";
+
+ ;
+```
+
+#### Buy a specific token
+
+You can specify a token to purchase by passing the `tokenAddress` prop.
+
+```tsx
+ ;
+```
+
+#### Customize the UI
+
+You can customize the UI of the `BuyWidget` component by passing a custom theme object to the `theme` prop.
+
+```tsx
+ ;
+```
+
+Refer to the [Theme](https://portal.thirdweb.com/references/typescript/v5/Theme) type for more details.
+
+#### Update the Title
+
+You can update the title of the widget by passing a `title` prop to the `BuyWidget` component.
+
+```tsx
+ ;
+```
+
+#### Configure the wallet connection
+
+You can customize the wallet connection flow by passing a `connectOptions` object to the `BuyWidget` component.
+
+```tsx
+ ;
+```
+
+Refer to the [BuyWidgetConnectOptions](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetConnectOptions) type for more details.
+
+```ts
+function BuyWidget(props: BuyWidgetProps): Element;
+```
+
+### Parameters
+
+Props of type [BuyWidgetProps](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetProps) to configure the BuyWidget component.
+
+#### Type
+
+```ts
+let props: {
+ activeWallet?: Wallet;
+ amount: string;
+ chain: Chain;
+ className?: string;
+ client: ThirdwebClient;
+ connectOptions?: BuyWidgetConnectOptions;
+ description?: string;
+ hiddenWallets?: Array;
+ image?: string;
+ locale?: LocaleId;
+ onCancel?: () => void;
+ onError?: (error: Error) => void;
+ onSuccess?: () => void;
+ paymentLinkId?: string;
+ presetOptions?: [number, number, number];
+ purchaseData?: Record;
+ style?: React.CSSProperties;
+ supportedTokens?: SupportedTokens;
+ theme?: "light" | "dark" | Theme;
+ title?: string;
+ tokenAddress?: Address;
+};
+```
+
+### Returns
+
+```ts
+let returnType: Element;
+```
+---
+
+## Widgets.CheckoutWidget
+
+Widget a prebuilt UI for purchasing a specific token.
+
+### Example
+
+#### Default configuration
+
+By default, the `CheckoutWidget` component will allows users to fund their wallets with crypto or fiat on any of the supported chains..
+
+```tsx
+ ;
+```
+
+#### Customize the UI
+
+You can customize the UI of the `CheckoutWidget` component by passing a custom theme object to the `theme` prop.
+
+```tsx
+ ;
+```
+
+Refer to the [Theme](https://portal.thirdweb.com/references/typescript/v5/Theme) type for more details.
+
+#### Update the Title
+
+You can update the title of the widget by passing a `title` prop to the `CheckoutWidget` component.
+
+```tsx
+ ;
+```
+
+#### Configure the wallet connection
+
+You can customize the wallet connection flow by passing a `connectOptions` object to the `CheckoutWidget` component.
+
+```tsx
+ ;
+```
+
+Refer to the [CheckoutWidgetConnectOptions](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetConnectOptions) type for more details.
+
+```ts
+function CheckoutWidget(props: CheckoutWidgetProps): Element;
+```
+
+### Parameters
+
+Props of type [CheckoutWidgetProps](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetProps) to configure the CheckoutWidget component.
+
+#### Type
+
+```ts
+let props: {
+ activeWallet?: Wallet;
+ amount: string;
+ chain: Chain;
+ className?: string;
+ client: ThirdwebClient;
+ connectOptions?: CheckoutWidgetConnectOptions;
+ description?: string;
+ feePayer?: "user" | "seller";
+ hiddenWallets?: Array;
+ image?: string;
+ locale?: LocaleId;
+ name?: string;
+ onCancel?: () => void;
+ onError?: (error: Error) => void;
+ onSuccess?: () => void;
+ paymentLinkId?: string;
+ presetOptions?: [number, number, number];
+ purchaseData?: Record;
+ seller: Address;
+ style?: React.CSSProperties;
+ supportedTokens?: SupportedTokens;
+ theme?: "light" | "dark" | Theme;
+ tokenAddress?: Address;
+};
+```
+
+### Returns
+
+```ts
+let returnType: Element;
+```
+---
+
+## Widgets.TransactionWidget
+
+Widget a prebuilt UI for purchasing a specific token.
+
+### Example
+
+#### Default configuration
+
+By default, the `TransactionWidget` component will allows users to fund their wallets with crypto or fiat on any of the supported chains..
+
+```tsx
+ ;
+```
+
+#### Customize the UI
+
+You can customize the UI of the `TransactionWidget` component by passing a custom theme object to the `theme` prop.
+
+```tsx
+ ;
+```
+
+Refer to the [Theme](https://portal.thirdweb.com/references/typescript/v5/Theme) type for more details.
+
+#### Update the Title
+
+You can update the title of the widget by passing a `title` prop to the `TransactionWidget` component.
+
+```tsx
+ ;
+```
+
+#### Configure the wallet connection
+
+You can customize the wallet connection flow by passing a `connectOptions` object to the `TransactionWidget` component.
+
+```tsx
+ ;
+```
+
+Refer to the [TransactionWidgetConnectOptions](https://portal.thirdweb.com/references/typescript/v5/TransactionWidgetConnectOptions) type for more details.
+
+```ts
+function TransactionWidget(props: TransactionWidgetProps): Element;
+```
+
+### Parameters
+
+Props of type [TransactionWidgetProps](https://portal.thirdweb.com/references/typescript/v5/TransactionWidgetProps) to configure the TransactionWidget component.
+
+#### Type
+
+```ts
+let props: {
+ activeWallet?: Wallet;
+ amount?: string;
+ className?: string;
+ client: ThirdwebClient;
+ connectOptions?: TransactionWidgetConnectOptions;
+ description?: string;
+ feePayer?: "user" | "seller";
+ hiddenWallets?: Array;
+ image?: string;
+ locale?: LocaleId;
+ onCancel?: () => void;
+ onError?: (error: Error) => void;
+ onSuccess?: () => void;
+ paymentLinkId?: string;
+ presetOptions?: [number, number, number];
+ purchaseData?: Record;
+ style?: React.CSSProperties;
+ supportedTokens?: SupportedTokens;
+ theme?: "light" | "dark" | Theme;
+ title?: string;
+ tokenAddress?: Address;
+ transaction: PreparedTransaction;
+};
+```
+
+### Returns
+
+```ts
+let returnType: Element;
+```
+---
# Wallets
---
@@ -62,7 +422,7 @@ function AccountAddress(
#### Type
```ts
-let __namedParameters: {about : string,accessKey : string,aria-activedescendant : string,aria-atomic : Booleanish,aria-autocomplete : "inline" | "none" | "list" | "both",aria-braillelabel : string,aria-brailleroledescription : string,aria-busy : Booleanish,aria-checked : boolean | "false" | "true" | "mixed",aria-colcount : number,aria-colindex : number,aria-colindextext : string,aria-colspan : number,aria-controls : string,aria-current : boolean | "false" | "true" | "time" | "date" | "page" | "step" | "location",aria-describedby : string,aria-description : string,aria-details : string,aria-disabled : Booleanish,aria-dropeffect : "link" | "popup" | "execute" | "none" | "copy" | "move",aria-errormessage : string,aria-expanded : Booleanish,aria-flowto : string,aria-grabbed : Booleanish,aria-haspopup : boolean | "false" | "true" | "dialog" | "menu" | "grid" | "listbox" | "tree",aria-hidden : Booleanish,aria-invalid : boolean | "false" | "true" | "grammar" | "spelling",aria-keyshortcuts : string,aria-label : string,aria-labelledby : string,aria-level : number,aria-live : "off" | "polite" | "assertive",aria-modal : Booleanish,aria-multiline : Booleanish,aria-multiselectable : Booleanish,aria-orientation : "horizontal" | "vertical",aria-owns : string,aria-placeholder : string,aria-posinset : number,aria-pressed : boolean | "false" | "true" | "mixed",aria-readonly : Booleanish,aria-relevant : "text" | "all" | "additions" | "additions removals" | "additions text" | "removals" | "removals additions" | "removals text" | "text additions" | "text removals",aria-required : Booleanish,aria-roledescription : string,aria-rowcount : number,aria-rowindex : number,aria-rowindextext : string,aria-rowspan : number,aria-selected : Booleanish,aria-setsize : number,aria-sort : "none" | "ascending" | "descending" | "other",aria-valuemax : number,aria-valuemin : number,aria-valuenow : number,aria-valuetext : string,autoCapitalize : (string & ({ })) | "on" | "off" | "none" | "sentences" | "words" | "characters",autoCorrect : string,autoFocus : boolean,autoSave : string,className : string,color : string,content : string,contentEditable : "inherit" | (Booleanish) | "plaintext-only",contextMenu : string,dangerouslySetInnerHTML : { __html: string | (TrustedHTML) },datatype : string,defaultChecked : boolean,defaultValue : string | number | (readonly Array),dir : string,draggable : Booleanish,enterKeyHint : "search" | "done" | "next" | "send" | "enter" | "go" | "previous",exportparts : string,formatFn : (str: string) => string,hidden : boolean,id : string,inert : boolean,inlist : any,inputMode : "search" | "email" | "url" | "text" | "none" | "tel" | "numeric" | "decimal",is : string,itemID : string,itemProp : string,itemRef : string,itemScope : boolean,itemType : string,lang : string,nonce : string,onAbort : ReactEventHandler,onAbortCapture : ReactEventHandler,onAnimationEnd : AnimationEventHandler,onAnimationEndCapture : AnimationEventHandler,onAnimationIteration : AnimationEventHandler,onAnimationIterationCapture : AnimationEventHandler,onAnimationStart : AnimationEventHandler,onAnimationStartCapture : AnimationEventHandler,onAuxClick : MouseEventHandler,onAuxClickCapture : MouseEventHandler,onBeforeInput : FormEventHandler,onBeforeInputCapture : FormEventHandler,onBeforeToggle : ToggleEventHandler,onBlur : FocusEventHandler,onBlurCapture : FocusEventHandler,onCanPlay : ReactEventHandler,onCanPlayCapture : ReactEventHandler,onCanPlayThrough : ReactEventHandler,onCanPlayThroughCapture : ReactEventHandler,onChange : FormEventHandler,onChangeCapture : FormEventHandler,onClick : MouseEventHandler,onClickCapture : MouseEventHandler,onCompositionEnd : CompositionEventHandler,onCompositionEndCapture : CompositionEventHandler,onCompositionStart : CompositionEventHandler,onCompositionStartCapture : CompositionEventHandler,onCompositionUpdate : CompositionEventHandler,onCompositionUpdateCapture : CompositionEventHandler,onContextMenu : MouseEventHandler,onContextMenuCapture : MouseEventHandler,onCopy : ClipboardEventHandler,onCopyCapture : ClipboardEventHandler,onCut : ClipboardEventHandler,onCutCapture : ClipboardEventHandler,onDoubleClick : MouseEventHandler,onDoubleClickCapture : MouseEventHandler,onDrag : DragEventHandler,onDragCapture : DragEventHandler,onDragEnd : DragEventHandler,onDragEndCapture : DragEventHandler,onDragEnter : DragEventHandler,onDragEnterCapture : DragEventHandler,onDragExit : DragEventHandler,onDragExitCapture : DragEventHandler,onDragLeave : DragEventHandler,onDragLeaveCapture : DragEventHandler,onDragOver : DragEventHandler,onDragOverCapture : DragEventHandler,onDragStart : DragEventHandler,onDragStartCapture : DragEventHandler,onDrop : DragEventHandler,onDropCapture : DragEventHandler,onDurationChange : ReactEventHandler,onDurationChangeCapture : ReactEventHandler,onEmptied : ReactEventHandler,onEmptiedCapture : ReactEventHandler,onEncrypted : ReactEventHandler,onEncryptedCapture : ReactEventHandler,onEnded : ReactEventHandler,onEndedCapture : ReactEventHandler,onError : ReactEventHandler,onErrorCapture : ReactEventHandler,onFocus : FocusEventHandler,onFocusCapture : FocusEventHandler,onGotPointerCapture : PointerEventHandler,onGotPointerCaptureCapture : PointerEventHandler,onInput : FormEventHandler,onInputCapture : FormEventHandler,onInvalid : FormEventHandler,onInvalidCapture : FormEventHandler,onKeyDown : KeyboardEventHandler,onKeyDownCapture : KeyboardEventHandler,onKeyPress : KeyboardEventHandler,onKeyPressCapture : KeyboardEventHandler,onKeyUp : KeyboardEventHandler,onKeyUpCapture : KeyboardEventHandler,onLoad : ReactEventHandler,onLoadCapture : ReactEventHandler,onLoadedData : ReactEventHandler,onLoadedDataCapture : ReactEventHandler,onLoadedMetadata : ReactEventHandler,onLoadedMetadataCapture : ReactEventHandler,onLoadStart : ReactEventHandler,onLoadStartCapture : ReactEventHandler,onLostPointerCapture : PointerEventHandler,onLostPointerCaptureCapture : PointerEventHandler,onMouseDown : MouseEventHandler,onMouseDownCapture : MouseEventHandler,onMouseEnter : MouseEventHandler,onMouseLeave : MouseEventHandler,onMouseMove : MouseEventHandler,onMouseMoveCapture : MouseEventHandler,onMouseOut : MouseEventHandler,onMouseOutCapture : MouseEventHandler,onMouseOver : MouseEventHandler,onMouseOverCapture : MouseEventHandler,onMouseUp : MouseEventHandler,onMouseUpCapture : MouseEventHandler,onPaste : ClipboardEventHandler,onPasteCapture : ClipboardEventHandler,onPause : ReactEventHandler,onPauseCapture : ReactEventHandler,onPlay : ReactEventHandler,onPlayCapture : ReactEventHandler,onPlaying : ReactEventHandler,onPlayingCapture : ReactEventHandler,onPointerCancel : PointerEventHandler,onPointerCancelCapture : PointerEventHandler,onPointerDown : PointerEventHandler,onPointerDownCapture : PointerEventHandler,onPointerEnter : PointerEventHandler,onPointerLeave : PointerEventHandler,onPointerMove : PointerEventHandler,onPointerMoveCapture : PointerEventHandler,onPointerOut : PointerEventHandler,onPointerOutCapture : PointerEventHandler,onPointerOver : PointerEventHandler,onPointerOverCapture : PointerEventHandler,onPointerUp : PointerEventHandler,onPointerUpCapture : PointerEventHandler,onProgress : ReactEventHandler,onProgressCapture : ReactEventHandler,onRateChange : ReactEventHandler,onRateChangeCapture : ReactEventHandler,onReset : FormEventHandler,onResetCapture : FormEventHandler,onScroll : UIEventHandler,onScrollCapture : UIEventHandler,onScrollEnd : UIEventHandler,onScrollEndCapture : UIEventHandler,onSeeked : ReactEventHandler,onSeekedCapture : ReactEventHandler,onSeeking : ReactEventHandler,onSeekingCapture : ReactEventHandler,onSelect : ReactEventHandler,onSelectCapture : ReactEventHandler,onStalled : ReactEventHandler,onStalledCapture : ReactEventHandler,onSubmit : FormEventHandler,onSubmitCapture : FormEventHandler,onSuspend : ReactEventHandler,onSuspendCapture : ReactEventHandler,onTimeUpdate : ReactEventHandler,onTimeUpdateCapture : ReactEventHandler,onToggle : ToggleEventHandler,onTouchCancel : TouchEventHandler,onTouchCancelCapture : TouchEventHandler,onTouchEnd : TouchEventHandler,onTouchEndCapture : TouchEventHandler,onTouchMove : TouchEventHandler,onTouchMoveCapture : TouchEventHandler,onTouchStart : TouchEventHandler,onTouchStartCapture : TouchEventHandler,onTransitionCancel : TransitionEventHandler,onTransitionCancelCapture : TransitionEventHandler,onTransitionEnd : TransitionEventHandler,onTransitionEndCapture : TransitionEventHandler,onTransitionRun : TransitionEventHandler,onTransitionRunCapture : TransitionEventHandler,onTransitionStart : TransitionEventHandler,onTransitionStartCapture : TransitionEventHandler,onVolumeChange : ReactEventHandler,onVolumeChangeCapture : ReactEventHandler,onWaiting : ReactEventHandler,onWaitingCapture : ReactEventHandler,onWheel : WheelEventHandler,onWheelCapture : WheelEventHandler,part : string,popover : "" | "auto" | "manual",popoverTarget : string,popoverTargetAction : "toggle" | "hide" | "show",prefix : string,property : string,radioGroup : string,rel : string,resource : string,results : number,rev : string,role : AriaRole,security : string,slot : string,spellCheck : Booleanish,style : CSSProperties,suppressContentEditableWarning : boolean,suppressHydrationWarning : boolean,tabIndex : number,title : string,translate : "yes" | "no",typeof : string,unselectable : "on" | "off",vocab : string}
+let __namedParameters: {about : string,accessKey : string,aria-activedescendant : string,aria-atomic : Booleanish,aria-autocomplete : "inline" | "none" | "list" | "both",aria-braillelabel : string,aria-brailleroledescription : string,aria-busy : Booleanish,aria-checked : boolean | "false" | "true" | "mixed",aria-colcount : number,aria-colindex : number,aria-colindextext : string,aria-colspan : number,aria-controls : string,aria-current : boolean | "false" | "true" | "time" | "date" | "page" | "step" | "location",aria-describedby : string,aria-description : string,aria-details : string,aria-disabled : Booleanish,aria-dropeffect : "link" | "popup" | "execute" | "none" | "copy" | "move",aria-errormessage : string,aria-expanded : Booleanish,aria-flowto : string,aria-grabbed : Booleanish,aria-haspopup : boolean | "false" | "true" | "dialog" | "menu" | "grid" | "listbox" | "tree",aria-hidden : Booleanish,aria-invalid : boolean | "false" | "true" | "grammar" | "spelling",aria-keyshortcuts : string,aria-label : string,aria-labelledby : string,aria-level : number,aria-live : "off" | "assertive" | "polite",aria-modal : Booleanish,aria-multiline : Booleanish,aria-multiselectable : Booleanish,aria-orientation : "horizontal" | "vertical",aria-owns : string,aria-placeholder : string,aria-posinset : number,aria-pressed : boolean | "false" | "true" | "mixed",aria-readonly : Booleanish,aria-relevant : "text" | "all" | "additions" | "additions removals" | "additions text" | "removals" | "removals additions" | "removals text" | "text additions" | "text removals",aria-required : Booleanish,aria-roledescription : string,aria-rowcount : number,aria-rowindex : number,aria-rowindextext : string,aria-rowspan : number,aria-selected : Booleanish,aria-setsize : number,aria-sort : "none" | "ascending" | "descending" | "other",aria-valuemax : number,aria-valuemin : number,aria-valuenow : number,aria-valuetext : string,autoCapitalize : (string & ({ })) | "on" | "off" | "none" | "sentences" | "words" | "characters",autoCorrect : string,autoFocus : boolean,autoSave : string,className : string,color : string,content : string,contentEditable : "inherit" | (Booleanish) | "plaintext-only",contextMenu : string,dangerouslySetInnerHTML : { __html: string | (TrustedHTML) },datatype : string,defaultChecked : boolean,defaultValue : string | number | (readonly Array),dir : string,draggable : Booleanish,enterKeyHint : "search" | "done" | "next" | "send" | "enter" | "go" | "previous",exportparts : string,formatFn : (str: string) => string,hidden : boolean,id : string,inert : boolean,inlist : any,inputMode : "search" | "email" | "url" | "text" | "none" | "tel" | "numeric" | "decimal",is : string,itemID : string,itemProp : string,itemRef : string,itemScope : boolean,itemType : string,lang : string,nonce : string,onAbort : ReactEventHandler,onAbortCapture : ReactEventHandler,onAnimationEnd : AnimationEventHandler,onAnimationEndCapture : AnimationEventHandler,onAnimationIteration : AnimationEventHandler,onAnimationIterationCapture : AnimationEventHandler,onAnimationStart : AnimationEventHandler,onAnimationStartCapture : AnimationEventHandler,onAuxClick : MouseEventHandler,onAuxClickCapture : MouseEventHandler,onBeforeInput : InputEventHandler,onBeforeInputCapture : FormEventHandler,onBeforeToggle : ToggleEventHandler,onBlur : FocusEventHandler,onBlurCapture : FocusEventHandler,onCanPlay : ReactEventHandler,onCanPlayCapture : ReactEventHandler,onCanPlayThrough : ReactEventHandler,onCanPlayThroughCapture : ReactEventHandler,onChange : FormEventHandler,onChangeCapture : FormEventHandler,onClick : MouseEventHandler,onClickCapture : MouseEventHandler,onCompositionEnd : CompositionEventHandler,onCompositionEndCapture : CompositionEventHandler,onCompositionStart : CompositionEventHandler,onCompositionStartCapture : CompositionEventHandler,onCompositionUpdate : CompositionEventHandler,onCompositionUpdateCapture : CompositionEventHandler,onContextMenu : MouseEventHandler,onContextMenuCapture : MouseEventHandler