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