Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01dce0e581 | ||
|
|
18425f9d74 | ||
|
|
bd2761af15 | ||
|
|
f3d2739df1 | ||
|
|
1aa4912b17 | ||
|
|
7967d7f17e | ||
|
|
1487de6808 | ||
|
|
18917b632a | ||
|
|
41919024c3 | ||
|
|
56eb1d2491 | ||
|
|
70ff608c72 | ||
|
|
7e442a4c66 | ||
|
|
a5b888576f | ||
|
|
53b6d1b4d6 | ||
|
|
9856ee4010 | ||
|
|
3c768a0ef9 | ||
|
|
a388439022 | ||
|
|
7b873d38db | ||
|
|
d33a943176 | ||
|
|
d5e0b6eb73 | ||
|
|
75e9be0358 | ||
|
|
a704c3f2b0 | ||
|
|
b0eccf365a | ||
|
|
f3525c7c01 | ||
|
|
e516b4ddc1 | ||
|
|
dffa457a39 | ||
|
|
859032dc17 | ||
|
|
e0ee69b061 | ||
|
|
b6be8d67ef | ||
|
|
6ddb15cf02 | ||
|
|
551ec680df | ||
|
|
7b9ce493c8 | ||
|
|
b27fcd3ba0 | ||
|
|
c40603f36e | ||
|
|
458246fea1 | ||
|
|
1b3e938b06 | ||
|
|
3ebca18f9e | ||
|
|
4c5696e414 | ||
|
|
9e43da4ec0 | ||
|
|
22cea5bb8f | ||
|
|
d0e4db26ce | ||
|
|
1056696696 | ||
|
|
48297993d8 | ||
|
|
cd9e42911e | ||
|
|
41e2ccb67d | ||
|
|
68a1dcac26 | ||
|
|
98d8f292db | ||
|
|
4f2dbccba4 | ||
|
|
908142f8fa | ||
|
|
4d065c3774 | ||
|
|
426229050c | ||
|
|
0fd55a2a13 | ||
|
|
59ad7e8b09 | ||
|
|
fda2284036 | ||
|
|
629efc13b6 | ||
|
|
512f9dd3c9 | ||
|
|
919230f0c8 | ||
|
|
3ac8cba2e8 | ||
|
|
35aaf24e8a | ||
|
|
ad2d1757e2 | ||
|
|
52aba0ffb8 | ||
|
|
9809d5cf66 | ||
|
|
8b10674481 | ||
|
|
a3c76d5f7d | ||
|
|
f5f6848ead | ||
|
|
82ba2b20fc | ||
|
|
b0e69b8794 | ||
|
|
dd983dab8f | ||
|
|
068cb592dc | ||
|
|
359d008d38 | ||
|
|
7b5682ce27 | ||
|
|
51f46d34cf | ||
|
|
63a45e21cd | ||
|
|
c11fe95416 | ||
|
|
851fee1c83 | ||
|
|
9983e6ccfd | ||
|
|
73c924b440 |
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
@@ -18,8 +20,9 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
"quickfix.biome": "always",
|
||||
"source.fixAll.biome": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
|
||||
@@ -66,3 +66,6 @@ ANALYTICS_SERVICE_URL=""
|
||||
STRIPE_SECRET_KEY=""
|
||||
GROWTH_PLAN_SKU=""
|
||||
PAYMENT_METHOD_CONFIGURATION=""
|
||||
|
||||
# required for bridge iframe (/bridge/widget) page
|
||||
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID=""
|
||||
@@ -68,4 +68,7 @@ module.exports = [
|
||||
"/token",
|
||||
"/vault",
|
||||
"/monetize/bridge",
|
||||
// ai
|
||||
"/ai-privacy-policy",
|
||||
"/ai-terms",
|
||||
];
|
||||
|
||||
@@ -18,6 +18,11 @@ const ContentSecurityPolicy = `
|
||||
block-all-mixed-content;
|
||||
`;
|
||||
|
||||
const EmbedContentSecurityPolicy = `
|
||||
${ContentSecurityPolicy}
|
||||
frame-ancestors *;
|
||||
`;
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
@@ -138,6 +143,24 @@ const baseNextConfig: NextConfig = {
|
||||
// Apply these headers to all routes in your application.
|
||||
source: "/(.*)",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/widget/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
|
||||
@@ -1,131 +1,132 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@stripe/react-stripe-js": "4.0.2",
|
||||
"@stripe/stripe-js": "7.9.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/api": "workspace:*",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"abitype": "1.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"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": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.3",
|
||||
"remark-gfm": "4.0.1",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "thirdweb-dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@stripe/react-stripe-js": "4.0.2",
|
||||
"@stripe/stripe-js": "7.9.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/api": "workspace:*",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"abitype": "1.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^7.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.3",
|
||||
"remark-gfm": "4.0.1",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.6",
|
||||
"@next/eslint-plugin-next": "15.3.6",
|
||||
"@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",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "thirdweb-dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidatePathAction(
|
||||
path: string,
|
||||
@@ -8,3 +8,7 @@ export async function revalidatePathAction(
|
||||
) {
|
||||
revalidatePath(path, type);
|
||||
}
|
||||
|
||||
export async function revalidateCacheTagAction(tag: string) {
|
||||
revalidateTag(tag);
|
||||
}
|
||||
|
||||
@@ -232,13 +232,13 @@ type TokenSwapParams = {
|
||||
buyTokenAddress: string;
|
||||
sellTokenChainId: number;
|
||||
sellTokenAddress: string;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
};
|
||||
|
||||
type TokenBuyParams = {
|
||||
buyTokenChainId: number | undefined;
|
||||
buyTokenAddress: string | undefined;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -298,7 +298,7 @@ export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportSwapWidgetShown(properties: {
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
}) {
|
||||
posthog.capture("swap widget shown", properties);
|
||||
}
|
||||
@@ -683,7 +683,7 @@ export function reportProductFeedback(properties: {
|
||||
*
|
||||
*/
|
||||
export function reportBridgePageLinkClick(params: {
|
||||
linkType: "bridge-docs" | "trending-tokens";
|
||||
linkType: "bridge-docs" | "trending-tokens" | "integrate-bridge-widget";
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import "server-only";
|
||||
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
|
||||
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
|
||||
import { normalizeTime } from "@/lib/time";
|
||||
import type {
|
||||
AIUsageStats,
|
||||
AnalyticsQueryParams,
|
||||
EcosystemWalletStats,
|
||||
EngineCloudStats,
|
||||
InAppWalletStats,
|
||||
TransactionStats,
|
||||
UniversalBridgeStats,
|
||||
UniversalBridgeWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletStatsWithName,
|
||||
WebhookLatencyStats,
|
||||
WebhookSummaryStats,
|
||||
X402QueryParams,
|
||||
@@ -148,13 +149,7 @@ const cached_getWalletConnections = unstable_cache(
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return (json.data as WalletStats[]).filter(
|
||||
(w) =>
|
||||
w.walletType !== "smart" &&
|
||||
w.walletType !== "smartWallet" &&
|
||||
w.walletType !== "inApp" &&
|
||||
w.walletType !== "inAppWallet",
|
||||
);
|
||||
return json.data as WalletStats[];
|
||||
},
|
||||
["getWalletConnections"],
|
||||
{
|
||||
@@ -162,11 +157,116 @@ const cached_getWalletConnections = unstable_cache(
|
||||
},
|
||||
);
|
||||
|
||||
export function getWalletConnections(
|
||||
const cache_getEOAWalletConnections = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<WalletStatsWithName[]> => {
|
||||
const result = await cached_getWalletConnections(
|
||||
normalizedParams(params),
|
||||
authToken,
|
||||
);
|
||||
const filteredResult = result.filter(
|
||||
(w) =>
|
||||
w.walletType !== "smart" &&
|
||||
w.walletType !== "smartWallet" &&
|
||||
w.walletType !== "inApp" &&
|
||||
w.walletType !== "inAppWallet",
|
||||
);
|
||||
|
||||
const modifiedResult = await Promise.all(
|
||||
filteredResult.map(async (w) => {
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
if (!wallet) {
|
||||
return {
|
||||
...w,
|
||||
walletName: w.walletType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...w,
|
||||
walletName: wallet.name,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return modifiedResult;
|
||||
},
|
||||
["getEOAWalletConnections"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export async function getEOAWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
) {
|
||||
return cached_getWalletConnections(normalizedParams(params), authToken);
|
||||
return cache_getEOAWalletConnections(normalizedParams(params), authToken);
|
||||
}
|
||||
|
||||
const cache_getEOAAndInAppWalletConnections = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<WalletStatsWithName[]> => {
|
||||
const result = await cached_getWalletConnections(
|
||||
normalizedParams(params),
|
||||
authToken,
|
||||
);
|
||||
|
||||
const modifiedResult = await Promise.all(
|
||||
result.map(async (w) => {
|
||||
if (
|
||||
w.walletType === "inApp" ||
|
||||
w.walletType === "inAppWallet" ||
|
||||
w.walletType === "smart" ||
|
||||
w.walletType === "smartWallet"
|
||||
) {
|
||||
return {
|
||||
...w,
|
||||
walletName: "User wallet",
|
||||
};
|
||||
}
|
||||
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
if (!wallet) {
|
||||
return {
|
||||
...w,
|
||||
walletName: w.walletType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...w,
|
||||
walletName: wallet.name,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return modifiedResult;
|
||||
},
|
||||
["getEOAAndInAppWalletConnections"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export async function getEOAAndInAppWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<WalletStatsWithName[]> {
|
||||
return cache_getEOAAndInAppWalletConnections(
|
||||
normalizedParams(params),
|
||||
authToken,
|
||||
);
|
||||
}
|
||||
|
||||
const cached_getInAppWalletUsage = unstable_cache(
|
||||
@@ -291,8 +391,11 @@ const cached_getAggregateUserOpUsage = unstable_cache(
|
||||
]);
|
||||
|
||||
// Aggregate stats across wallet types
|
||||
return userOpStats.reduce(
|
||||
(acc, curr) => {
|
||||
const aggregated = userOpStats.reduce(
|
||||
(
|
||||
acc: UserOpStats & { gasPriceSum: number; gasPriceCount: number },
|
||||
curr,
|
||||
) => {
|
||||
// Skip testnets from the aggregated stats
|
||||
if (curr.chainId) {
|
||||
const chain = chains.data.find(
|
||||
@@ -306,6 +409,10 @@ const cached_getAggregateUserOpUsage = unstable_cache(
|
||||
acc.successful += curr.successful;
|
||||
acc.failed += curr.failed;
|
||||
acc.sponsoredUsd += curr.sponsoredUsd;
|
||||
acc.gasUnits += curr.gasUnits;
|
||||
// For avgGasPrice, we'll track sum and count for proper averaging
|
||||
acc.gasPriceSum += curr.avgGasPrice * curr.successful;
|
||||
acc.gasPriceCount += curr.successful;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
@@ -313,8 +420,28 @@ const cached_getAggregateUserOpUsage = unstable_cache(
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
successful: 0,
|
||||
gasUnits: 0,
|
||||
avgGasPrice: 0,
|
||||
gasPriceSum: 0,
|
||||
gasPriceCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Calculate the proper average gas price
|
||||
aggregated.avgGasPrice =
|
||||
aggregated.gasPriceCount > 0
|
||||
? aggregated.gasPriceSum / aggregated.gasPriceCount
|
||||
: 0;
|
||||
|
||||
// Return only the UserOpStats fields
|
||||
return {
|
||||
date: aggregated.date,
|
||||
failed: aggregated.failed,
|
||||
sponsoredUsd: aggregated.sponsoredUsd,
|
||||
successful: aggregated.successful,
|
||||
gasUnits: aggregated.gasUnits,
|
||||
avgGasPrice: aggregated.avgGasPrice,
|
||||
};
|
||||
},
|
||||
["getAggregateUserOpUsage"],
|
||||
{
|
||||
@@ -664,41 +791,6 @@ export function getUniversalBridgeWalletUsage(
|
||||
);
|
||||
}
|
||||
|
||||
const cached_getEngineCloudMethodUsage = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<EngineCloudStats[]> => {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics({
|
||||
authToken,
|
||||
url: `v2/engine-cloud/requests?${searchParams.toString()}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
},
|
||||
});
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch Engine Cloud method usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineCloudStats[];
|
||||
},
|
||||
["getEngineCloudMethodUsage"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export function getEngineCloudMethodUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
) {
|
||||
return cached_getEngineCloudMethodUsage(normalizedParams(params), authToken);
|
||||
}
|
||||
|
||||
const _cached_getWebhookSummary = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams & { webhookId: string },
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function projectContractsCacheTag(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return `${params.teamId}/${params.projectId}/project-contracts`;
|
||||
}
|
||||
@@ -1,58 +1,111 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import pLimit from "p-limit";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import {
|
||||
getProjectContracts,
|
||||
type ProjectContract,
|
||||
} from "@/api/project/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
import { supportedERCs } from "@/utils/supportedERCs";
|
||||
import { serverThirdwebClient } from "../../constants/thirdweb-client.server";
|
||||
import { resolveFunctionSelectors } from "../../lib/selectors";
|
||||
import { projectContractsCacheTag } from "./cache-tag";
|
||||
|
||||
export async function getSortedDeployedContracts(params: {
|
||||
onlyMainnet?: boolean;
|
||||
type GetFilteredProjectContractsParams = {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
deploymentType: string | undefined;
|
||||
}) {
|
||||
};
|
||||
|
||||
type ProjectContractWithIsToken = ProjectContract & { isToken: boolean };
|
||||
|
||||
async function getProjectContractsWithIsTokenInfo(
|
||||
params: GetFilteredProjectContractsParams,
|
||||
): Promise<ProjectContractWithIsToken[]> {
|
||||
const contracts = await getProjectContracts({
|
||||
authToken: params.authToken,
|
||||
deploymentType: params.deploymentType,
|
||||
deploymentType: undefined, // don't pass it to api to fetch all contracts, we'll filter/merge manually here
|
||||
projectId: params.projectId,
|
||||
teamId: params.teamId,
|
||||
});
|
||||
|
||||
const chainIds = Array.from(new Set(contracts.map((c) => c.chainId)));
|
||||
const chains = (
|
||||
await Promise.allSettled(
|
||||
chainIds.map((chainId) =>
|
||||
fetchChainWithLocalOverrides(chainId.toString()),
|
||||
),
|
||||
)
|
||||
)
|
||||
.filter((c) => c.status === "fulfilled")
|
||||
.map((c) => c.value)
|
||||
.filter((c) => c !== null);
|
||||
const assetContracts = contracts.filter((c) => c.deploymentType === "asset");
|
||||
const otherContracts = contracts.filter((c) => c.deploymentType !== "asset");
|
||||
|
||||
const mainnetContracts: ProjectContract[] = [];
|
||||
const testnetContracts: ProjectContract[] = [];
|
||||
// "asset" deployment type contract is assumed to be a "token" type contract already
|
||||
// for others, check if its a "token" type contract (isERC20, isERC721 or isERC1155)
|
||||
|
||||
for (const contract of contracts) {
|
||||
const chain = chains.find(
|
||||
(chain) => contract.chainId === chain.chainId.toString(),
|
||||
const limit = pLimit(20);
|
||||
|
||||
const othersContractWithIsTokenInfo: ProjectContractWithIsToken[] =
|
||||
await Promise.all(
|
||||
otherContracts.map(async (c) => {
|
||||
return limit(async () => {
|
||||
try {
|
||||
const contract = getContract({
|
||||
address: c.contractAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(c.chainId)),
|
||||
client: serverThirdwebClient,
|
||||
});
|
||||
|
||||
const functionSelectors = await resolveFunctionSelectors(contract);
|
||||
const ercs = supportedERCs(functionSelectors);
|
||||
if (ercs.isERC20 || ercs.isERC721 || ercs.isERC1155) {
|
||||
return {
|
||||
...c,
|
||||
isToken: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
isToken: false,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...c,
|
||||
isToken: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (chain && chain.status !== "deprecated") {
|
||||
if (chain.testnet) {
|
||||
testnetContracts.push(contract);
|
||||
} else {
|
||||
mainnetContracts.push(contract);
|
||||
}
|
||||
|
||||
for (const c of othersContractWithIsTokenInfo) {
|
||||
if (c.deploymentType === "asset") {
|
||||
assetContracts.push(c);
|
||||
} else {
|
||||
otherContracts.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
mainnetContracts.sort((a, b) => Number(a.chainId) - Number(b.chainId));
|
||||
|
||||
if (params.onlyMainnet) {
|
||||
return mainnetContracts;
|
||||
}
|
||||
|
||||
testnetContracts.sort((a, b) => Number(a.chainId) - Number(b.chainId));
|
||||
|
||||
return [...mainnetContracts, ...testnetContracts];
|
||||
return [
|
||||
...assetContracts.map(
|
||||
(c) => ({ ...c, isToken: true }) as ProjectContractWithIsToken,
|
||||
),
|
||||
...othersContractWithIsTokenInfo,
|
||||
];
|
||||
}
|
||||
|
||||
export async function getFilteredProjectContracts(
|
||||
params: GetFilteredProjectContractsParams & {
|
||||
type: "token-contracts" | "non-token-contracts";
|
||||
},
|
||||
) {
|
||||
const cached_getProjectContractsWithIsTokenInfo = unstable_cache(
|
||||
getProjectContractsWithIsTokenInfo,
|
||||
["get-project-contracts-with-is-token-info"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
tags: [projectContractsCacheTag(params)],
|
||||
},
|
||||
);
|
||||
|
||||
const contracts = await cached_getProjectContractsWithIsTokenInfo(params);
|
||||
|
||||
if (params.type === "token-contracts") {
|
||||
return contracts.filter((c) => c.isToken);
|
||||
} else {
|
||||
return contracts.filter((c) => !c.isToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { getMemberByAccountId } from "./team-members";
|
||||
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
export type Team = TeamResponse & {
|
||||
stripeCustomerId: string | null;
|
||||
isLegacyPlan: boolean;
|
||||
};
|
||||
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Project } from "@/api/project/projects";
|
||||
|
||||
type X402Fee = {
|
||||
feeRecipient: string;
|
||||
feeBps: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract x402 fee configuration from project's engineCloud service
|
||||
*/
|
||||
export function getX402Fees(project: Project): X402Fee {
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
if (!engineCloudService) {
|
||||
return {
|
||||
feeRecipient: "",
|
||||
feeBps: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
feeRecipient: engineCloudService.x402FeeRecipient || "",
|
||||
feeBps: engineCloudService.x402FeeBPS || 0,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +1,23 @@
|
||||
"use client";
|
||||
import { useId, useMemo } from "react";
|
||||
import { Area, AreaChart, Bar, BarChart } from "recharts";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
import { ArrowUpRightIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type FakeCartData = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
function generateRandomData(): FakeCartData[] {
|
||||
return Array.from({ length: 30 }, () => ({
|
||||
value: Math.floor(Math.random() * 100 + 30),
|
||||
}));
|
||||
}
|
||||
|
||||
const skeletonChartConfig = {
|
||||
value: {
|
||||
color: "hsl(var(--muted)/90%)",
|
||||
label: "Value",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function EmptyChartState({
|
||||
children,
|
||||
type,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
type: "bar" | "area";
|
||||
}) {
|
||||
const barChartData = useMemo(() => generateRandomData(), []);
|
||||
|
||||
export function EmptyChartState({ content }: { content?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative z-0 h-full w-full">
|
||||
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center font-medium text-base text-muted-foreground">
|
||||
{children ?? "No data available"}
|
||||
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
|
||||
{content || (
|
||||
<div className="flex items-center gap-3 flex-col">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<XIcon className="size-4" />
|
||||
</div>
|
||||
<p className="text-sm"> No data available </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkeletonBarChart data={barChartData} type={type} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +26,7 @@ export function LoadingChartState({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30",
|
||||
"pointer-events-none flex h-full w-full items-center justify-center bg-card rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -54,51 +35,42 @@ export function LoadingChartState({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonBarChart(props: {
|
||||
data: FakeCartData[];
|
||||
type: "bar" | "area";
|
||||
export function EmptyChartStateGetStartedCTA(props: {
|
||||
link?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const fillAreaSkeletonId = useId();
|
||||
return (
|
||||
<ChartContainer
|
||||
className="pointer-events-none h-full w-full blur-[5px]"
|
||||
config={skeletonChartConfig}
|
||||
>
|
||||
{props.type === "bar" ? (
|
||||
<BarChart
|
||||
data={props.data}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
<div className="flex items-center flex-col">
|
||||
<div className="rounded-full border p-2 mb-4">
|
||||
<XIcon className="size-4 text-foreground" />
|
||||
</div>
|
||||
<div className="mb-5 space-y-1">
|
||||
<h3 className="text-base text-foreground text-center font-medium">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.description && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.link && (
|
||||
<Button
|
||||
asChild
|
||||
className="rounded-full gap-2"
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
<Bar dataKey="value" fill="var(--color-value)" radius={8} />
|
||||
</BarChart>
|
||||
) : (
|
||||
<AreaChart
|
||||
data={props.data}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillAreaSkeletonId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={"hsl(var(--muted-foreground)/0.5)"}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop offset="95%" stopColor="transparent" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="value"
|
||||
fill={`url(#${fillAreaSkeletonId})`}
|
||||
radius={8}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
type="monotone"
|
||||
/>
|
||||
</AreaChart>
|
||||
<Link href={props.link.href}>
|
||||
{props.link.label} <ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { BuyWidget, SwapWidget } from "thirdweb/react";
|
||||
import {
|
||||
BuyWidget,
|
||||
type SupportedFiatCurrency,
|
||||
SwapWidget,
|
||||
} from "thirdweb/react";
|
||||
import type { Wallet } from "thirdweb/wallets";
|
||||
import {
|
||||
reportAssetBuyCancelled,
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
|
||||
} from "@/constants/public-envs";
|
||||
@@ -30,9 +35,10 @@ import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { appMetadata } from "../../constants/connect";
|
||||
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
|
||||
|
||||
type PageType = "asset" | "bridge" | "chain";
|
||||
type PageType = "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
|
||||
export type BuyAndSwapEmbedProps = {
|
||||
persistTokenSelections?: boolean;
|
||||
buyTab:
|
||||
| {
|
||||
buyToken:
|
||||
@@ -64,6 +70,7 @@ export type BuyAndSwapEmbedProps = {
|
||||
| undefined;
|
||||
pageType: PageType;
|
||||
wallets?: Wallet[];
|
||||
currency?: SupportedFiatCurrency;
|
||||
};
|
||||
|
||||
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
@@ -92,7 +99,9 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
|
||||
: props.pageType === "chain"
|
||||
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
|
||||
: undefined,
|
||||
: props.pageType === "bridge-iframe"
|
||||
? NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID
|
||||
: undefined,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
});
|
||||
@@ -115,6 +124,7 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
|
||||
{tab === "buy" && (
|
||||
<BuyWidget
|
||||
currency={props.currency || "USD"}
|
||||
amount={props.buyTab?.buyToken?.amount || "1"}
|
||||
chain={
|
||||
props.buyTab?.buyToken?.chainId
|
||||
@@ -238,6 +248,8 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
|
||||
{tab === "swap" && (
|
||||
<SwapWidget
|
||||
currency={props.currency || "USD"}
|
||||
persistTokenSelections={props.persistTokenSelections}
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none"
|
||||
|
||||
@@ -34,7 +34,8 @@ export function ExportToCSVButton(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("flex items-center gap-2 border text-xs", props.className)}
|
||||
className={cn("gap-2 rounded-full", props.className)}
|
||||
size="sm"
|
||||
disabled={props.disabled || exportMutation.isPending}
|
||||
onClick={async () => {
|
||||
exportMutation.mutate();
|
||||
@@ -42,16 +43,11 @@ export function ExportToCSVButton(props: {
|
||||
variant="outline"
|
||||
>
|
||||
{exportMutation.isPending ? (
|
||||
<>
|
||||
Downloading
|
||||
<Spinner className="size-3" />
|
||||
</>
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon className="size-3" />
|
||||
Export as CSV
|
||||
</>
|
||||
<DownloadIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
Export
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ function Variants() {
|
||||
key={currentPlan}
|
||||
requiredPlan={requiredPlan}
|
||||
teamSlug="foo"
|
||||
isLegacyPlan={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
))}
|
||||
|
||||
@@ -14,6 +14,7 @@ type GatedSwitchProps = {
|
||||
trackingLabel?: string;
|
||||
currentPlan: Team["billingPlan"];
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
teamSlug: string;
|
||||
switchProps?: SwitchProps;
|
||||
};
|
||||
@@ -33,7 +34,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
<div className="w-full min-w-[280px]">
|
||||
<h3 className="font-medium text-base">
|
||||
<span className="capitalize">
|
||||
{getTeamPlanBadgeLabel(props.requiredPlan)}+
|
||||
{getTeamPlanBadgeLabel(props.requiredPlan, false)}+
|
||||
</span>{" "}
|
||||
plan required
|
||||
</h3>
|
||||
@@ -48,7 +49,8 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan)} plan
|
||||
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan, false)}{" "}
|
||||
plan
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -61,6 +63,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
{isUpgradeRequired && (
|
||||
<TeamPlanBadge
|
||||
plan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
postfix="+"
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
|
||||
@@ -23,28 +23,42 @@ const teamPlanToBadgeVariant: Record<
|
||||
starter: "warning",
|
||||
};
|
||||
|
||||
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
export function getTeamPlanBadgeLabel(
|
||||
plan: Team["billingPlan"],
|
||||
isLegacyPlan: boolean,
|
||||
) {
|
||||
if (plan === "growth_legacy") {
|
||||
return "Growth - Legacy";
|
||||
}
|
||||
|
||||
if (isLegacyPlan) {
|
||||
return `${plan} - Legacy`;
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function TeamPlanBadge(props: {
|
||||
teamSlug: string;
|
||||
plan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
className?: string;
|
||||
postfix?: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
|
||||
function handleNavigateToBilling(e: React.MouseEvent | React.KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (props.isLegacyPlan) {
|
||||
router.push(`/team/${props.teamSlug}/~/billing`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.plan !== "free") {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
|
||||
}
|
||||
|
||||
@@ -57,11 +71,13 @@ export function TeamPlanBadge(props: {
|
||||
handleNavigateToBilling(e);
|
||||
}
|
||||
}}
|
||||
role={props.plan === "free" ? "button" : undefined}
|
||||
tabIndex={props.plan === "free" ? 0 : undefined}
|
||||
variant={teamPlanToBadgeVariant[props.plan]}
|
||||
role={props.plan === "free" || props.isLegacyPlan ? "button" : undefined}
|
||||
tabIndex={props.plan === "free" || props.isLegacyPlan ? 0 : undefined}
|
||||
variant={
|
||||
props.isLegacyPlan ? "warning" : teamPlanToBadgeVariant[props.plan]
|
||||
}
|
||||
>
|
||||
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
|
||||
{`${getTeamPlanBadgeLabel(props.plan, props.isLegacyPlan)}${props.postfix || ""}`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
|
||||
yAxis?: boolean;
|
||||
xAxis?: {
|
||||
sameDay?: boolean;
|
||||
showHour?: boolean;
|
||||
};
|
||||
|
||||
variant?: "stacked" | "individual";
|
||||
@@ -90,9 +90,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState type="area">
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
<EmptyChartState content={props.emptyChartState} />
|
||||
) : (
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
@@ -112,7 +110,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
tickFormatter={(value) =>
|
||||
format(
|
||||
new Date(value),
|
||||
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
|
||||
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
}
|
||||
tickLine={false}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type { InAppWalletAuth } from "thirdweb/wallets";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import type { InAppWalletStats } from "@/types/analytics";
|
||||
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
|
||||
import { AutoMergeBarChart, type StatData } from "./automerge-barchart";
|
||||
|
||||
const meta = {
|
||||
component: Component,
|
||||
title: "Charts/InAppWallets",
|
||||
title: "Charts/AutoMergeBarChart",
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
@@ -22,57 +21,106 @@ function Component() {
|
||||
"This is an example of a description about user wallet usage chart";
|
||||
return (
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<BadgeContainer label="30 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<BadgeContainer label="10 days, view more variant">
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={"#"}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(30)}
|
||||
stats={createStatsStub(10)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="30 days">
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
stats={createStatsStub(30)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(60)}
|
||||
stats={createStatsStub(60)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="120 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(120)}
|
||||
stats={createStatsStub(120)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(10)}
|
||||
stats={createStatsStub(10)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Empty List">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(0)}
|
||||
stats={createStatsStub(0)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Pending">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={[]}
|
||||
stats={[]}
|
||||
isPending={true}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
@@ -111,17 +159,16 @@ const pickRandomAuthMethod = () => {
|
||||
return capitalized;
|
||||
};
|
||||
|
||||
function createInAppWalletStatsStub(days: number): InAppWalletStats[] {
|
||||
const stubbedData: InAppWalletStats[] = [];
|
||||
function createStatsStub(days: number): StatData[] {
|
||||
const stubbedData: StatData[] = [];
|
||||
|
||||
let d = days;
|
||||
while (d !== 0) {
|
||||
const uniqueWallets = Math.floor(Math.random() * 100);
|
||||
stubbedData.push({
|
||||
authenticationMethod: pickRandomAuthMethod(),
|
||||
label: pickRandomAuthMethod(),
|
||||
date: new Date(2024, 11, d).toLocaleString(),
|
||||
uniqueWalletsConnected: uniqueWallets,
|
||||
newUsers: Math.floor(Math.random() * 100) + 1,
|
||||
count: uniqueWallets,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.7) {
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { TotalValueChartHeader } from "./chart-header";
|
||||
|
||||
export type StatData = {
|
||||
date: string;
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
};
|
||||
|
||||
const defaultLabel = "Unknown";
|
||||
|
||||
// merges the multiple stats in single bar if they share the same date
|
||||
// shows the top `maxLabelsToShow` labels, merge the rest into "Others"
|
||||
|
||||
export function AutoMergeBarChart(props: {
|
||||
stats: StatData[];
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
emptyChartState: React.ReactElement | undefined;
|
||||
description: string | undefined;
|
||||
maxLabelsToShow: number;
|
||||
exportButton:
|
||||
| {
|
||||
fileName: string;
|
||||
}
|
||||
| undefined;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
const { stats, maxLabelsToShow } = props;
|
||||
|
||||
const { chartConfig, chartData } = useMemo(() => {
|
||||
const _chartConfig: ChartConfig = {};
|
||||
const _chartDataMap: Map<string, ChartData> = new Map();
|
||||
const labelToCountMap: Map<string, number> = new Map();
|
||||
// for each stat, add it in _chartDataMap
|
||||
for (const stat of stats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData && stat.count > 0) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: stat.date,
|
||||
[stat.label || defaultLabel]: stat.count,
|
||||
} as ChartData);
|
||||
} else if (chartData) {
|
||||
chartData[stat.label || defaultLabel] =
|
||||
(chartData[stat.label || defaultLabel] || 0) + stat.count;
|
||||
}
|
||||
|
||||
labelToCountMap.set(
|
||||
stat.label || defaultLabel,
|
||||
stat.count + (labelToCountMap.get(stat.label || defaultLabel) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const labelsSorted = Array.from(labelToCountMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((w) => w[0]);
|
||||
|
||||
const labelsToShow = labelsSorted.slice(0, maxLabelsToShow);
|
||||
const labelsToShowAsOther = labelsSorted.slice(maxLabelsToShow);
|
||||
|
||||
// replace chainIdsToTagAsOther chainId with "other"
|
||||
for (const data of _chartDataMap.values()) {
|
||||
for (const dataKey in data) {
|
||||
if (labelsToShowAsOther.includes(dataKey)) {
|
||||
data.others = (data.others || 0) + (data[dataKey] || 0);
|
||||
delete data[dataKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labelsToShow.forEach((walletType, i) => {
|
||||
_chartConfig[walletType] = {
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
label: labelsToShow[i],
|
||||
};
|
||||
});
|
||||
|
||||
// Add Other
|
||||
if (labelsToShow.length >= maxLabelsToShow) {
|
||||
labelsToShow.push("others");
|
||||
_chartConfig.others = {
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
label: "Others",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
chartConfig: _chartConfig,
|
||||
chartData: Array.from(_chartDataMap.values()).sort(
|
||||
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
|
||||
),
|
||||
};
|
||||
}, [stats, maxLabelsToShow]);
|
||||
|
||||
const uniqueLabels = Object.keys(chartConfig);
|
||||
|
||||
const total = useMemo(() => {
|
||||
return props.stats.reduce((acc, stat) => acc + stat.count, 0);
|
||||
}, [props.stats]);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="h-[275px] w-full"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
props.viewMoreLink ? (
|
||||
<TotalValueChartHeader
|
||||
total={total}
|
||||
title={props.title}
|
||||
isPending={props.isPending}
|
||||
viewMoreLink={props.viewMoreLink}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative px-6 pt-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg tracking-tight leading-none">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.description && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.exportButton && props.stats.length > 0 && (
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
fileName={props.exportButton.fileName}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Time", ...uniqueLabels];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
new Date(time).toISOString(),
|
||||
...uniqueLabels.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={props.emptyChartState}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return format(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
variant="stacked"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,10 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
emptyChartState?: React.ReactElement;
|
||||
className?: string;
|
||||
xAxis?: {
|
||||
showHour?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
@@ -55,7 +59,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={cn("overflow-hidden", props.className)}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
@@ -74,16 +78,19 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState type="bar">
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
<EmptyChartState content={props.emptyChartState} />
|
||||
) : (
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
tickFormatter={(value) => format(new Date(value), "MMM d")}
|
||||
tickFormatter={(value) =>
|
||||
format(
|
||||
new Date(value),
|
||||
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
|
||||
export function TotalValueChartHeader(props: {
|
||||
total: number;
|
||||
title: string;
|
||||
isUsd?: boolean;
|
||||
isPending: boolean;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1 p-6 pb-0 flex justify-between items-start gap-3">
|
||||
<div>
|
||||
<SkeletonContainer
|
||||
loadedData={!props.isPending ? props.total : undefined}
|
||||
skeletonData={100}
|
||||
render={(value) => {
|
||||
return (
|
||||
<p className="text-3xl font-semibold tracking-tight">
|
||||
{props.isUsd
|
||||
? compactUSDFormatter.format(value)
|
||||
: compactNumberFormatter.format(value)}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<h3 className="text-muted-foreground">{props.title}</h3>
|
||||
</div>
|
||||
|
||||
{props.viewMoreLink && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link href={props.viewMoreLink}>
|
||||
<span>View More</span>
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const compactUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
@@ -22,6 +22,7 @@ interface UpsellWrapperProps {
|
||||
isLocked?: boolean;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
currentPlan?: Team["billingPlan"];
|
||||
isLegacyPlan?: boolean;
|
||||
featureName: string;
|
||||
featureDescription: string;
|
||||
benefits?: {
|
||||
@@ -41,6 +42,7 @@ export function UpsellWrapper({
|
||||
featureDescription,
|
||||
benefits = [],
|
||||
className,
|
||||
isLegacyPlan = false,
|
||||
}: UpsellWrapperProps) {
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
@@ -66,6 +68,7 @@ export function UpsellWrapper({
|
||||
featureDescription={featureDescription}
|
||||
featureName={featureName}
|
||||
requiredPlan={requiredPlan}
|
||||
isLegacyPlan={isLegacyPlan}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
@@ -79,6 +82,7 @@ export function UpsellContent(props: {
|
||||
featureDescription: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
currentPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
benefits?: {
|
||||
description: string;
|
||||
status: "available" | "soon";
|
||||
@@ -96,6 +100,7 @@ export function UpsellContent(props: {
|
||||
plan={props.requiredPlan}
|
||||
postfix=" Feature"
|
||||
teamSlug={props.teamSlug}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
|
||||
|
||||
@@ -62,6 +62,8 @@ function Story() {
|
||||
client={storybookThirdwebClient}
|
||||
contracts={[]}
|
||||
pageSize={10}
|
||||
projectId="project-id"
|
||||
teamId="team-id"
|
||||
projectSlug={projectSlug}
|
||||
removeContractFromProject={removeContractStub}
|
||||
teamSlug={teamSlug}
|
||||
@@ -74,6 +76,8 @@ function Story() {
|
||||
client={storybookThirdwebClient}
|
||||
contracts={[popularPolygonNFTs[0] as ProjectContract]}
|
||||
pageSize={10}
|
||||
projectId="project-id"
|
||||
teamId="team-id"
|
||||
projectSlug={projectSlug}
|
||||
removeContractFromProject={removeContractStub}
|
||||
teamSlug={teamSlug}
|
||||
@@ -86,6 +90,8 @@ function Story() {
|
||||
client={storybookThirdwebClient}
|
||||
contracts={popularPolygonNFTs}
|
||||
pageSize={10}
|
||||
projectId="project-id"
|
||||
teamId="team-id"
|
||||
projectSlug={projectSlug}
|
||||
removeContractFromProject={removeContractStub}
|
||||
teamSlug={teamSlug}
|
||||
@@ -95,6 +101,8 @@ function Story() {
|
||||
|
||||
<BadgeContainer label="10 Contracts, 2 chains">
|
||||
<ContractTableUI
|
||||
projectId="project-id"
|
||||
teamId="team-id"
|
||||
client={storybookThirdwebClient}
|
||||
contracts={[...popularPolygonNFTs, ...EthereumPopularNFTs]}
|
||||
pageSize={10}
|
||||
@@ -114,6 +122,8 @@ function Story() {
|
||||
...basePopularTokens,
|
||||
]}
|
||||
pageSize={10}
|
||||
projectId="project-id"
|
||||
teamId="team-id"
|
||||
projectSlug={projectSlug}
|
||||
removeContractFromProject={removeContractStub}
|
||||
teamSlug={teamSlug}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { removeContractFromProject } from "@/hooks/project-contracts";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { revalidateCacheTagAction } from "../../../actions/revalidate";
|
||||
import { projectContractsCacheTag } from "../../../api/project/cache-tag";
|
||||
import {
|
||||
ContractNameCell,
|
||||
ContractTypeCell,
|
||||
@@ -57,6 +59,8 @@ export function ContractTable(props: {
|
||||
client={props.client}
|
||||
contracts={props.contracts}
|
||||
pageSize={props.pageSize}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
projectSlug={props.projectSlug}
|
||||
removeContractFromProject={async (contractId) => {
|
||||
await removeContractFromProject({
|
||||
@@ -77,6 +81,8 @@ export function ContractTableUI(props: {
|
||||
removeContractFromProject: (contractId: string) => Promise<void>;
|
||||
client: ThirdwebClient;
|
||||
variant: "asset" | "contract";
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
@@ -243,6 +249,13 @@ export function ContractTableUI(props: {
|
||||
<ContractActionsCell
|
||||
contractId={contract.id}
|
||||
onContractRemoved={() => {
|
||||
revalidateCacheTagAction(
|
||||
projectContractsCacheTag({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
setDeletedContractIds((v) => {
|
||||
return [...v, contract.id];
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function Component() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}},
|
||||
);
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function Component() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}}
|
||||
);
|
||||
|
||||
@@ -214,7 +214,7 @@ function App() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}}
|
||||
);`,
|
||||
dotnet: `using Thirdweb;
|
||||
@@ -272,7 +272,7 @@ export default function Component() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}}
|
||||
);
|
||||
|
||||
@@ -1103,7 +1103,7 @@ function AccountFactorySection(props: {
|
||||
<AlertDescription>
|
||||
The recommended way to use account factories is to integrate the{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/transactions/sponsor"
|
||||
href="https://portal.thirdweb.com/wallets/sponsor-gas"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -178,7 +178,7 @@ export async function ContractCard({
|
||||
{auditLink && !isBeta && (
|
||||
<>
|
||||
<Link
|
||||
className="relative z-1 flex items-center gap-1 font-medium text-sm text-success-text hover:underline"
|
||||
className="relative z-10 flex items-center gap-1 font-medium text-sm text-success-text hover:underline"
|
||||
href={auditLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowDownToLineIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { getAddress, isAddress, type ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getAddress,
|
||||
getContract,
|
||||
isAddress,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { useActiveWalletChain } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
@@ -31,6 +43,11 @@ import { Spinner } from "@/components/ui/Spinner";
|
||||
import { useChainSlug } from "@/hooks/chains/chainSlug";
|
||||
import { useAddContractToProject } from "@/hooks/project-contracts";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { revalidateCacheTagAction } from "../../../actions/revalidate";
|
||||
import { projectContractsCacheTag } from "../../../api/project/cache-tag";
|
||||
import { resolveFunctionSelectors } from "../../../lib/selectors";
|
||||
import { supportedERCs } from "../../../utils/supportedERCs";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../../ui/alert";
|
||||
|
||||
type ImportModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -42,6 +59,7 @@ type ImportModalProps = {
|
||||
client: ThirdwebClient;
|
||||
type: "contract" | "asset";
|
||||
onSuccess?: () => void;
|
||||
allowedContractType: "token" | "non-token";
|
||||
};
|
||||
|
||||
export const ImportModal: React.FC<ImportModalProps> = (props) => {
|
||||
@@ -75,6 +93,7 @@ export const ImportModal: React.FC<ImportModalProps> = (props) => {
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
type={props.type}
|
||||
allowedContractType={props.allowedContractType}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -105,6 +124,7 @@ function ImportForm(props: {
|
||||
client: ThirdwebClient;
|
||||
type: "contract" | "asset";
|
||||
onSuccess?: () => void;
|
||||
allowedContractType: "token" | "non-token";
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const activeChainId = useActiveWalletChain()?.id;
|
||||
@@ -119,9 +139,14 @@ function ImportForm(props: {
|
||||
const chainSlug = useChainSlug(form.watch("chainId"));
|
||||
const addContractToProject = useAddContractToProject();
|
||||
|
||||
const [notAllowedError, setNotAllowedError] = useState(false);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onChange={() => {
|
||||
setNotAllowedError(false);
|
||||
}}
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
const { chainId } = data;
|
||||
let contractAddress: string;
|
||||
@@ -158,6 +183,27 @@ function ImportForm(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the contract matches the requirement
|
||||
const contract = getContract({
|
||||
address: contractAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(chainId),
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
const functionSelectors = await resolveFunctionSelectors(contract);
|
||||
const ercs = supportedERCs(functionSelectors);
|
||||
|
||||
const isToken = ercs.isERC20 || ercs.isERC721 || ercs.isERC1155;
|
||||
|
||||
if (
|
||||
(props.allowedContractType === "token" && !isToken) ||
|
||||
(props.allowedContractType === "non-token" && isToken)
|
||||
) {
|
||||
setNotAllowedError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
addContractToProject.mutate(
|
||||
{
|
||||
chainId: chainId.toString(),
|
||||
@@ -177,6 +223,12 @@ function ImportForm(props: {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
revalidateCacheTagAction(
|
||||
projectContractsCacheTag({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
}),
|
||||
);
|
||||
router.refresh();
|
||||
toast.success("Contract imported successfully");
|
||||
props.onSuccess?.();
|
||||
@@ -217,6 +269,34 @@ function ImportForm(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notAllowedError && (
|
||||
<div className="px-6 pt-6 text-sm text-destructive-text">
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle> Invalid Contract </AlertTitle>
|
||||
<AlertDescription>
|
||||
{props.allowedContractType === "token" && (
|
||||
<span>
|
||||
This contract is not a token contract. <br /> Only ERC20,
|
||||
ERC721 and ERC1155 contracts can be imported as tokens.
|
||||
</span>
|
||||
)}
|
||||
{props.allowedContractType === "non-token" && (
|
||||
<span>
|
||||
This contract is a token contract. <br /> Go to the{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens`}
|
||||
>
|
||||
Tokens
|
||||
</UnderlineLink>{" "}
|
||||
page to import in dashboard.
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end border-t bg-card p-6">
|
||||
{addContractToProject.isSuccess &&
|
||||
addContractToProject.data?.result ? (
|
||||
|
||||
@@ -64,7 +64,7 @@ const truncateIdentifier = (value: string) => {
|
||||
return `${value.slice(0, 8)}...${value.slice(-4)}`;
|
||||
};
|
||||
|
||||
export function InAppWalletUsersPageContent(
|
||||
export function UserWalletsTable(
|
||||
props: {
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
@@ -288,83 +288,81 @@ export function InAppWalletUsersPageContent(
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
isLoading={isSearching}
|
||||
hasResults={hasSearchResults}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Download as .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
{hasSearchResults ? (
|
||||
<SearchResults results={searchResults} client={props.client} />
|
||||
) : (
|
||||
<>
|
||||
<TWTable
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="User wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={wallets.length === 0 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => p + 1)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
isLoading={isSearching}
|
||||
hasResults={hasSearchResults}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Download as .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{hasSearchResults ? (
|
||||
<SearchResults results={searchResults} client={props.client} />
|
||||
) : (
|
||||
<>
|
||||
<TWTable
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="User wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={wallets.length === 0 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => p + 1)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -41,3 +41,6 @@ export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
|
||||
|
||||
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
THIRDWEB_BRIDGE_URL,
|
||||
THIRDWEB_BUNDLER_DOMAIN,
|
||||
THIRDWEB_ENGINE_CLOUD_URL,
|
||||
THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
THIRDWEB_PAY_DOMAIN,
|
||||
@@ -38,6 +39,7 @@ export function getConfiguredThirdwebClient(options: {
|
||||
rpc: THIRDWEB_RPC_DOMAIN,
|
||||
social: THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
storage: THIRDWEB_STORAGE_DOMAIN,
|
||||
engineCloud: new URL(THIRDWEB_ENGINE_CLOUD_URL).hostname,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,3 +25,6 @@ export const THIRDWEB_INSIGHT_API_DOMAIN =
|
||||
|
||||
export const THIRDWEB_BRIDGE_URL =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb-dev.com";
|
||||
|
||||
export const THIRDWEB_ENGINE_CLOUD_URL =
|
||||
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "https://engine.thirdweb-dev.com";
|
||||
|
||||
@@ -30,6 +30,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
billingPlan: billingPlan,
|
||||
billingStatus: "validPayment",
|
||||
canCreatePublicChains: null,
|
||||
isLegacyPlan: false,
|
||||
capabilities: {
|
||||
bundler: {
|
||||
enabled: true,
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface WalletStats {
|
||||
walletType: string;
|
||||
}
|
||||
|
||||
export type WalletStatsWithName = WalletStats & {
|
||||
walletName: string;
|
||||
};
|
||||
|
||||
export interface InAppWalletStats {
|
||||
date: string;
|
||||
authenticationMethod: string;
|
||||
@@ -23,6 +27,8 @@ export interface UserOpStats {
|
||||
successful: number;
|
||||
failed: number;
|
||||
sponsoredUsd: number;
|
||||
gasUnits: number;
|
||||
avgGasPrice: number;
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
@@ -33,13 +39,6 @@ export interface TransactionStats {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface EngineCloudStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
pathname: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface UniversalBridgeStats {
|
||||
date: string;
|
||||
chainId: number;
|
||||
@@ -102,36 +101,20 @@ export interface X402SettlementsOverall {
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
export interface X402SettlementsByChainId {
|
||||
date: string;
|
||||
export interface X402SettlementsByChainId extends X402SettlementsOverall {
|
||||
chainId: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
export interface X402SettlementsByPayer {
|
||||
date: string;
|
||||
export interface X402SettlementsByPayer extends X402SettlementsOverall {
|
||||
payer: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
interface X402SettlementsByReceiver {
|
||||
date: string;
|
||||
interface X402SettlementsByReceiver extends X402SettlementsOverall {
|
||||
receiver: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
export interface X402SettlementsByResource {
|
||||
date: string;
|
||||
export interface X402SettlementsByResource extends X402SettlementsOverall {
|
||||
resource: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
interface X402SettlementsByAsset {
|
||||
|
||||
@@ -13,9 +13,16 @@ export type ProductSKU =
|
||||
| "usage:in_app_wallet"
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
// dedicated relayer SKUs
|
||||
| DedicatedRelayerSKU
|
||||
| null;
|
||||
|
||||
export type ChainInfraSKU =
|
||||
| "chain:infra:rpc"
|
||||
| "chain:infra:insight"
|
||||
| "chain:infra:account_abstraction";
|
||||
|
||||
export type DedicatedRelayerSKU =
|
||||
| "product:dedicated_relayer_standard"
|
||||
| "product:dedicated_relayer_premium"
|
||||
| "product:dedicated_relayer_enterprise";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatTickerNumber = (value: number) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const usdCurrencyFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6, // prefix with $
|
||||
minimumFractionDigits: 0, // don't show decimal places if value is a whole number
|
||||
notation: "compact", // at max 2 decimal places
|
||||
roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations
|
||||
style: "currency", // shows 1.2M instead of 1,200,000, 1.2B instead of 1,200,000,000
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
style: "currency",
|
||||
});
|
||||
|
||||
export const toUSD = (value: number) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/(app)/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -192,6 +193,20 @@ export function FaucetButton({
|
||||
|
||||
// Can not claim
|
||||
if (canClaimFaucetQuery.data && canClaimFaucetQuery.data.canClaim === false) {
|
||||
if (canClaimFaucetQuery.data.type === "paid-plan-required") {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="w-full gap-2 whitespace-pre-wrap h-auto min-h-10 text-center bg-background"
|
||||
variant="outline"
|
||||
>
|
||||
<Link href="/team/~/~/billing">
|
||||
Faucet is not available on Free plan. Upgrade your plan to continue
|
||||
<ArrowUpRightIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button className="!opacity-100 w-full " disabled variant="outline">
|
||||
{canClaimFaucetQuery.data.type === "throttle" && (
|
||||
@@ -203,10 +218,6 @@ export function FaucetButton({
|
||||
|
||||
{canClaimFaucetQuery.data.type === "unsupported-chain" &&
|
||||
"Faucet is empty right now"}
|
||||
|
||||
{/* TODO: add an upsell path here to subscribe to one of these plans */}
|
||||
{canClaimFaucetQuery.data.type === "paid-plan-required" &&
|
||||
"Faucet is only available on Starter, Growth, Scale and Pro plans."}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import { contractType as getContractType } from "thirdweb/extensions/thirdweb";
|
||||
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { supportedERCs } from "@/utils/supportedERCs";
|
||||
import { getValidReward } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards";
|
||||
import {
|
||||
isERC20ClaimConditionsSupported,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
isPermissionsEnumerableSupported,
|
||||
isPermissionsSupported,
|
||||
} from "./detectedFeatures/permissions";
|
||||
import { supportedERCs } from "./detectedFeatures/supportedERCs";
|
||||
import { type EmbedTypeToShow, getEmbedTypeToShow } from "./getEmbedTypeToShow";
|
||||
|
||||
type ContractPageMetadata = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { supportedERCs } from "@/utils/supportedERCs";
|
||||
import { isClaimConditionsSupported } from "./detectedFeatures/claimConditions";
|
||||
import {
|
||||
isDirectListingSupported,
|
||||
isEnglishAuctionSupported,
|
||||
} from "./detectedFeatures/marketplace";
|
||||
import { supportedERCs } from "./detectedFeatures/supportedERCs";
|
||||
|
||||
export type EmbedTypeToShow =
|
||||
| "marketplace-v3"
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import { isGetNFTsSupported as ERC721_isGetNFTsSupported } from "thirdweb/extensions/erc721";
|
||||
import { isGetNFTsSupported as ERC1155_isGetNFTsSupported } from "thirdweb/extensions/erc1155";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { supportedERCs } from "./detectedFeatures/supportedERCs";
|
||||
import { supportedERCs } from "@/utils/supportedERCs";
|
||||
|
||||
export type NewPublicPageType = "erc20" | "erc1155" | "erc721";
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AccountsPage(props: {
|
||||
View list of smart accounts that have been created for this
|
||||
contract.{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/transactions/sponsor"
|
||||
href="https://portal.thirdweb.com/wallets/sponsor-gas"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getTeams } from "@/api/team/get-team";
|
||||
import type { MinimalTeamsAndProjects } from "@/components/contracts/import-contract/types";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { supportedERCs } from "@/utils/supportedERCs";
|
||||
import { shortenIfAddress } from "@/utils/usedapp-external";
|
||||
import type { ProjectMeta } from "../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
@@ -14,7 +15,6 @@ import { ConfigureCustomChain } from "./_layout/ConfigureCustomChain";
|
||||
import { getContractMetadataHeaderData } from "./_layout/contract-metadata";
|
||||
import { ContractPageLayout } from "./_layout/contract-page-layout";
|
||||
import { ContractPageLayoutClient } from "./_layout/contract-page-layout.client";
|
||||
import { supportedERCs } from "./_utils/detectedFeatures/supportedERCs";
|
||||
import { getContractPageParamsInfo } from "./_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "./_utils/getContractPageMetadata";
|
||||
import { getContractPageSidebarLinks } from "./_utils/getContractPageSidebarLinks";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BotIcon, CoinsIcon, FuelIcon, HardDriveIcon } from "lucide-react";
|
||||
import { BridgeIcon } from "@/icons/BridgeIcon";
|
||||
import { InsightIcon } from "@/icons/InsightIcon";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { RPCIcon } from "@/icons/RPCIcon";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
@@ -54,6 +55,12 @@ export const services = [
|
||||
link: "https://thirdweb.com/x402",
|
||||
name: "x402",
|
||||
},
|
||||
{
|
||||
icon: InsightIcon,
|
||||
id: "insight",
|
||||
link: "https://thirdweb.com/gateway",
|
||||
name: "Indexer",
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
id: ChainSupportedService;
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -1,130 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
ContractCard,
|
||||
ContractCardSkeleton,
|
||||
} from "@/components/contracts/contract-card/contract-card";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { DeployUpsellCard } from "../components/upsells/deploy-your-own";
|
||||
import { getCategory } from "../data";
|
||||
|
||||
type ExploreCategoryPageProps = {
|
||||
params: Promise<{
|
||||
category: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
props: ExploreCategoryPageProps,
|
||||
): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const category = getCategory(params.category);
|
||||
if (!category) {
|
||||
notFound();
|
||||
}
|
||||
return {
|
||||
description: `${category.description} Deploy with one click to Ethereum, Polygon, Optimism, and other EVM blockchains with thirdweb.`,
|
||||
title: `${category.name} Smart Contracts | Explore`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ExploreCategoryPage(
|
||||
props: ExploreCategoryPageProps,
|
||||
) {
|
||||
const params = await props.params;
|
||||
const category = getCategory(params.category);
|
||||
if (!category) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Breadcrumb className="border-border border-b px-6 py-4">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/explore">Explore</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
{category.displayName || category.name}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="container flex flex-col gap-4 py-8">
|
||||
<h1 className="mb-3 font-bold text-3xl tracking-tighter lg:text-5xl">
|
||||
{category.displayName || category.name}
|
||||
</h1>
|
||||
<p className="max-w-screen-md text-base text-muted-foreground lg:text-lg">
|
||||
{category.description}{" "}
|
||||
{category.learnMore && (
|
||||
<Link href={category.learnMore}>Learn more</Link>
|
||||
)}
|
||||
</p>
|
||||
<div className="h-10" />
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
{category.contracts.map((publishedContractId) => {
|
||||
const publisher: string | undefined = Array.isArray(
|
||||
publishedContractId,
|
||||
)
|
||||
? publishedContractId[0].split("/")[0]
|
||||
: publishedContractId.split("/")[0];
|
||||
const contractId: string | undefined = Array.isArray(
|
||||
publishedContractId,
|
||||
)
|
||||
? publishedContractId[0].split("/")[1]
|
||||
: publishedContractId.split("/")[1];
|
||||
const modules = Array.isArray(publishedContractId)
|
||||
? publishedContractId[1]
|
||||
: undefined;
|
||||
const overrides = Array.isArray(publishedContractId)
|
||||
? publishedContractId[2]
|
||||
: undefined;
|
||||
|
||||
if (!publisher || !contractId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<ContractCardSkeleton />}
|
||||
key={publisher + contractId + overrides?.title}
|
||||
>
|
||||
<ContractCard
|
||||
contractId={contractId}
|
||||
descriptionOverride={overrides?.description}
|
||||
isBeta={category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
moduleId: m.split("/")[1] || "",
|
||||
publisher: m.split("/")[0] || "",
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
publisher={publisher}
|
||||
titleOverride={overrides?.title}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-16" />
|
||||
<DeployUpsellCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
@@ -7,48 +6,45 @@ import {
|
||||
} from "@/components/contracts/contract-card/contract-card";
|
||||
import type { ExploreCategory } from "../../data";
|
||||
|
||||
export function ContractRow({ category }: { category: ExploreCategory }) {
|
||||
export function ContractRow({
|
||||
category,
|
||||
prefixCard,
|
||||
}: {
|
||||
category: ExploreCategory;
|
||||
prefixCard?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
{/* Title, Description + View all link */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<header className="flex flex-col gap-1.5">
|
||||
<Link href={`/explore/${category.id}`}>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
{category.displayName || category.name}
|
||||
</h2>
|
||||
</Link>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
{category.displayName || category.name}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{category.description}{" "}
|
||||
{category.learnMore && (
|
||||
<Link
|
||||
className="inline text-link-foreground"
|
||||
href={category.learnMore}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
{(category.description || category.learnMore) && (
|
||||
<p className="text-muted-foreground">
|
||||
{category.description}{" "}
|
||||
{category.learnMore && (
|
||||
<Link
|
||||
className="inline text-link-foreground"
|
||||
href={category.learnMore}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{category.contracts.length > 6 && (
|
||||
<Link
|
||||
className="flex shrink-0 items-center gap-1 text-base text-link-foreground hover:text-foreground"
|
||||
href={`/explore/${category.id}`}
|
||||
>
|
||||
View all
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
<div className="relative z-0 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{category.contracts.slice(0, 6).map((publishedContractId) => {
|
||||
{prefixCard}
|
||||
{category.contracts.map((publishedContractId) => {
|
||||
const publisher: string | undefined = Array.isArray(
|
||||
publishedContractId,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { reportTokenUpsellClicked } from "@/analytics/report";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const thirdwebEns = {
|
||||
name: "thirdweb.eth",
|
||||
image: "https://euc.li/thirdweb.eth",
|
||||
};
|
||||
|
||||
function TokenContractCard(props: {
|
||||
name: string;
|
||||
description: string;
|
||||
link: string;
|
||||
trackingType: "nft" | "coin";
|
||||
}) {
|
||||
return (
|
||||
<article className="relative flex min-h-[220px] flex-col rounded-lg border border-border bg-card p-4 hover:border-active-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1 font-medium text-sm text-success-text">
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
Audited
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-3.5" />
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
<Link
|
||||
className="cursor-pointer before:absolute before:inset-0 before:z-0"
|
||||
href={props.link}
|
||||
onClick={() => {
|
||||
reportTokenUpsellClicked({
|
||||
assetType: props.trackingType,
|
||||
pageType: "explore",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{props.name}
|
||||
</Link>
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-sm leading-5">
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto relative z-1 flex justify-between gap-2 pt-3">
|
||||
<Link
|
||||
className="flex shrink-0 items-center gap-1.5 hover:underline"
|
||||
href={`/${thirdwebEns.name}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Img
|
||||
className="size-5 rounded-full object-cover"
|
||||
src={thirdwebEns.image}
|
||||
/>
|
||||
|
||||
<span className="text-xs"> {thirdwebEns.name}</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
asChild
|
||||
className="relative z-10 h-auto gap-1.5 px-2.5 py-1.5 text-xs hover:bg-inverted hover:text-inverted-foreground"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link href={props.link}>
|
||||
<RocketIcon className="size-3" />
|
||||
Deploy
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function NFTCollectionCard() {
|
||||
return (
|
||||
<TokenContractCard
|
||||
name="NFT Collection"
|
||||
description="Launch an ERC-721 or ERC-1155 NFT collection"
|
||||
link="/team/~/~project/tokens/create/nft"
|
||||
trackingType="nft"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoinCard() {
|
||||
return (
|
||||
<TokenContractCard
|
||||
name="Coin"
|
||||
description="Launch an ERC-20 token to create a cryptocurrency"
|
||||
link="/team/~/~project/tokens/create/token"
|
||||
trackingType="coin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,31 +5,12 @@ import { PlainTextCodeBlock } from "@/components/ui/code/plaintext-code";
|
||||
export const DeployUpsellCard: React.FC = () => {
|
||||
return (
|
||||
<section className="rounded-xl border border-border px-4 py-6 shadow-lg md:px-8 md:py-14">
|
||||
<h3 className="font-bold text-2xl tracking-tight">
|
||||
<h3 className="font-semibold text-2xl tracking-tight">
|
||||
Didn't find what you're looking for? Build your own!
|
||||
</h3>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<p className="text-muted-foreground ">
|
||||
Create a contract with a single command.{" "}
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-link-foreground"
|
||||
href="https://portal.thirdweb.com/contracts/build/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about the Solidity SDK
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<PlainTextCodeBlock code="npx thirdweb create contract" />
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<p className="text-muted-foreground ">
|
||||
Deploy a contract with a single command.{" "}
|
||||
<Link
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import exploreFeatureImage from "../../../../../../../public/assets/landingpage/explore-featured.png";
|
||||
import heroIcon1 from "../../../../../../../public/assets/product-pages/publish/hero-icon-1.png";
|
||||
import heroIcon2 from "../../../../../../../public/assets/product-pages/publish/hero-icon-2.png";
|
||||
|
||||
export const PublishUpsellCard: React.FC = () => {
|
||||
return (
|
||||
<div className="flex gap-10 rounded-xl border border-border bg-card p-8 shadow-lg md:p-10">
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="font-bold text-3xl tracking-tighter">
|
||||
Accelerate your protocol's growth
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
Publishing your contract is the best way to get your contracts in
|
||||
front of our 70k+ community of web3 developers.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Image alt="" className="hidden size-6 md:block" src={heroIcon1} />
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
Save development time.{" "}
|
||||
</span>
|
||||
Focus on protocol development and save time by not having to build
|
||||
middleware layer yourself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Image alt="" className="hidden size-6 md:block" src={heroIcon2} />
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
Shareable landing page.{" "}
|
||||
</span>
|
||||
By publishing your contract, your contracts become easily shareable
|
||||
with a landing page for your contract.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex gap-2 pt-4">
|
||||
<Button asChild>
|
||||
<Link href="/contact-us" rel="noopener noreferrer" target="_blank">
|
||||
Get In Touch
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/contracts/publish/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
alt=""
|
||||
className="hidden w-[40%] grayscale invert md:block dark:filter-none"
|
||||
draggable={false}
|
||||
src={exploreFeatureImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,151 +1,55 @@
|
||||
type PublishedContractID = `${string}/${string}`;
|
||||
|
||||
export interface ExploreCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description: string;
|
||||
description: string | undefined;
|
||||
learnMore?: string;
|
||||
contracts:
|
||||
| Array<PublishedContractID>
|
||||
| Array<
|
||||
[
|
||||
PublishedContractID,
|
||||
Array<PublishedContractID>,
|
||||
{ title: string; description: string },
|
||||
]
|
||||
>;
|
||||
showInExplore?: boolean;
|
||||
contracts: Array<
|
||||
| PublishedContractID
|
||||
| [
|
||||
PublishedContractID,
|
||||
Array<PublishedContractID>,
|
||||
{ title: string; description: string },
|
||||
]
|
||||
>;
|
||||
isBeta?: boolean;
|
||||
}
|
||||
|
||||
const POPULAR = {
|
||||
export const NFTS = {
|
||||
contracts: [
|
||||
"thirdweb.eth/DropERC721",
|
||||
"thirdweb.eth/MarketplaceV3",
|
||||
"thirdweb.eth/AccountFactory",
|
||||
"0xaF3202F6bAEbA50d37e0d4B0b870455EDF198D7c/BurnMintERC677",
|
||||
"thirdweb.eth/DropERC1155",
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/NFTStake",
|
||||
"unlock-protocol.eth/PublicLock",
|
||||
],
|
||||
description: "A collection of our most deployed contracts.",
|
||||
id: "popular",
|
||||
name: "Popular",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const NFTS = {
|
||||
contracts: [
|
||||
"thirdweb.eth/DropERC721",
|
||||
"thirdweb.eth/OpenEditionERC721",
|
||||
"thirdweb.eth/TokenERC721",
|
||||
"thirdweb.eth/DropERC1155",
|
||||
"thirdweb.eth/TokenERC1155",
|
||||
"flairsdk.eth/ERC721CommunityStream",
|
||||
"thirdweb.eth/LoyaltyCard",
|
||||
"doubledev.eth/ERC4907",
|
||||
"thirdweb.eth/Multiwrap",
|
||||
"kronickatz.eth/ERC721NESDrop",
|
||||
],
|
||||
description:
|
||||
"NFT Collections, Editions, Drops and everything else NFT-related.",
|
||||
displayName: "NFTs",
|
||||
id: "nft",
|
||||
name: "NFT",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const GOVERNANCE = {
|
||||
contracts: [
|
||||
"thirdweb.eth/VoteERC20",
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/Split",
|
||||
],
|
||||
export const GOVERNANCE = {
|
||||
contracts: ["thirdweb.eth/VoteERC20", "thirdweb.eth/Split"],
|
||||
description: "Create your own DAO, vote on proposals, and manage a treasury.",
|
||||
id: "daos-governance",
|
||||
name: "DAOs & Governance",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const DROPS = {
|
||||
contracts: [
|
||||
"thirdweb.eth/DropERC721",
|
||||
"thirdweb.eth/DropERC1155",
|
||||
"thirdweb.eth/DropERC20",
|
||||
],
|
||||
description: "Release NFTs and Tokens based on preset Claim Conditions.",
|
||||
displayName: "Drops",
|
||||
id: "drops",
|
||||
name: "Drop",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const MARKETS = {
|
||||
contracts: [
|
||||
"thirdweb.eth/MarketplaceV3",
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/Split",
|
||||
],
|
||||
export const MARKETS = {
|
||||
contracts: ["thirdweb.eth/MarketplaceV3", "thirdweb.eth/Split"],
|
||||
description: "Quickly spin up your own on-chain marketplace for NFTs.",
|
||||
displayName: "Marketplaces",
|
||||
id: "marketplace",
|
||||
name: "Marketplace",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const AIRDROP = {
|
||||
export const AIRDROP = {
|
||||
contracts: ["thirdweb.eth/Airdrop"],
|
||||
description:
|
||||
"Efficiently transfer large numbers of on-chain assets to a large number of recipients.",
|
||||
displayName: "Airdrops",
|
||||
id: "airdrop",
|
||||
name: "Airdrop",
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const GAMING = {
|
||||
contracts: [
|
||||
"thirdweb.eth/MarketplaceV3",
|
||||
"thirdweb.eth/DropERC721",
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/TokenERC1155",
|
||||
"thirdweb.eth/Multiwrap",
|
||||
"thirdweb.eth/NFTStake",
|
||||
],
|
||||
description:
|
||||
"A collection of contracts that are popular for building play-to-earn and free-to-own web3 games.",
|
||||
displayName: "Gaming",
|
||||
id: "gaming",
|
||||
name: "Gaming",
|
||||
showInExplore: false,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const LOYALTY = {
|
||||
contracts: [
|
||||
"thirdweb.eth/LoyaltyCard",
|
||||
"thirdweb.eth/MarketplaceV3",
|
||||
"thirdweb.eth/TokenERC20",
|
||||
],
|
||||
description:
|
||||
"A collection of contracts that are popular for building loyalty programs.",
|
||||
displayName: "Loyalty",
|
||||
id: "loyalty",
|
||||
name: "Loyalty",
|
||||
showInExplore: false,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const COMMERCE = {
|
||||
contracts: [
|
||||
"thirdweb.eth/TokenERC721",
|
||||
"thirdweb.eth/TokenERC1155",
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/Split",
|
||||
],
|
||||
description:
|
||||
"Most popular contracts for building web3 commerce apps. Reward loyal customers and sell NFTs through your storefront.",
|
||||
displayName: "Commerce",
|
||||
id: "commerce",
|
||||
name: "Commerce",
|
||||
showInExplore: false,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const STAKING = {
|
||||
export const STAKING = {
|
||||
contracts: [
|
||||
"thirdweb.eth/NFTStake",
|
||||
"thirdweb.eth/EditionStake",
|
||||
@@ -153,12 +57,10 @@ const STAKING = {
|
||||
],
|
||||
description: "Stake your NFTs or tokens to earn ERC20 tokens in return.",
|
||||
displayName: "Staking",
|
||||
id: "staking",
|
||||
name: "Staking",
|
||||
showInExplore: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const SMART_WALLET = {
|
||||
export const SMART_WALLET = {
|
||||
contracts: [
|
||||
"thirdweb.eth/AccountFactory",
|
||||
"thirdweb.eth/ManagedAccountFactory",
|
||||
@@ -168,30 +70,16 @@ const SMART_WALLET = {
|
||||
description:
|
||||
"Account factories that let you spin up Account Abstraction (ERC-4337) wallets for your users. Not sure which factory is right for you?",
|
||||
displayName: "Account Abstraction",
|
||||
id: "smart-wallet",
|
||||
learnMore:
|
||||
"https://portal.thirdweb.com/wallets/smart-wallet/get-started#1-deploy-a-smart-wallet-factory-contract",
|
||||
name: "Account Abstraction",
|
||||
showInExplore: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const STYLUS = {
|
||||
export const STYLUS = {
|
||||
contracts: [
|
||||
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC1155",
|
||||
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC721",
|
||||
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC20",
|
||||
],
|
||||
description:
|
||||
"Airdrop your NFTs or tokens to a large number of recipients. Built with Arbitrum Stylus.",
|
||||
displayName: "Airdrop - Arbitrum Stylus",
|
||||
id: "stylus",
|
||||
name: "Stylus",
|
||||
showInExplore: true,
|
||||
isBeta: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const MODULAR_CONTRACTS_STYLUS = {
|
||||
contracts: [
|
||||
// erc721 token
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
@@ -232,44 +120,8 @@ const MODULAR_CONTRACTS_STYLUS = {
|
||||
},
|
||||
],
|
||||
],
|
||||
description:
|
||||
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
|
||||
displayName: "Modular Contracts - Arbitrum Stylus",
|
||||
id: "modular-contracts",
|
||||
name: "modular",
|
||||
showInExplore: true,
|
||||
description: undefined,
|
||||
displayName: "Arbitrum Stylus",
|
||||
name: "Stylus",
|
||||
isBeta: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const CATEGORIES: Record<string, ExploreCategory> = {
|
||||
[POPULAR.id]: POPULAR,
|
||||
[NFTS.id]: NFTS,
|
||||
[MARKETS.id]: MARKETS,
|
||||
[STYLUS.id]: STYLUS,
|
||||
[DROPS.id]: DROPS,
|
||||
[SMART_WALLET.id]: SMART_WALLET,
|
||||
[AIRDROP.id]: AIRDROP,
|
||||
[GAMING.id]: GAMING,
|
||||
[LOYALTY.id]: LOYALTY,
|
||||
[COMMERCE.id]: COMMERCE,
|
||||
[STAKING.id]: STAKING,
|
||||
[GOVERNANCE.id]: GOVERNANCE,
|
||||
[MODULAR_CONTRACTS_STYLUS.id]: MODULAR_CONTRACTS_STYLUS,
|
||||
};
|
||||
|
||||
export function getCategory(id: string) {
|
||||
if (isExploreCategory(id)) {
|
||||
return CATEGORIES[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type ExploreCategoryName = keyof typeof CATEGORIES;
|
||||
|
||||
function isExploreCategory(category: string): category is ExploreCategoryName {
|
||||
return category in CATEGORIES;
|
||||
}
|
||||
|
||||
export const EXPLORE_PAGE_DATA = Object.values(CATEGORIES).filter((v) =>
|
||||
"showInExplore" in v ? v.showInExplore !== false : true,
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fragment } from "react";
|
||||
import { ContractRow } from "./components/contract-row";
|
||||
import { CoinCard, NFTCollectionCard } from "./components/token-cards";
|
||||
import { DeployUpsellCard } from "./components/upsells/deploy-your-own";
|
||||
import { PublishUpsellCard } from "./components/upsells/publish-submit";
|
||||
import { EXPLORE_PAGE_DATA } from "./data";
|
||||
import { TokensSection } from "./tokens-section";
|
||||
import {
|
||||
AIRDROP,
|
||||
GOVERNANCE,
|
||||
MARKETS,
|
||||
NFTS,
|
||||
SMART_WALLET,
|
||||
STAKING,
|
||||
STYLUS,
|
||||
} from "./data";
|
||||
|
||||
const title = "List of smart contracts for EVM Developers";
|
||||
const description =
|
||||
"A list of Ethereum smart contract templates for web3 developers, including the most popular evm smart contracts for dapps, NFTs and more.";
|
||||
"A list of smart contract templates for web3 developers, including the most popular evm smart contracts for dapps, NFTs and more.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
@@ -22,9 +28,9 @@ export const metadata: Metadata = {
|
||||
export default async function ExplorePage() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="border-b py-10">
|
||||
<div className="border-b py-12">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="mb-2 font-bold text-4xl lg:text-5xl tracking-tight">
|
||||
<h1 className="mb-2 font-semibold text-4xl lg:text-5xl tracking-tight">
|
||||
Explore
|
||||
</h1>
|
||||
<p className="max-w-screen-sm text-sm lg:text-base text-muted-foreground">
|
||||
@@ -35,24 +41,34 @@ export default async function ExplorePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container max-w-7xl pt-8 pb-4">
|
||||
<TokensSection />
|
||||
</div>
|
||||
|
||||
<div className="container max-w-7xl flex flex-col gap-8 py-10">
|
||||
<div className="container max-w-7xl flex flex-col py-10">
|
||||
<div className="flex flex-col gap-14">
|
||||
{EXPLORE_PAGE_DATA.map((category, idx) => (
|
||||
<Fragment key={category.id}>
|
||||
{Math.floor(EXPLORE_PAGE_DATA.length / 2) === idx && (
|
||||
<PublishUpsellCard />
|
||||
)}
|
||||
<ContractRow category={category} />
|
||||
</Fragment>
|
||||
))}
|
||||
<PopularSection />
|
||||
<ContractRow category={MARKETS} />
|
||||
<ContractRow category={NFTS} prefixCard={<NFTCollectionCard />} />
|
||||
<ContractRow category={SMART_WALLET} />
|
||||
<ContractRow category={AIRDROP} />
|
||||
<ContractRow category={STAKING} />
|
||||
<ContractRow category={GOVERNANCE} />
|
||||
<ContractRow category={STYLUS} />
|
||||
<DeployUpsellCard />
|
||||
</div>
|
||||
|
||||
<DeployUpsellCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PopularSection() {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-1">Popular</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
A collection of our most deployed contracts.
|
||||
</p>
|
||||
<div className="relative z-0 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<CoinCard />
|
||||
<NFTCollectionCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ZapIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { reportTokenUpsellClicked } from "@/analytics/report";
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TokensSection() {
|
||||
return (
|
||||
<div className="bg-card p-4 lg:py-8 lg:px-6 border rounded-xl relative w-full overflow-hidden">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
strokeDasharray={"4 2"}
|
||||
className="text-border dark:text-border/70 hidden lg:block translate-x-5"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom left,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm bg-background h-auto px-3 py-1 gap-2 mb-4"
|
||||
>
|
||||
<div className="rounded-full bg-primary size-2" />
|
||||
New
|
||||
</Badge>
|
||||
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-1">
|
||||
Launch Your Tokens Effortlessly
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 text-sm lg:text-base">
|
||||
Deploy contract and configure all settings you need to launch your token
|
||||
in one seamless flow
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-4xl">
|
||||
<CardLink
|
||||
assetType="coin"
|
||||
title="Launch Coin"
|
||||
description="Launch an ERC-20 token to create a cryptocurrency"
|
||||
href="/team/~/~project/tokens/create/token"
|
||||
bullets={[
|
||||
"Deploy Contract",
|
||||
"Configure Price and Supply",
|
||||
"Airdrop Tokens",
|
||||
]}
|
||||
/>
|
||||
|
||||
<CardLink
|
||||
assetType="nft"
|
||||
title="Launch NFT Collection"
|
||||
description="Launch an ERC-721 or ERC-1155 NFT collection"
|
||||
href="/team/~/~project/tokens/create/nft"
|
||||
bullets={[
|
||||
"Deploy Contract",
|
||||
"Upload NFTs",
|
||||
"Configure Price and Supply",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardLink(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
assetType: "nft" | "coin";
|
||||
bullets: string[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border p-4 cursor-pointer hover:border-active-border bg-background",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex">
|
||||
<div className="flex items-center justify-center rounded-full border p-2 bg-card">
|
||||
<ZapIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
|
||||
<Link
|
||||
className="before:absolute before:inset-0"
|
||||
href={props.href}
|
||||
onClick={() => {
|
||||
reportTokenUpsellClicked({
|
||||
assetType: props.assetType,
|
||||
pageType: "explore",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">{props.description}</p>
|
||||
|
||||
<ul className="mt-4 space-y-1 text-sm list-disc list-inside text-muted-foreground">
|
||||
{props.bullets.map((bullet) => (
|
||||
<li key={bullet}>{bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { bridgeStats } from "../../../bridge/data";
|
||||
import { bridgeStats } from "../../../bridge/(general)/data";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { TokenPage } from "./components/token-page";
|
||||
|
||||
|
||||
@@ -17,15 +17,21 @@ export default async function CheckoutPage(props: {
|
||||
searchParams: Promise<{
|
||||
amount?: string;
|
||||
invoice_id?: string;
|
||||
project_id?: string;
|
||||
chain_id?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
console.log("params", params);
|
||||
console.log("searchParams", searchParams);
|
||||
|
||||
switch (params.sku) {
|
||||
case "topup": {
|
||||
const amountUSD = Number.parseInt(
|
||||
(await props.searchParams).amount || "10",
|
||||
);
|
||||
const amountUSD = Number.parseInt(searchParams.amount || "10");
|
||||
if (Number.isNaN(amountUSD)) {
|
||||
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
|
||||
}
|
||||
@@ -43,7 +49,7 @@ export default async function CheckoutPage(props: {
|
||||
break;
|
||||
}
|
||||
case "invoice": {
|
||||
const invoiceId = (await props.searchParams).invoice_id;
|
||||
const invoiceId = searchParams.invoice_id;
|
||||
if (!invoiceId) {
|
||||
return <StripeRedirectErrorPage errorMessage="Invalid invoice ID" />;
|
||||
}
|
||||
@@ -59,10 +65,12 @@ export default async function CheckoutPage(props: {
|
||||
redirect(invoice);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const response = await getBillingCheckoutUrl({
|
||||
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
|
||||
teamSlug: params.team_slug,
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
if (response.status === "error") {
|
||||
|
||||
@@ -7,6 +7,10 @@ import { getAbsoluteUrl } from "@/utils/vercel";
|
||||
export async function getBillingCheckoutUrl(options: {
|
||||
teamSlug: string;
|
||||
sku: Exclude<ProductSKU, null>;
|
||||
params: {
|
||||
project_id?: string;
|
||||
chain_id?: string | string[];
|
||||
};
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -17,13 +21,26 @@ export async function getBillingCheckoutUrl(options: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const chainIds = options.params.chain_id
|
||||
? (Array.isArray(options.params.chain_id)
|
||||
? options.params.chain_id
|
||||
: [options.params.chain_id]
|
||||
)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
: undefined;
|
||||
|
||||
const body = {
|
||||
redirectTo: getAbsoluteStripeRedirectUrl(),
|
||||
sku: options.sku,
|
||||
projectId: options.params.project_id,
|
||||
chainIds: chainIds,
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
redirectTo: getAbsoluteStripeRedirectUrl(),
|
||||
sku: options.sku,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
|
||||
export function AccountTeamsUI(props: {
|
||||
@@ -117,8 +116,6 @@ function TeamRow(props: {
|
||||
role: TeamAccountRole;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const plan = getValidTeamPlan(props.team);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* start */}
|
||||
@@ -133,7 +130,11 @@ function TeamRow(props: {
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-semibold text-sm">{props.team.name}</p>
|
||||
<TeamPlanBadge plan={plan} teamSlug={props.team.slug} />
|
||||
<TeamPlanBadge
|
||||
plan={props.team.billingPlan}
|
||||
teamSlug={props.team.slug}
|
||||
isLegacyPlan={props.team.isLegacyPlan}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm capitalize">
|
||||
{props.role.toLowerCase()}
|
||||
|
||||
@@ -312,7 +312,7 @@ function CreateProjectForm(props: {
|
||||
type="submit"
|
||||
>
|
||||
{createProject.isPending && <Spinner className="size-4" />}
|
||||
Create Projecct
|
||||
Create Project
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -25,9 +24,6 @@ export function AppRouterProviders(props: {
|
||||
children: React.ReactNode;
|
||||
autoConnect: boolean;
|
||||
}) {
|
||||
// get the current pathname
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -40,9 +36,6 @@ export function AppRouterProviders(props: {
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
forcedTheme={
|
||||
pathname.startsWith("/get-started") ? "dark" : undefined
|
||||
}
|
||||
>
|
||||
<Toaster richColors />
|
||||
<SanctionedAddressesChecker>
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
} from "responsive-rsc";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
|
||||
import {
|
||||
getClientTransactions,
|
||||
getEOAWalletConnections,
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
} from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
@@ -26,7 +25,7 @@ import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletStatsWithName,
|
||||
} from "@/types/analytics";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
@@ -240,7 +239,7 @@ async function AsyncWalletDistributionCard(props: {
|
||||
range: Range;
|
||||
authToken: string;
|
||||
}) {
|
||||
const walletConnections = await getWalletConnections(
|
||||
const walletConnections = await getEOAWalletConnections(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
@@ -373,29 +372,19 @@ async function AsyncTotalSponsoredCard(props: {
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
metric="Account Abstraction"
|
||||
metric="Gas Sponsorship"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
|
||||
const formattedData = await Promise.all(
|
||||
data.map(async (w) => {
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => ({ name: w.walletType }),
|
||||
);
|
||||
return {
|
||||
totalConnections: w.totalConnections,
|
||||
uniqueWalletsConnected: w.uniqueWalletsConnected,
|
||||
walletName: wallet.name,
|
||||
walletType: w.walletType,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
async function WalletDistributionCard({
|
||||
data,
|
||||
}: {
|
||||
data: WalletStatsWithName[];
|
||||
}) {
|
||||
return (
|
||||
<PieChartCard
|
||||
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
|
||||
data={data.map(({ walletName, uniqueWalletsConnected }) => {
|
||||
return {
|
||||
label: walletName,
|
||||
value: uniqueWalletsConnected,
|
||||
|
||||
@@ -73,21 +73,24 @@ export function PlanInfoCardUI(props: {
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{validPlan === "growth_legacy" ? "Growth" : validPlan} Plan
|
||||
</h3>
|
||||
{validPlan.includes("legacy") && (
|
||||
{props.team.isLegacyPlan && (
|
||||
<Badge variant="warning">Legacy</Badge>
|
||||
)}
|
||||
{trialEndsInFuture && <Badge variant="default">Trial</Badge>}
|
||||
</div>
|
||||
|
||||
{validPlan.includes("legacy") && (
|
||||
{props.team.isLegacyPlan && (
|
||||
<p className="text-sm text-yellow-600">
|
||||
You are on the legacy plan. You may save by upgrading to new
|
||||
plan.{" "}
|
||||
Legacy plans will be upgraded to new plans automatically on
|
||||
January 1st, 2026.
|
||||
<br />
|
||||
<UnderlineLink
|
||||
className="decoration-yellow-600/50"
|
||||
href="/pricing"
|
||||
href="https://blog.thirdweb.com/retiring-legacy-pricing-plans-on-january-1st-2026"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
Learn more about changes to legacy plans
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystem } from "@/api/team/ecosystems";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
|
||||
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function EcosystemUsersPage(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<InAppWalletUsersPageContent
|
||||
<UserWalletsTable
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
ecosystemSlug={ecosystem.slug}
|
||||
|
||||
@@ -63,15 +63,17 @@ function MetricPlaceholders({
|
||||
{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>
|
||||
<EmptyChartState
|
||||
content={
|
||||
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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col grow justify-center items-center">
|
||||
<UpsellContent
|
||||
currentPlan={team.billingPlan}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
featureDescription="Create support cases for your projects"
|
||||
featureName="Support Center"
|
||||
requiredPlan="starter"
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
import { getUserOpUsage } from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { getTeamSubscriptions } from "@/api/team/team-subscription";
|
||||
import { SponsoredTransactionsTable } from "@/components/sponsored-transactions-table/SponsoredTransactionsTable";
|
||||
import {
|
||||
getLastNDaysRange,
|
||||
type Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AccountAbstractionSummary } from "../../../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { AccountAbstractionAnalytics } from "../../../../[project_slug]/(sidebar)/account-abstraction/aa-analytics";
|
||||
import { searchParamLoader } from "../../../../[project_slug]/(sidebar)/account-abstraction/search-params";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
const [params, parsedSearchParams, authToken] = await Promise.all([
|
||||
props.params,
|
||||
searchParamLoader(props.searchParams),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
@@ -22,6 +31,8 @@ export default async function Page(props: {
|
||||
loginRedirect(`/team/${params.team_slug}/~/usage/account-abstraction`);
|
||||
}
|
||||
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
@@ -44,20 +55,51 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const interval = parsedSearchParams.interval ?? "week";
|
||||
const rangeType = parsedSearchParams.range || "last-120";
|
||||
|
||||
const range: Range = {
|
||||
from:
|
||||
rangeType === "custom"
|
||||
? parsedSearchParams.from
|
||||
: getLastNDaysRange(rangeType).from,
|
||||
to:
|
||||
rangeType === "custom"
|
||||
? parsedSearchParams.to
|
||||
: getLastNDaysRange(rangeType).to,
|
||||
type: rangeType,
|
||||
};
|
||||
|
||||
const userOpStats = await getUserOpUsage(
|
||||
{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
teamId: team.id,
|
||||
to: range.to,
|
||||
},
|
||||
authToken,
|
||||
);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-14">
|
||||
<SponsoredTransactionsTable
|
||||
<div className="flex flex-col gap-10">
|
||||
<AccountAbstractionSummary
|
||||
authToken={authToken}
|
||||
teamId={team.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
/>
|
||||
|
||||
<AccountAbstractionAnalytics
|
||||
client={client}
|
||||
from={usageSubscription.currentPeriodStart}
|
||||
projects={projects}
|
||||
teamId={team.id}
|
||||
teamSlug={team.slug}
|
||||
to={usageSubscription.currentPeriodEnd}
|
||||
userOpStats={userOpStats}
|
||||
variant="team"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function Layout(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border pt-10 pb-4">
|
||||
<div className="border-border pt-10 pb-6">
|
||||
<div className="container max-w-7xl flex flex-col gap-4 lg:flex-row lg:justify-between">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Usage</h1>
|
||||
<div className="items-center gap-3 hidden lg:flex">
|
||||
@@ -47,7 +47,7 @@ export default async function Layout(props: {
|
||||
path: `${usagePath}/storage`,
|
||||
},
|
||||
{
|
||||
name: "Account Abstraction",
|
||||
name: "Gas Sponsorship",
|
||||
path: `${usagePath}/account-abstraction`,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -43,7 +43,8 @@ export default async function Page(props: {
|
||||
<div className="grow flex flex-col justify-center items-center">
|
||||
<UpsellContent
|
||||
currentPlan={team.billingPlan}
|
||||
featureDescription="View RPC, Wallet, Storage, Account Abstraction, Engine Cloud, Webhooks usage and more"
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
featureDescription="View RPC, Wallet, Storage, Gas Sponsorship, Engine Cloud, Webhooks usage and more"
|
||||
featureName="Usage"
|
||||
requiredPlan="starter"
|
||||
teamSlug={params.team_slug}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function CountGraph(props: {
|
||||
return format(label, "MMM dd, HH:mm");
|
||||
}}
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
showHour: true,
|
||||
}}
|
||||
yAxis
|
||||
/>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function RateGraph(props: {
|
||||
return format(label, "MMM dd, HH:mm");
|
||||
}}
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
showHour: true,
|
||||
}}
|
||||
yAxis
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,6 @@ export default async function RPCUsage(props: {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const currentPlan = team.billingPlan;
|
||||
const currentRateLimit = team.capabilities.rpc.rateLimit;
|
||||
|
||||
const apiData = await getLast24HoursRPCUsage({
|
||||
@@ -102,7 +101,11 @@ export default async function RPCUsage(props: {
|
||||
<div className="font-bold text-2xl capitalize">
|
||||
{currentRateLimit.toLocaleString()} RPS
|
||||
</div>
|
||||
<TeamPlanBadge plan={currentPlan} teamSlug={team.slug} />
|
||||
<TeamPlanBadge
|
||||
plan={team.billingPlan}
|
||||
teamSlug={team.slug}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getAiUsage } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { AiTokenUsageChartCardUI } from "../ai/analytics/chart/AiTokenUsageChartCard";
|
||||
|
||||
async function AsyncAiAnalytics(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const stats = await getAiUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<AiTokenUsageChartCardUI
|
||||
title="AI token volume"
|
||||
isPending={false}
|
||||
aiUsageStats={stats}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View more details",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No AI usage found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIAnalyticsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<AiTokenUsageChartCardUI
|
||||
isPending={true}
|
||||
title="AI token volume"
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
|
||||
aiUsageStats={[]}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAiAnalytics
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import {
|
||||
AutoMergeBarChart,
|
||||
type StatData,
|
||||
} from "@/components/blocks/charts/automerge-barchart";
|
||||
|
||||
type AllWalletConnectionsChartProps = {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export function AllWalletConnectionsChart(
|
||||
props: AllWalletConnectionsChartProps,
|
||||
) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<AllWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
|
||||
}
|
||||
>
|
||||
<AsyncAllWalletConnectionsChart {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAllWalletConnectionsChart(
|
||||
props: AllWalletConnectionsChartProps,
|
||||
) {
|
||||
const stats = await getEOAAndInAppWalletConnections(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<AllWalletConnectionsChartUI
|
||||
{...props}
|
||||
isPending={false}
|
||||
stats={stats.map((stat) => ({
|
||||
date: stat.date,
|
||||
count: stat.uniqueWalletsConnected,
|
||||
label: stat.walletName,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AllWalletConnectionsChartUI(props: {
|
||||
stats: StatData[];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AutoMergeBarChart
|
||||
title="Wallet connections"
|
||||
description={undefined}
|
||||
stats={props.stats || []}
|
||||
isPending={props.isPending}
|
||||
exportButton={undefined}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View Configuration",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets/configure`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No wallet connections found in selected time period"
|
||||
/>
|
||||
}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getUniversalBridgeUsage } from "@/api/analytics";
|
||||
import {
|
||||
EmptyChartState,
|
||||
EmptyChartStateGetStartedCTA,
|
||||
LoadingChartState,
|
||||
} from "@/components/analytics/empty-chart-state";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
import type { UniversalBridgeStats } from "@/types/analytics";
|
||||
import { TotalVolumePieChart } from "../payments/components/TotalVolumePieChart";
|
||||
|
||||
async function AsyncBridgeCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const data = await getUniversalBridgeUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<BridgeChartCardUI
|
||||
data={data}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BridgeChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="min-h-[40px] h-full border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncBridgeCard {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BridgeChartCardUI(props: {
|
||||
data: UniversalBridgeStats[];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const total =
|
||||
props.data.reduce((acc, curr) => acc + curr.amountUsdCents, 0) / 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-card border rounded-lg">
|
||||
<TotalValueChartHeader
|
||||
title="Bridge Volume"
|
||||
total={total}
|
||||
isUsd={true}
|
||||
isPending={false}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/bridge`}
|
||||
/>
|
||||
<div className="p-6 grow flex items-center justify-center">
|
||||
{total === 0 ? (
|
||||
<EmptyChartState
|
||||
content={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View Configuration",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No bridge volume found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TotalVolumePieChart data={props.data} hideTotal={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Meta } from "@storybook/nextjs";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
X402SettlementsOverall,
|
||||
} from "@/types/analytics";
|
||||
import { ProjectHighlightsCard } from "./highlights-card-ui";
|
||||
|
||||
const meta = {
|
||||
component: ProjectHighlightsCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ResponsiveSearchParamsProvider value={{}}>
|
||||
<div className="container max-w-6xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
),
|
||||
],
|
||||
title: "Analytics/ProjectHighlightsCard",
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProjectHighlightsCard>;
|
||||
|
||||
export default meta;
|
||||
|
||||
function generateDateSeries(days: number) {
|
||||
const dates: string[] = [];
|
||||
const today = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
dates.push(d.toISOString());
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function inAppWalletStub(days: number): InAppWalletStats[] {
|
||||
const dates = generateDateSeries(days);
|
||||
return dates.map((date) => {
|
||||
return {
|
||||
// main data
|
||||
newUsers: randomValue(50, 100),
|
||||
uniqueWalletsConnected: randomValue(500, 1200),
|
||||
// others
|
||||
authenticationMethod: "email",
|
||||
date,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// a few aggregated entries that will be summed for the "Active Users" stat
|
||||
function aggregatedInAppWalletsStub(): InAppWalletStats[] {
|
||||
return Array.from({ length: 3 }).map(() => {
|
||||
return {
|
||||
// main data
|
||||
newUsers: randomValue(50, 100),
|
||||
uniqueWalletsConnected: randomValue(500, 1200),
|
||||
// others
|
||||
date: new Date().toISOString(),
|
||||
authenticationMethod: "email",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bridgeVolumeStub(days: number): UniversalBridgeStats[] {
|
||||
const dates = generateDateSeries(days);
|
||||
return dates.map((date) => {
|
||||
return {
|
||||
// main data
|
||||
developerFeeUsdCents: randomValue(500 * 100, 2500 * 100),
|
||||
status: "completed",
|
||||
// others
|
||||
chainId: 0,
|
||||
count: 0,
|
||||
date,
|
||||
amountUsdCents: 0,
|
||||
type: "onchain",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function randomValue(min: number, max: number) {
|
||||
return Math.max(0, Math.round(min + Math.random() * (max - min)));
|
||||
}
|
||||
|
||||
function generateX402Settlements(days: number): X402SettlementsOverall[] {
|
||||
const dates = generateDateSeries(days);
|
||||
return dates.map((date) => {
|
||||
return {
|
||||
date,
|
||||
totalRequests: randomValue(10, 100),
|
||||
totalValue: randomValue(500, 2500),
|
||||
totalValueUSD: randomValue(500, 2500),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function ThirtyDays() {
|
||||
const days = 30;
|
||||
const data = {
|
||||
aggregatedUserStats: aggregatedInAppWalletsStub(),
|
||||
userStats: inAppWalletStub(days),
|
||||
bridgeVolumeStats: bridgeVolumeStub(days),
|
||||
x402Settlements: generateX402Settlements(days),
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={data.aggregatedUserStats}
|
||||
userStats={data.userStats}
|
||||
volumeStats={data.bridgeVolumeStats}
|
||||
x402Settlements={data.x402Settlements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleDayRevenue() {
|
||||
const days = 1;
|
||||
const data = {
|
||||
aggregatedUserStats: [],
|
||||
userStats: [],
|
||||
bridgeVolumeStats: bridgeVolumeStub(days),
|
||||
x402Settlements: generateX402Settlements(days),
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={data.aggregatedUserStats}
|
||||
userStats={data.userStats}
|
||||
volumeStats={data.bridgeVolumeStats}
|
||||
x402Settlements={data.x402Settlements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleDayRevenueNoX402() {
|
||||
const days = 1;
|
||||
const data = {
|
||||
aggregatedUserStats: [],
|
||||
userStats: [],
|
||||
bridgeVolumeStats: bridgeVolumeStub(days),
|
||||
x402Settlements: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={data.aggregatedUserStats}
|
||||
userStats={data.userStats}
|
||||
volumeStats={data.bridgeVolumeStats}
|
||||
x402Settlements={data.x402Settlements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { useSetResponsiveSearchParams } from "responsive-rsc";
|
||||
import type { InAppWalletStats, UniversalBridgeStats } from "@/types/analytics";
|
||||
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
X402SettlementsOverall,
|
||||
} from "@/types/analytics";
|
||||
import {
|
||||
CombinedBarChartCard,
|
||||
type CombinedBarChartConfig,
|
||||
} from "../../../../components/Analytics/CombinedBarChartCard";
|
||||
|
||||
type AggregatedMetrics = {
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
totalVolume: number;
|
||||
feesCollected: number;
|
||||
bridgeRevenue: number;
|
||||
x402Revenue: number;
|
||||
};
|
||||
|
||||
export function ProjectHighlightsCard(props: {
|
||||
selectedChart: string | undefined;
|
||||
aggregatedUserStats: InAppWalletStats[];
|
||||
userStats: InAppWalletStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
selectedChartQueryParam: string;
|
||||
x402Settlements: X402SettlementsOverall[];
|
||||
}) {
|
||||
const {
|
||||
selectedChart,
|
||||
aggregatedUserStats,
|
||||
const { aggregatedUserStats, userStats, volumeStats, x402Settlements } =
|
||||
props;
|
||||
|
||||
const [selectedChart, setSelectedChart] = useState<string | undefined>(
|
||||
"activeUsers",
|
||||
);
|
||||
|
||||
const timeSeriesData = processTimeSeriesData(
|
||||
userStats,
|
||||
volumeStats,
|
||||
teamSlug,
|
||||
projectSlug,
|
||||
selectedChartQueryParam,
|
||||
} = props;
|
||||
x402Settlements,
|
||||
);
|
||||
|
||||
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
const chartConfig = {
|
||||
const chartConfig: CombinedBarChartConfig<keyof AggregatedMetrics> = {
|
||||
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
|
||||
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
|
||||
totalVolume: {
|
||||
color: "hsl(var(--chart-2))",
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
description="Onramp, swap, and bridge with thirdweb's Payments."
|
||||
link="https://portal.thirdweb.com/payments"
|
||||
metric="Payments"
|
||||
/>
|
||||
),
|
||||
newUsers: { color: "hsl(var(--chart-2))", label: "New Users" },
|
||||
bridgeRevenue: {
|
||||
color: "hsl(var(--chart-3))",
|
||||
isCurrency: true,
|
||||
label: "Total Volume",
|
||||
label: "Bridge",
|
||||
hideAsTab: true,
|
||||
},
|
||||
x402Revenue: {
|
||||
color: "hsl(var(--chart-4))",
|
||||
isCurrency: true,
|
||||
label: "x402",
|
||||
hideAsTab: true,
|
||||
},
|
||||
feesCollected: {
|
||||
color: "hsl(var(--chart-4))",
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
description="Your app hasn't collected any fees yet."
|
||||
link={`/team/${teamSlug}/${projectSlug}/payments/settings`}
|
||||
metric="Fees"
|
||||
/>
|
||||
),
|
||||
color: "hsl(var(--chart-3))",
|
||||
emptyContent: undefined,
|
||||
isCurrency: true,
|
||||
label: "Fee Revenue",
|
||||
label: "Revenue",
|
||||
stackedKeys: ["bridgeRevenue", "x402Revenue"],
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
@@ -83,12 +82,7 @@ export function ProjectHighlightsCard(props: {
|
||||
chartConfig={chartConfig}
|
||||
data={timeSeriesData}
|
||||
onSelect={(key) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
[selectedChartQueryParam]: key,
|
||||
};
|
||||
});
|
||||
setSelectedChart(key);
|
||||
}}
|
||||
trendFn={(data, key) =>
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
@@ -103,6 +97,8 @@ export function ProjectHighlightsCard(props: {
|
||||
|
||||
type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
date: string;
|
||||
bridgeRevenue: number;
|
||||
x402Revenue: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -111,48 +107,51 @@ type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
function processTimeSeriesData(
|
||||
userStats: InAppWalletStats[],
|
||||
volumeStats: UniversalBridgeStats[],
|
||||
x402Settlements: X402SettlementsOverall[],
|
||||
): TimeSeriesMetrics[] {
|
||||
const metrics: TimeSeriesMetrics[] = [];
|
||||
const dates = [
|
||||
...new Set([
|
||||
...userStats.map((a) => new Date(a.date).toISOString().slice(0, 10)),
|
||||
...volumeStats.map((a) => new Date(a.date).toISOString().slice(0, 10)),
|
||||
...userStats.map((a) => ignoreTimePeriod(a.date)),
|
||||
...volumeStats.map((a) => ignoreTimePeriod(a.date)),
|
||||
...x402Settlements.map((a) => ignoreTimePeriod(a.date)),
|
||||
]),
|
||||
];
|
||||
|
||||
for (const date of dates) {
|
||||
const activeUsers = userStats
|
||||
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
|
||||
.filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date))
|
||||
.reduce((acc, curr) => acc + curr.uniqueWalletsConnected, 0);
|
||||
|
||||
const newUsers = userStats
|
||||
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
|
||||
.filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date))
|
||||
.reduce((acc, curr) => acc + curr.newUsers, 0);
|
||||
|
||||
const volume = volumeStats
|
||||
const bridgeRevenue = volumeStats
|
||||
.filter(
|
||||
(v) =>
|
||||
new Date(v.date).toISOString().slice(0, 10) === date &&
|
||||
v.status === "completed",
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
|
||||
|
||||
const fees = volumeStats
|
||||
.filter(
|
||||
(v) =>
|
||||
new Date(v.date).toISOString().slice(0, 10) === date &&
|
||||
ignoreTimePeriod(v.date) === ignoreTimePeriod(date) &&
|
||||
v.status === "completed",
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
|
||||
|
||||
const x402Revenue = x402Settlements
|
||||
.filter((x) => ignoreTimePeriod(x.date) === ignoreTimePeriod(date))
|
||||
.reduce((acc, curr) => acc + curr.totalValueUSD, 0);
|
||||
|
||||
metrics.push({
|
||||
activeUsers: activeUsers,
|
||||
date: date,
|
||||
feesCollected: fees,
|
||||
feesCollected: bridgeRevenue + x402Revenue,
|
||||
bridgeRevenue: bridgeRevenue,
|
||||
x402Revenue: x402Revenue,
|
||||
newUsers: newUsers,
|
||||
totalVolume: volume,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function ignoreTimePeriod(date: string) {
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getX402Settlements,
|
||||
} from "@/api/analytics";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Range } from "@/components/analytics/date-range-selector";
|
||||
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { ProjectHighlightsCard } from "./highlights-card-ui";
|
||||
import type { PageParams, PageSearchParams } from "./types";
|
||||
|
||||
export function ProjectHighlightCard(props: {
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
interval: "day" | "week";
|
||||
params: PageParams;
|
||||
range: Range;
|
||||
searchParams: PageSearchParams;
|
||||
project: Project;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAppHighlightsCard
|
||||
authToken={props.authToken}
|
||||
client={props.client}
|
||||
interval={props.interval}
|
||||
params={props.params}
|
||||
project={props.project}
|
||||
range={props.range}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAppHighlightsCard(props: {
|
||||
project: Project;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
client: ThirdwebClient;
|
||||
params: PageParams;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [
|
||||
aggregatedUserStats,
|
||||
walletUserStatsTimeSeries,
|
||||
universalBridgeUsage,
|
||||
x402Settlements,
|
||||
] = await Promise.allSettled([
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getUniversalBridgeUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getX402Settlements(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
]);
|
||||
|
||||
if (
|
||||
walletUserStatsTimeSeries.status === "fulfilled" &&
|
||||
universalBridgeUsage.status === "fulfilled"
|
||||
) {
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={
|
||||
aggregatedUserStats.status === "fulfilled"
|
||||
? aggregatedUserStats.value
|
||||
: []
|
||||
}
|
||||
userStats={
|
||||
walletUserStatsTimeSeries.status === "fulfilled"
|
||||
? walletUserStatsTimeSeries.value
|
||||
: []
|
||||
}
|
||||
volumeStats={
|
||||
universalBridgeUsage.status === "fulfilled"
|
||||
? universalBridgeUsage.value
|
||||
: []
|
||||
}
|
||||
x402Settlements={
|
||||
x402Settlements.status === "fulfilled" ? x402Settlements.value : []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/wallets"
|
||||
metric="Wallets"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getInsightStatusCodeUsage } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { RequestsByStatusGraph } from "../gateway/indexer/components/RequestsByStatusGraph";
|
||||
|
||||
export function IndexerRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<RequestsByStatusGraph
|
||||
data={[]}
|
||||
isPending={true}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncIndexerRequestsChartCard
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncIndexerRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getInsightStatusCodeUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return (
|
||||
<RequestsByStatusGraph
|
||||
data={requestsData && "data" in requestsData ? requestsData.data : []}
|
||||
isPending={false}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View more details",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No indexer requests found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Range } from "@/components/analytics/date-range-selector";
|
||||
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { TransactionsChartCardAsync } from "../components/Transactions";
|
||||
import { AIAnalyticsChartCard } from "./ai-card";
|
||||
import { AllWalletConnectionsChart } from "./all-wallet-connections-chart";
|
||||
import { BridgeChartCard } from "./bridge-card";
|
||||
import { ProjectHighlightCard } from "./highlights-card";
|
||||
import { IndexerRequestsChartCard } from "./indexer-card";
|
||||
import { RPCRequestsChartCard } from "./rpc-card";
|
||||
import type { PageParams, PageSearchParams } from "./types";
|
||||
import { X402RequestsChartCard } from "./x402-card";
|
||||
|
||||
export async function ProjectAnalytics(props: {
|
||||
project: Project;
|
||||
params: PageParams;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
searchParams: PageSearchParams;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { project, params, range, interval, searchParams, client, authToken } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* highlights */}
|
||||
<ProjectHighlightCard
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
interval={interval}
|
||||
params={params}
|
||||
project={project}
|
||||
range={range}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* wallets */}
|
||||
<AllWalletConnectionsChart
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
projectId={project.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
|
||||
{/* bridge */}
|
||||
<BridgeChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Indexer */}
|
||||
<IndexerRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
{/* RPC */}
|
||||
<RPCRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* x402 */}
|
||||
<X402RequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
{/* AI */}
|
||||
<AIAnalyticsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transactions, Chains, Contracts */}
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
|
||||
>
|
||||
<TransactionsChartCardAsync
|
||||
client={client}
|
||||
params={{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
to: range.to,
|
||||
}}
|
||||
authToken={authToken}
|
||||
selectedChart={
|
||||
typeof searchParams.client_transactions === "string"
|
||||
? searchParams.client_transactions
|
||||
: undefined
|
||||
}
|
||||
selectedChartQueryParam="client_transactions"
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getRpcUsageByType } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { RPCRequestsChartUI } from "../gateway/rpc/components/RequestsGraph";
|
||||
|
||||
async function AsyncRPCRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getRpcUsageByType(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
const isEmpty = requestsData?.every((d) => d.count === 0);
|
||||
|
||||
return (
|
||||
<RPCRequestsChartUI
|
||||
isPending={false}
|
||||
data={isEmpty ? [] : requestsData || []}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View more details",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No RPC requests found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RPCRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<RPCRequestsChartUI
|
||||
isPending={true}
|
||||
data={[]}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncRPCRequestsChartCard
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type PageParams = {
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
};
|
||||
|
||||
export type PageSearchParams = {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
type: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
client_transactions: string | undefined | string[];
|
||||
totalSponsored: string | undefined | string[];
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getX402Settlements } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { X402RequestsChartCardUI } from "./x402-requests-chart";
|
||||
|
||||
async function AsyncX402RequestsChart(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const stats = await getX402Settlements(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
|
||||
|
||||
return (
|
||||
<X402RequestsChartCardUI
|
||||
stats={
|
||||
isAllEmpty
|
||||
? []
|
||||
: stats.map((stat) => ({
|
||||
requests: stat.totalRequests,
|
||||
time: stat.date,
|
||||
}))
|
||||
}
|
||||
isPending={false}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View Configuration",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/x402/configuration`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No X402 requests found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function X402RequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<X402RequestsChartCardUI
|
||||
stats={[]}
|
||||
isPending={true}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncX402RequestsChart
|
||||
from={props.from}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
|
||||
export function X402RequestsChartCardUI(props: {
|
||||
stats: Array<{ requests: number; time: string }>;
|
||||
isPending: boolean;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
emptyChartState?: React.ReactElement | undefined;
|
||||
}) {
|
||||
const total = useMemo(() => {
|
||||
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
|
||||
}, [props.stats]);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="w-full h-[275px]"
|
||||
data={props.stats}
|
||||
config={{
|
||||
requests: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Requests",
|
||||
},
|
||||
}}
|
||||
isPending={props.isPending}
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(label) => {
|
||||
return format(label, "MMM dd");
|
||||
}}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return compactNumberFormatter.format(value as number);
|
||||
}}
|
||||
customHeader={
|
||||
<TotalValueChartHeader
|
||||
total={total}
|
||||
isPending={props.isPending}
|
||||
title="X402 Requests"
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
|
||||
/>
|
||||
}
|
||||
emptyChartState={props.emptyChartState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
@@ -34,13 +34,17 @@ function AccountAbstractionSummaryInner(props: {
|
||||
|
||||
async function AsyncAccountAbstractionSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
const aggregateUserOpStats = await getAggregateUserOpUsage(
|
||||
{
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
);
|
||||
@@ -55,8 +59,10 @@ async function AsyncAccountAbstractionSummary(props: {
|
||||
|
||||
export function AccountAbstractionSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -71,6 +77,8 @@ export function AccountAbstractionSummary(props: {
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { DocLink } from "@/components/blocks/DocLink";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
|
||||
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
|
||||
import type { UserOpStats } from "@/types/analytics";
|
||||
import { formatTickerNumber } from "@/utils/format-utils";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string;
|
||||
};
|
||||
|
||||
type MetricType =
|
||||
| "sponsoredTransactions"
|
||||
| "gasUnits"
|
||||
| "avgGasPrice"
|
||||
| "costPerGasUnit";
|
||||
|
||||
export function GasMetricsChartCard(props: {
|
||||
userOpStats: UserOpStats[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { userOpStats } = props;
|
||||
const topChainsToShow = 10;
|
||||
const chainsStore = useAllChainsData();
|
||||
const [metricType, setMetricType] = useState<MetricType>(
|
||||
"sponsoredTransactions",
|
||||
);
|
||||
|
||||
const { chartConfig, chartData } = useMemo(() => {
|
||||
const _chartConfig: ChartConfig = {};
|
||||
const _chartDataMap: Map<string, ChartData> = new Map();
|
||||
const chainIdToVolumeMap: Map<string, number> = new Map();
|
||||
|
||||
// for each stat, add it in _chartDataMap
|
||||
for (const stat of userOpStats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
const { chainId } = stat;
|
||||
const chain = chainsStore.idToChain.get(Number(chainId));
|
||||
const chainName = chain?.name || chainId || "Unknown";
|
||||
|
||||
let value: number;
|
||||
if (metricType === "sponsoredTransactions") {
|
||||
value = stat.successful;
|
||||
} else if (metricType === "gasUnits") {
|
||||
value = stat.gasUnits;
|
||||
} else if (metricType === "avgGasPrice") {
|
||||
value = stat.avgGasPrice;
|
||||
} else {
|
||||
// costPerGasUnit: USD spent per gas unit (helps identify price spike impact)
|
||||
value = stat.gasUnits > 0 ? stat.sponsoredUsd / stat.gasUnits : 0;
|
||||
}
|
||||
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: stat.date,
|
||||
[chainName]: value,
|
||||
} as ChartData);
|
||||
} else {
|
||||
chartData[chainName] = (chartData[chainName] || 0) + value;
|
||||
}
|
||||
|
||||
chainIdToVolumeMap.set(
|
||||
chainName,
|
||||
value + (chainIdToVolumeMap.get(chainName) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const chainsSorted = Array.from(chainIdToVolumeMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((w) => w[0]);
|
||||
|
||||
const chainsToShow = chainsSorted.slice(0, topChainsToShow);
|
||||
const chainsToTagAsOthers = chainsSorted.slice(topChainsToShow);
|
||||
|
||||
// replace chainIdsToTagAsOther chainId with "other"
|
||||
for (const data of _chartDataMap.values()) {
|
||||
for (const chainId in data) {
|
||||
if (chainsToTagAsOthers.includes(chainId)) {
|
||||
data.others = (data.others || 0) + (data[chainId] || 0);
|
||||
delete data[chainId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chainsToShow.forEach((chainName, i) => {
|
||||
_chartConfig[chainName] = {
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
label: chainName,
|
||||
};
|
||||
});
|
||||
|
||||
// Add Other
|
||||
if (chainsToTagAsOthers.length > 0) {
|
||||
chainsToShow.push("others");
|
||||
_chartConfig.others = {
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
label: "Others",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
chartConfig: _chartConfig,
|
||||
chartData: Array.from(_chartDataMap.values()).sort((a, b) => {
|
||||
return new Date(a.time).getTime() - new Date(b.time).getTime();
|
||||
}),
|
||||
};
|
||||
}, [userOpStats, chainsStore, metricType]);
|
||||
|
||||
const uniqueChainIds = Object.keys(chartConfig);
|
||||
const disableActions =
|
||||
props.isPending ||
|
||||
chartData.length === 0 ||
|
||||
chartData.every((data) => {
|
||||
return Object.entries(data).every(([key, value]) => {
|
||||
return key === "time" || value === 0;
|
||||
});
|
||||
});
|
||||
|
||||
const metricConfig = {
|
||||
sponsoredTransactions: {
|
||||
title: "Sponsored Transactions",
|
||||
description: "Total number of sponsored transactions",
|
||||
fileName: "Sponsored Transactions",
|
||||
formatter: (value: number) => formatTickerNumber(value),
|
||||
},
|
||||
gasUnits: {
|
||||
title: "Gas Units Consumed",
|
||||
description: "Total gas units consumed by sponsored transactions",
|
||||
fileName: "Gas Units Consumed",
|
||||
formatter: (value: number) => formatTickerNumber(value),
|
||||
},
|
||||
avgGasPrice: {
|
||||
title: "Average Gas Price",
|
||||
description: "Average gas price in Gwei for sponsored transactions",
|
||||
fileName: "Average Gas Price",
|
||||
formatter: (value: number) => {
|
||||
// Convert from wei to Gwei
|
||||
const gwei = value / 1_000_000_000;
|
||||
return `${formatTickerNumber(gwei)} Gwei`;
|
||||
},
|
||||
},
|
||||
costPerGasUnit: {
|
||||
title: "Cost per Gas Unit",
|
||||
description:
|
||||
"USD spent per gas unit - spikes indicate gas price increases",
|
||||
fileName: "Cost per Gas Unit",
|
||||
formatter: (value: number) => {
|
||||
// Show in micro-USD for readability
|
||||
return `$${(value * 1_000_000).toFixed(4)}µ`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config =
|
||||
metricConfig[metricType as keyof typeof metricConfig] ||
|
||||
metricConfig.sponsoredTransactions;
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-[1] lg:aspect-[3.5]"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{config.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => setMetricType("sponsoredTransactions")}
|
||||
size="sm"
|
||||
variant={
|
||||
metricType === "sponsoredTransactions" ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
Transactions
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setMetricType("gasUnits")}
|
||||
size="sm"
|
||||
variant={metricType === "gasUnits" ? "default" : "outline"}
|
||||
>
|
||||
Gas Units
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setMetricType("avgGasPrice")}
|
||||
size="sm"
|
||||
variant={metricType === "avgGasPrice" ? "default" : "outline"}
|
||||
>
|
||||
Gas Price
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setMetricType("costPerGasUnit")}
|
||||
size="sm"
|
||||
variant={
|
||||
metricType === "costPerGasUnit" ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
Cost Efficiency
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-6 right-6 mt-4 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:my-0 md:flex md:mt-10">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
disabled={disableActions}
|
||||
fileName={config.fileName}
|
||||
getData={async () => {
|
||||
const header = ["Date", ...uniqueChainIds];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<GasMetricsChartCardEmptyChartState />}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return format(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
toolTipValueFormatter={(value) => {
|
||||
if (typeof value !== "number") {
|
||||
return "";
|
||||
}
|
||||
return config.formatter(value);
|
||||
}}
|
||||
variant="stacked"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GasMetricsChartCardEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">Sponsor gas for your users</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
icon={TypeScriptIcon}
|
||||
label="TypeScript"
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
/>
|
||||
<DocLink
|
||||
icon={ReactIcon}
|
||||
label="React"
|
||||
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
|
||||
/>
|
||||
<DocLink
|
||||
icon={ReactIcon}
|
||||
label="React Native"
|
||||
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
|
||||
/>
|
||||
<DocLink
|
||||
icon={UnityIcon}
|
||||
label="Unity"
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
|
||||
/>
|
||||
<DocLink
|
||||
icon={UnrealIcon}
|
||||
label="Unreal Engine"
|
||||
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
|
||||
/>
|
||||
<DocLink
|
||||
icon={DotNetIcon}
|
||||
label=".NET"
|
||||
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export function createUserOpStatsStub(days: number): UserOpStats[] {
|
||||
const successful = Math.random() * 100;
|
||||
const failed = Math.random() * 100;
|
||||
const sponsoredUsd = Math.random() * 100;
|
||||
const gasUnits = Math.random() * 1000000; // Random gas units between 0-1M
|
||||
const avgGasPrice = Math.random() * 100000000000; // Random gas price in wei (0-100 Gwei)
|
||||
|
||||
stubbedData.push({
|
||||
chainId: Math.floor(Math.random() * 100).toString(),
|
||||
@@ -16,6 +18,8 @@ export function createUserOpStatsStub(days: number): UserOpStats[] {
|
||||
failed,
|
||||
sponsoredUsd,
|
||||
successful,
|
||||
gasUnits,
|
||||
avgGasPrice,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.7) {
|
||||
|
||||
@@ -11,17 +11,34 @@ import {
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { SponsoredTransactionsTable } from "@/components/sponsored-transactions-table/SponsoredTransactionsTable";
|
||||
import type { UserOpStats } from "@/types/analytics";
|
||||
import { SponsoredTransactionsChartCard } from "./AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
|
||||
import { GasMetricsChartCard } from "./AccountAbstractionAnalytics/GasMetricsChartCard";
|
||||
import { TotalSponsoredChartCard } from "./AccountAbstractionAnalytics/TotalSponsoredChartCard";
|
||||
import { searchParams } from "./search-params";
|
||||
|
||||
export function AccountAbstractionAnalytics(props: {
|
||||
type AccountAbstractionAnalyticsProps = {
|
||||
userOpStats: UserOpStats[];
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
projectId: string;
|
||||
}) {
|
||||
} & (
|
||||
| {
|
||||
variant: "project";
|
||||
projectId: string;
|
||||
}
|
||||
| {
|
||||
variant: "team";
|
||||
projects: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
slug: string;
|
||||
}[];
|
||||
}
|
||||
);
|
||||
|
||||
export function AccountAbstractionAnalytics(
|
||||
props: AccountAbstractionAnalyticsProps,
|
||||
) {
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
|
||||
const [rangeType, setRangeType] = useQueryState(
|
||||
@@ -62,6 +79,8 @@ export function AccountAbstractionAnalytics(props: {
|
||||
to: rangeType === "custom" ? to : getLastNDaysRange(rangeType).to,
|
||||
type: rangeType,
|
||||
};
|
||||
const resolvedFrom = range.from;
|
||||
const resolvedTo = range.to;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -100,20 +119,23 @@ export function AccountAbstractionAnalytics(props: {
|
||||
userOpStats={props.userOpStats}
|
||||
/>
|
||||
|
||||
<SponsoredTransactionsChartCard
|
||||
<GasMetricsChartCard
|
||||
isPending={isLoading}
|
||||
userOpStats={props.userOpStats}
|
||||
/>
|
||||
|
||||
<SponsoredTransactionsTable
|
||||
client={props.client}
|
||||
from={from.toISOString()}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
to={to.toISOString()}
|
||||
variant="project"
|
||||
/>
|
||||
{resolvedFrom && resolvedTo && (
|
||||
<SponsoredTransactionsTable
|
||||
client={props.client}
|
||||
from={resolvedFrom.toISOString()}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
to={resolvedTo.toISOString()}
|
||||
{...(props.variant === "project"
|
||||
? { projectId: props.projectId, variant: "project" as const }
|
||||
: { projects: props.projects, variant: "team" as const })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { defineChain, getContract, type ThirdwebClient } from "thirdweb";
|
||||
import { getCompilerMetadata } from "thirdweb/contract";
|
||||
import { getSortedDeployedContracts } from "@/api/project/getSortedDeployedContracts";
|
||||
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -60,9 +60,9 @@ async function AsyncYourFactories(props: {
|
||||
projectSlug: string;
|
||||
clientThirdwebClient: ThirdwebClient;
|
||||
}) {
|
||||
const deployedContracts = await getSortedDeployedContracts({
|
||||
const deployedContracts = await getFilteredProjectContracts({
|
||||
authToken: props.authToken,
|
||||
deploymentType: undefined,
|
||||
type: "non-token-contracts",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ type AccountAbstractionSettingsPageProps = {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
validTeamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
@@ -565,6 +566,7 @@ export function AccountAbstractionSettingsPage(
|
||||
<GatedSwitch
|
||||
currentPlan={props.validTeamPlan}
|
||||
requiredPlan="starter"
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: field.value.enabled,
|
||||
id: "server-verifier-switch",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { DocLink } from "@/components/blocks/DocLink";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import type { AIUsageStats } from "@/types/analytics";
|
||||
|
||||
@@ -17,7 +19,8 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
aiUsageStats: AIUsageStats[];
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
viewMoreLink: string | undefined;
|
||||
emptyChartState?: React.ReactElement | undefined;
|
||||
}) {
|
||||
const { aiUsageStats } = props;
|
||||
|
||||
@@ -52,36 +55,46 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
chartData.length === 0 ||
|
||||
chartData.every((data) => data.tokens === 0);
|
||||
|
||||
const total = useMemo(() => {
|
||||
return chartData.reduce((acc, curr) => acc + curr.tokens, 0);
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
chartClassName="h-[275px] w-full aspect-auto"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
disabled={disableActions}
|
||||
fileName="AI Token Usage"
|
||||
getData={async () => {
|
||||
const header = ["Date", "Tokens"];
|
||||
const rows = chartData.map((data) => [
|
||||
data.time,
|
||||
data.tokens.toString(),
|
||||
]);
|
||||
return { header, rows };
|
||||
}}
|
||||
props.viewMoreLink ? (
|
||||
<TotalValueChartHeader
|
||||
isPending={props.isPending}
|
||||
total={total}
|
||||
title={props.title}
|
||||
viewMoreLink={props.viewMoreLink}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
disabled={disableActions}
|
||||
fileName="AI Token Usage"
|
||||
getData={async () => {
|
||||
const header = ["Date", "Tokens"];
|
||||
const rows = chartData.map((data) => [
|
||||
data.time,
|
||||
data.tokens.toString(),
|
||||
]);
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<AiTokenUsageEmptyChartState />}
|
||||
emptyChartState={props.emptyChartState}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend={false}
|
||||
@@ -97,20 +110,27 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function AiTokenUsageEmptyChartState() {
|
||||
function _AiTokenUsageEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
|
||||
No data available
|
||||
</h3>
|
||||
<p className="mb-6 text-center text-sm">
|
||||
Integrate thirdweb AI to interact with any EVM chain using natural
|
||||
language
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
icon={BotIcon}
|
||||
label="Get Started"
|
||||
link="https://portal.thirdweb.com/ai/chat"
|
||||
/>
|
||||
</div>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="rounded-full gap-2"
|
||||
>
|
||||
<Link href="https://portal.thirdweb.com/ai/chat" target="_blank">
|
||||
Get Started
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ function AiAnalyticsUI({ stats, isPending }: AiAnalyticsProps) {
|
||||
return (
|
||||
<AiTokenUsageChartCardUI
|
||||
title="Token Usage"
|
||||
description="The total number of tokens used for AI interactions on your project."
|
||||
aiUsageStats={stats || []}
|
||||
isPending={isPending}
|
||||
viewMoreLink={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getSessionById, getSessions } from "../../api/session";
|
||||
@@ -39,7 +39,7 @@ export default async function Page(props: {
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
redirect(`/team/${params.team_slug}/${params.project_slug}/ai`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -410,7 +410,7 @@ export function ChatBar(props: {
|
||||
if (message.trim() === "" && images.length === 0) return;
|
||||
handleSubmit(message);
|
||||
}}
|
||||
variant="primary"
|
||||
variant="default"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||